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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user