Initial commit: stalwart-migrator design and scaffolding

In-place upgrade tool for Stalwart Mail Server (0.15.5 -> latest) with
checkpointed rollback and post-migration validation. Design stage; see
ARCHITECTURE.md.
This commit is contained in:
2026-08-22 18:17:17 -07:00
commit 719a945d64
71 changed files with 6677 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
package backup
import (
"os"
"path/filepath"
"testing"
)
func writeTree(t *testing.T, root string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "a.db"), []byte("alpha data"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "sub", "b.db"), []byte("bravo data, a bit longer"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("a.db", filepath.Join(root, "link-to-a")); err != nil {
t.Fatal(err)
}
}
func TestCopyDataDirAndVerify(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
manifest, err := CopyDataDir(src, dst)
if err != nil {
t.Fatalf("CopyDataDir: %v", err)
}
if len(manifest.Files) != 2 {
t.Fatalf("manifest has %d files, want 2 (symlinks aren't hashed): %+v", len(manifest.Files), manifest.Files)
}
if manifest.TotalBytes != int64(len("alpha data")+len("bravo data, a bit longer")) {
t.Errorf("TotalBytes = %d, want %d", manifest.TotalBytes, len("alpha data")+len("bravo data, a bit longer"))
}
// The copy should be byte-identical, including the symlink.
got, err := os.ReadFile(filepath.Join(dst, "sub", "b.db"))
if err != nil || string(got) != "bravo data, a bit longer" {
t.Errorf("copied sub/b.db = %q, %v", got, err)
}
target, err := os.Readlink(filepath.Join(dst, "link-to-a"))
if err != nil || target != "a.db" {
t.Errorf("copied symlink target = %q, %v, want a.db", target, err)
}
if err := VerifyDataDirBackup(dst, manifest); err != nil {
t.Errorf("VerifyDataDirBackup on an untouched copy: %v", err)
}
}
func TestCopyDataDirRefusesSelfCopy(t *testing.T) {
dir := filepath.Join(t.TempDir(), "data")
os.MkdirAll(dir, 0o755)
if _, err := CopyDataDir(dir, dir); err == nil {
t.Fatal("CopyDataDir(dir, dir) should refuse to copy a directory onto itself")
}
}
func TestCopyDataDirRefusesNestedDestination(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
nested := filepath.Join(src, "backup")
if _, err := CopyDataDir(src, nested); err == nil {
t.Fatal("CopyDataDir should refuse a destination nested inside the source")
}
}
func TestCopyDataDirRetryClearsStaleFiles(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
// Simulate a stale partial copy from a previous failed attempt.
os.MkdirAll(dst, 0o755)
if err := os.WriteFile(filepath.Join(dst, "stale-leftover.tmp"), []byte("junk"), 0o644); err != nil {
t.Fatal(err)
}
manifest, err := CopyDataDir(src, dst)
if err != nil {
t.Fatalf("CopyDataDir: %v", err)
}
if _, err := os.Stat(filepath.Join(dst, "stale-leftover.tmp")); !os.IsNotExist(err) {
t.Error("stale-leftover.tmp should have been cleared by a fresh copy, but still exists")
}
if err := VerifyDataDirBackup(dst, manifest); err != nil {
t.Errorf("VerifyDataDirBackup after retry: %v", err)
}
}
func TestVerifyDataDirBackupDetectsTampering(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
manifest, err := CopyDataDir(src, dst)
if err != nil {
t.Fatalf("CopyDataDir: %v", err)
}
// Corrupt the backup after the fact - Verify must catch it.
if err := os.WriteFile(filepath.Join(dst, "a.db"), []byte("corrupted!"), 0o644); err != nil {
t.Fatal(err)
}
if err := VerifyDataDirBackup(dst, manifest); err == nil {
t.Fatal("VerifyDataDirBackup should have detected the tampered file")
}
}
func TestManifestChecksumIsDeterministic(t *testing.T) {
src := filepath.Join(t.TempDir(), "data")
os.MkdirAll(src, 0o755)
writeTree(t, src)
dst := filepath.Join(t.TempDir(), "data-backup")
m1, err := CopyDataDir(src, dst)
if err != nil {
t.Fatal(err)
}
sum1, err := m1.Checksum()
if err != nil {
t.Fatal(err)
}
dst2 := filepath.Join(t.TempDir(), "data-backup-2")
m2, err := CopyDataDir(src, dst2)
if err != nil {
t.Fatal(err)
}
sum2, err := m2.Checksum()
if err != nil {
t.Fatal(err)
}
if sum1 != sum2 {
t.Errorf("two copies of the same source produced different manifest checksums: %s vs %s", sum1, sum2)
}
}
func TestWriteReadManifestRoundtrip(t *testing.T) {
m := &Manifest{
SourceDir: "/var/lib/stalwart",
Files: []ManifestEntry{{RelPath: "a.db", SHA256: "deadbeef", Size: 42}},
TotalBytes: 42,
}
path := filepath.Join(t.TempDir(), "manifest.json")
if err := WriteManifest(path, m); err != nil {
t.Fatalf("WriteManifest: %v", err)
}
got, err := ReadManifest(path)
if err != nil {
t.Fatalf("ReadManifest: %v", err)
}
if got.TotalBytes != 42 || len(got.Files) != 1 || got.Files[0].SHA256 != "deadbeef" {
t.Errorf("roundtrip mismatch: %+v", got)
}
}