119 SPDX-FileCopyrightText headers and the README's licence line. The distinction that matters here: LINUXexpert-org appears in this repository in two completely different roles. As a copyright holder in the SPDX headers, which is what changes, and as the GitHub organisation in the module path and 64 import statements, which does not -- the repository still lives at github.com/LINUXexpert-org/stalwart-migrator, and rewriting that would not be a licence change, it would break the build. Both replacements are anchored to their copyright forms, so an import path cannot match either. Import count is 64 before and after, and go.mod is untouched. LICENSE untouched: the FSF's copyright on the GPL text and the "<name of author>" placeholders are not ours to edit. go vet, go build and go test all clean.
209 lines
6.7 KiB
Go
209 lines
6.7 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// 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
|
|
}
|