1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-12 16:38:28 -04:00
Files
DankMaterialShell/core/internal/backup/backup.go
T
bbedward 7974887295 settings: diff-only configs, machine-state split, backup CLI
- settings.json/session.json now store only values that differ from
  spec defaults
- Machine-specific state moves out of settings.json into session.json
- Add dms backup create/restore: tar.gz of ~/.config/DankMaterialShell

fixes #3027
2026-08-11 15:32:01 -04:00

199 lines
4.2 KiB
Go

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
}
}
}