GPLv3's "How to Apply These Terms" asks for a notice in each source file; this is the modern two-line SPDX form of it rather than the full paragraph. 82 files, including tests. The blank line after the header is load-bearing. In Go a comment block immediately preceding `package X` becomes the package doc comment, so without the separator the SPDX lines would be absorbed into the doc for the eleven packages whose doc.go (or main.go) opens with one, and `go doc` would print them. Verified it doesn't.
209 lines
6.7 KiB
Go
209 lines
6.7 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package backup
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Manifest records the per-file checksums produced by CopyDataDir, so a
|
|
// later Verify pass can detect corruption or truncation introduced by the
|
|
// copy itself. It is NOT authentication that the copied store is a valid,
|
|
// openable Stalwart database - confirming that would mean booting the old
|
|
// binary read-only against the backup, which needs config/CLI details this
|
|
// tool doesn't verify yet (see the parallel caveat on
|
|
// stalwartapi.Client.AccountSnapshot). Treat a clean Verify as "the bytes we
|
|
// wrote match the bytes we copied", not "Stalwart can open this".
|
|
type Manifest struct {
|
|
SourceDir string `json:"source_dir"`
|
|
Files []ManifestEntry `json:"files"`
|
|
TotalBytes int64 `json:"total_bytes"`
|
|
}
|
|
|
|
type ManifestEntry struct {
|
|
RelPath string `json:"rel_path"`
|
|
SHA256 string `json:"sha256"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// Checksum returns a single content hash identifying this manifest (and
|
|
// transitively, the exact set of files and bytes it describes), suitable
|
|
// for recording as a checkpoint.Artifact's SHA256 - a directory doesn't
|
|
// have one natural hash, so this stands in for it.
|
|
func (m *Manifest) Checksum() (string, error) {
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
return "", fmt.Errorf("backup: marshal manifest: %w", err)
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
// WriteManifest persists a manifest as JSON next to the backup it describes.
|
|
func WriteManifest(path string, m *Manifest) error {
|
|
data, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("backup: marshal manifest: %w", err)
|
|
}
|
|
if err := os.WriteFile(path, data, 0o640); err != nil {
|
|
return fmt.Errorf("backup: write manifest %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadManifest loads a manifest previously written by WriteManifest.
|
|
func ReadManifest(path string) (*Manifest, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("backup: read manifest %s: %w", path, err)
|
|
}
|
|
var m Manifest
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return nil, fmt.Errorf("backup: parse manifest %s: %w", path, err)
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// CopyDataDir copies srcDir to dstDir the way `cp -a` would (directories,
|
|
// regular files, and symlinks, preserving regular-file permissions),
|
|
// hashing every regular file as it's written. dstDir is cleared first if it
|
|
// already exists, so a retried step after a partial failure produces a
|
|
// clean copy rather than a mix of old and new files - filesystem copies
|
|
// aren't resumable at the byte level in any way worth building here, so a
|
|
// retry just redoes the whole thing.
|
|
func CopyDataDir(srcDir, dstDir string) (*Manifest, error) {
|
|
srcAbs, err := filepath.Abs(srcDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("backup: resolve source %s: %w", srcDir, err)
|
|
}
|
|
dstAbs, err := filepath.Abs(dstDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("backup: resolve destination %s: %w", dstDir, err)
|
|
}
|
|
if srcAbs == dstAbs {
|
|
return nil, fmt.Errorf("backup: source and destination are the same path: %s", srcAbs)
|
|
}
|
|
if strings.HasPrefix(dstAbs+string(filepath.Separator), srcAbs+string(filepath.Separator)) {
|
|
return nil, fmt.Errorf("backup: destination %s is inside source %s - refusing to copy a directory into itself", dstAbs, srcAbs)
|
|
}
|
|
if err := os.RemoveAll(dstAbs); err != nil {
|
|
return nil, fmt.Errorf("backup: clear stale destination %s: %w", dstAbs, err)
|
|
}
|
|
if err := os.MkdirAll(dstAbs, 0o750); err != nil {
|
|
return nil, fmt.Errorf("backup: create destination %s: %w", dstAbs, err)
|
|
}
|
|
|
|
manifest := &Manifest{SourceDir: srcAbs}
|
|
walkErr := filepath.WalkDir(srcAbs, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(srcAbs, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
dstPath := filepath.Join(dstAbs, rel)
|
|
|
|
if d.Type()&fs.ModeSymlink != 0 {
|
|
target, err := os.Readlink(path)
|
|
if err != nil {
|
|
return fmt.Errorf("readlink %s: %w", path, err)
|
|
}
|
|
return os.Symlink(target, dstPath)
|
|
}
|
|
if d.IsDir() {
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.MkdirAll(dstPath, info.Mode().Perm())
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return fmt.Errorf("backup: unsupported file type at %s (mode %s)", path, info.Mode())
|
|
}
|
|
sum, size, err := copyFileWithChecksum(path, dstPath, info.Mode().Perm())
|
|
if err != nil {
|
|
return fmt.Errorf("copy %s: %w", path, err)
|
|
}
|
|
manifest.Files = append(manifest.Files, ManifestEntry{RelPath: rel, SHA256: sum, Size: size})
|
|
manifest.TotalBytes += size
|
|
return nil
|
|
})
|
|
if walkErr != nil {
|
|
return nil, fmt.Errorf("backup: copy %s to %s: %w", srcAbs, dstAbs, walkErr)
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func copyFileWithChecksum(src, dst string, perm fs.FileMode) (sha256Hex string, size int64, err error) {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
defer out.Close()
|
|
h := sha256.New()
|
|
n, err := io.Copy(io.MultiWriter(out, h), in)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
if err := out.Sync(); err != nil {
|
|
return "", 0, err
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), n, nil
|
|
}
|
|
|
|
// VerifyDataDirBackup recomputes every file's checksum under dstDir and
|
|
// compares it against the manifest CopyDataDir produced, returning a single
|
|
// error describing every mismatch found (not just the first) so a human
|
|
// sees the full extent of any corruption in one pass.
|
|
func VerifyDataDirBackup(dstDir string, m *Manifest) error {
|
|
var problems []string
|
|
for _, entry := range m.Files {
|
|
path := filepath.Join(dstDir, entry.RelPath)
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
problems = append(problems, fmt.Sprintf("%s: %v", entry.RelPath, err))
|
|
continue
|
|
}
|
|
h := sha256.New()
|
|
size, err := io.Copy(h, f)
|
|
f.Close()
|
|
if err != nil {
|
|
problems = append(problems, fmt.Sprintf("%s: read error: %v", entry.RelPath, err))
|
|
continue
|
|
}
|
|
if size != entry.Size {
|
|
problems = append(problems, fmt.Sprintf("%s: size %d, want %d", entry.RelPath, size, entry.Size))
|
|
continue
|
|
}
|
|
if got := hex.EncodeToString(h.Sum(nil)); got != entry.SHA256 {
|
|
problems = append(problems, fmt.Sprintf("%s: sha256 %s, want %s", entry.RelPath, got, entry.SHA256))
|
|
}
|
|
}
|
|
if len(problems) > 0 {
|
|
return fmt.Errorf("backup: verification failed for %d of %d file(s):\n%s", len(problems), len(m.Files), strings.Join(problems, "\n"))
|
|
}
|
|
return nil
|
|
}
|