package backup import ( "archive/tar" "compress/gzip" "fmt" "io" "os" "path/filepath" "strings" "time" ) const dirName = "DankMaterialShell" func ConfigDir() (string, error) { configDir, err := os.UserConfigDir() if err != nil { return "", err } return filepath.Join(configDir, dirName), nil } func DefaultArchiveName() string { return fmt.Sprintf("dms-backup-%s.tar.gz", time.Now().Format("20060102-150405")) } func Create(outputPath string) error { srcDir, err := ConfigDir() if err != nil { return err } if _, err := os.Stat(srcDir); err != nil { return fmt.Errorf("no DMS configuration found at %s: %w", srcDir, err) } out, err := os.Create(outputPath) if err != nil { return err } defer out.Close() gz := gzip.NewWriter(out) defer gz.Close() tw := tar.NewWriter(gz) defer tw.Close() return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } rel, err := filepath.Rel(srcDir, path) if err != nil { return err } if rel == "." { return nil } var link string if info.Mode()&os.ModeSymlink != 0 { if link, err = os.Readlink(path); err != nil { return err } } header, err := tar.FileInfoHeader(info, link) if err != nil { return err } header.Name = filepath.ToSlash(filepath.Join(dirName, rel)) if err := tw.WriteHeader(header); err != nil { return err } if !info.Mode().IsRegular() { return nil } f, err := os.Open(path) if err != nil { return err } defer f.Close() _, err = io.Copy(tw, f) return err }) } func Restore(archivePath string) (string, error) { dstDir, err := ConfigDir() if err != nil { return "", err } if err := validateArchive(archivePath); err != nil { return "", err } previous := "" if _, err := os.Stat(dstDir); err == nil { previous = dstDir + ".pre-restore-" + time.Now().Format("20060102-150405") if err := os.Rename(dstDir, previous); err != nil { return "", fmt.Errorf("failed to move existing configuration aside: %w", err) } } if err := extract(archivePath, filepath.Dir(dstDir)); err != nil { if previous != "" { os.RemoveAll(dstDir) os.Rename(previous, dstDir) } return "", err } return previous, nil } func validateArchive(archivePath string) error { hasSettings := false err := walkArchive(archivePath, func(header *tar.Header, _ *tar.Reader) error { name := filepath.ToSlash(filepath.Clean(header.Name)) if strings.HasPrefix(name, "..") || filepath.IsAbs(header.Name) { return fmt.Errorf("unsafe path in archive: %s", header.Name) } if !strings.HasPrefix(name, dirName+"/") && name != dirName { return fmt.Errorf("not a DMS backup: unexpected entry %s", header.Name) } if name == dirName+"/settings.json" { hasSettings = true } return nil }) if err != nil { return err } if !hasSettings { return fmt.Errorf("not a DMS backup: settings.json missing from archive") } return nil } func extract(archivePath, destParent string) error { return walkArchive(archivePath, func(header *tar.Header, tr *tar.Reader) error { target := filepath.Join(destParent, filepath.Clean(header.Name)) switch header.Typeflag { case tar.TypeDir: return os.MkdirAll(target, os.FileMode(header.Mode)) case tar.TypeSymlink: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } return os.Symlink(header.Linkname, target) case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) if err != nil { return err } defer f.Close() _, err = io.Copy(f, tr) return err default: return nil } }) } func walkArchive(archivePath string, visit func(*tar.Header, *tar.Reader) error) error { f, err := os.Open(archivePath) if err != nil { return err } defer f.Close() gz, err := gzip.NewReader(f) if err != nil { return fmt.Errorf("not a gzip archive: %w", err) } defer gz.Close() tr := tar.NewReader(gz) for { header, err := tr.Next() if err == io.EOF { return nil } if err != nil { return err } if err := visit(header, tr); err != nil { return err } } }