mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-13 00:48:29 -04:00
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
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user