// Package backup handles configuration archives. Callers must stop containers, // exclude concurrent filesystem writers, and manage rollback across files. package backup import ( "archive/tar" "bytes" "compress/gzip" "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path" "path/filepath" "sort" "strings" "time" ) const ( MaxTotalBytes = 32 << 20 MaxFiles = 4096 maxArchiveBytes = 4*((MaxTotalBytes+2)/3) + 4096 magic = "DSHBACKUP\x01" magicV2 = "DSHBACKUP\x02" SchemaVersion = 2 maxManifestBytes = 4096 maxTarBytes = MaxTotalBytes + MaxFiles*4096 + maxManifestBytes ) var allowed = [...]string{".credentials.yaml", "cordis.patch.yml", "profiles/web/package.json", "profiles/web/cordis.patch.yml", "profiles/web/pnpm-lock.yaml", "dsh-ssh.json"} var resources = [...]string{"skins", "pets", "agent-presets", ".agent-presets"} type Metadata struct { DSHVersion string `json:"dsh_version"` CreatedAt time.Time `json:"created_at"` } type manifest struct { SchemaVersion int `json:"schema_version"` Metadata } func allowedPath(name string, recursive bool) bool { if name == "." || len(name) > 1024 || path.Clean(name) != name || strings.ContainsAny(name, "\\\x00:") || path.IsAbs(name) { return false } for _, valid := range allowed { if name == valid { return true } } if recursive { for _, dir := range resources { if strings.HasPrefix(name, dir+"/") { return true } } } return false } type payload struct { Version int `json:"version"` Files map[string][]byte `json:"files"` } func newGCM(key []byte) (cipher.AEAD, error) { b, err := aes.NewCipher(key) if err != nil { return nil, err } return cipher.NewGCM(b) } func openHome(home string) (*os.Root, error) { info, err := os.Lstat(home) if err != nil { return nil, err } if info.Mode()&os.ModeSymlink != 0 { return nil, errors.New("home must not be a symlink") } return os.OpenRoot(home) } // os.Root permits in-root symlinks, so reject them explicitly in each component. func checkPath(root *os.Root, name string) error { parts := strings.Split(name, "/") for i := range parts { info, err := root.Lstat(strings.Join(parts[:i+1], "/")) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("symlink rejected: %s", name) } if i < len(parts)-1 && !info.IsDir() { return fmt.Errorf("not a directory: %s", name) } if i == len(parts)-1 && !info.Mode().IsRegular() { return fmt.Errorf("not a regular file: %s", name) } } return nil } func readBounded(f *os.File, limit int64) ([]byte, error) { info, err := f.Stat() if err != nil { return nil, err } if !info.Mode().IsRegular() { return nil, errors.New("not a regular file") } if info.Size() > limit { return nil, errors.New("backup size limit exceeded") } b, err := io.ReadAll(io.LimitReader(f, limit+1)) if err != nil { return nil, err } if int64(len(b)) > limit { return nil, errors.New("backup size limit exceeded") } return b, nil } // Create writes a 0600 archive using a raw 16, 24, or 32 byte AES key. // Missing allowlisted files are omitted; destination's parent must exist. func Create(home, destination string, key []byte) error { return CreateWithMetadata(home, destination, key, Metadata{}) } // CreateWithMetadata writes an AES-GCM encrypted tar.gz with an authenticated // manifest. Empty resource directories are omitted; callers must exclude writers. func CreateWithMetadata(home, destination string, key []byte, metadata Metadata) error { gcm, err := newGCM(key) if err != nil { return err } root, err := openHome(home) if err != nil { return err } defer root.Close() p := payload{Version: SchemaVersion, Files: make(map[string][]byte)} remaining := int64(MaxTotalBytes) addFile := func(name string) error { if !allowedPath(name, true) { return fmt.Errorf("path not allowed: %q", name) } if err := checkPath(root, name); err != nil { return err } f, err := root.Open(name) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } data, readErr := readBounded(f, remaining) closeErr := f.Close() if readErr != nil { return fmt.Errorf("%s: %w", name, readErr) } if closeErr != nil { return closeErr } remaining -= int64(len(data)) p.Files[name] = data if len(p.Files) > MaxFiles { return errors.New("backup file count limit exceeded") } return nil } for _, name := range allowed { if err := addFile(name); err != nil { return err } } visited := 0 var walk func(string) error walk = func(name string) error { visited++ if visited > MaxFiles*2 || len(name) > 1024 { return errors.New("resource traversal limit exceeded") } info, err := root.Lstat(name) if err != nil { return err } if !info.IsDir() { return addFile(name) } dir, err := root.Open(name) if err != nil { return err } defer dir.Close() for { entries, err := dir.ReadDir(1) if err == io.EOF { return nil } if err != nil { return err } for _, entry := range entries { if err := walk(name + "/" + entry.Name()); err != nil { return err } } } } for _, name := range resources { info, err := root.Lstat(name) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return err } if !info.IsDir() { return fmt.Errorf("resource root is not a directory: %s", name) } if err := walk(name); err != nil { return err } } plain, err := encodeV2(p.Files, metadata) if err != nil { return err } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return err } archive := append([]byte(magicV2), nonce...) archive = gcm.Seal(archive, nonce, plain, []byte(magicV2)) if len(archive) > maxArchiveBytes { return errors.New("backup archive size limit exceeded") } destRoot, err := os.OpenRoot(filepath.Dir(destination)) if err != nil { return err } defer destRoot.Close() return replace(destRoot, filepath.Base(destination), archive) } // Restore authenticates and validates everything before changing home. Files // absent from the archive remain untouched. Replacement is atomic per file only. func Restore(archive, home string, key []byte) error { gcm, err := newGCM(key) if err != nil { return err } aroot, err := os.OpenRoot(filepath.Dir(archive)) if err != nil { return err } defer aroot.Close() if err := checkPath(aroot, filepath.Base(archive)); err != nil { return err } f, err := aroot.Open(filepath.Base(archive)) if err != nil { return err } data, readErr := readBounded(f, maxArchiveBytes) closeErr := f.Close() if readErr != nil { return readErr } if closeErr != nil { return closeErr } if len(data) < len(magic)+gcm.NonceSize()+gcm.Overhead() || (!bytes.HasPrefix(data, []byte(magic)) && !bytes.HasPrefix(data, []byte(magicV2))) { return errors.New("invalid backup format") } nonce := data[len(magic) : len(magic)+gcm.NonceSize()] header := string(data[:len(magic)]) plain, err := gcm.Open(nil, nonce, data[len(magic)+gcm.NonceSize():], []byte(header)) if err != nil { return fmt.Errorf("authenticate backup: %w", err) } var p payload if header == magicV2 { p.Files, err = decodeV2(plain) if err != nil { return err } } else { if err := decodeJSON(plain, &p); err != nil { return err } if p.Version != 1 || p.Files == nil { return errors.New("unsupported backup payload") } } if len(p.Files) > MaxFiles { return errors.New("backup file count limit exceeded") } total := 0 for name, data := range p.Files { if !allowedPath(name, header == magicV2) { return fmt.Errorf("path not allowed: %q", name) } for parent := path.Dir(name); parent != "."; parent = path.Dir(parent) { if _, exists := p.Files[parent]; exists { return errors.New("conflicting backup paths") } } total += len(data) if total > MaxTotalBytes { return errors.New("backup size limit exceeded") } } root, err := openHome(home) if err != nil { return err } defer root.Close() for name := range p.Files { if err := checkPath(root, name); err != nil { return err } } for name, data := range p.Files { if err := root.MkdirAll(filepath.Dir(name), 0700); err != nil { return err } if err := replace(root, name, data); err != nil { return err } } return nil } func decodeJSON(data []byte, value any) error { d := json.NewDecoder(bytes.NewReader(data)) d.DisallowUnknownFields() if err := d.Decode(value); err != nil { return err } if err := d.Decode(new(any)); err != io.EOF { return errors.New("trailing backup data") } return nil } func encodeV2(files map[string][]byte, metadata Metadata) ([]byte, error) { if metadata.CreatedAt.IsZero() { metadata.CreatedAt = time.Now().UTC() } if metadata.DSHVersion == "" { metadata.DSHVersion = "unknown" } data, err := json.Marshal(manifest{SchemaVersion, metadata}) if err != nil { return nil, err } if len(data) > maxManifestBytes { return nil, errors.New("manifest size limit exceeded") } var out bytes.Buffer gz := gzip.NewWriter(&out) tw := tar.NewWriter(gz) write := func(name string, data []byte) error { if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(data)), Typeflag: tar.TypeReg, Format: tar.FormatPAX}); err != nil { return err } _, err := tw.Write(data) return err } if err := write("manifest.json", data); err != nil { return nil, err } names := make([]string, 0, len(files)) for name := range files { names = append(names, name) } sort.Strings(names) for _, name := range names { if err := write(name, files[name]); err != nil { return nil, err } } if err := tw.Close(); err != nil { return nil, err } if err := gz.Close(); err != nil { return nil, err } return out.Bytes(), nil } func decodeV2(plain []byte) (map[string][]byte, error) { source := bytes.NewReader(plain) gz, err := gzip.NewReader(source) if err != nil { return nil, err } defer gz.Close() gz.Multistream(false) unpacked, err := io.ReadAll(io.LimitReader(gz, maxTarBytes+1)) if err != nil { return nil, err } if len(unpacked) > maxTarBytes { return nil, errors.New("expanded archive size limit exceeded") } if source.Len() != 0 { return nil, errors.New("trailing gzip data") } input := bytes.NewReader(unpacked) tr := tar.NewReader(input) files := make(map[string][]byte) seenManifest := false remaining := int64(MaxTotalBytes) for { h, err := tr.Next() if err == io.EOF { break } if err != nil { return nil, err } if h.Typeflag != tar.TypeReg || h.Linkname != "" { return nil, errors.New("non-regular archive entry") } limit := remaining if h.Name == "manifest.json" { if seenManifest { return nil, errors.New("duplicate manifest") } limit = maxManifestBytes } else { if !allowedPath(h.Name, true) { return nil, fmt.Errorf("path not allowed: %q", h.Name) } if _, exists := files[h.Name]; exists { return nil, errors.New("duplicate archive entry") } if len(files) >= MaxFiles { return nil, errors.New("backup file count limit exceeded") } } if h.Size < 0 || h.Size > limit { return nil, errors.New("backup size limit exceeded") } data, err := io.ReadAll(tr) if err != nil { return nil, err } if h.Name == "manifest.json" { var m manifest if err := decodeJSON(data, &m); err != nil { return nil, err } if m.SchemaVersion != SchemaVersion || m.CreatedAt.IsZero() || m.DSHVersion == "" { return nil, errors.New("invalid manifest") } seenManifest = true } else { files[h.Name] = data remaining -= int64(len(data)) } } if !seenManifest || input.Len() != 0 { return nil, errors.New("missing manifest or trailing tar data") } return files, nil } func replace(root *os.Root, name string, data []byte) error { if err := checkPath(root, name); err != nil { return err } var random [16]byte if _, err := rand.Read(random[:]); err != nil { return err } tmp := filepath.Join(filepath.Dir(name), ".backup-"+hex.EncodeToString(random[:])) f, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return err } defer root.Remove(tmp) _, writeErr := f.Write(data) if writeErr == nil { writeErr = f.Sync() } closeErr := f.Close() if writeErr != nil { return writeErr } if closeErr != nil { return closeErr } if err := checkPath(root, name); err != nil { return err } return root.Rename(tmp, name) }