package volsnap import ( "os" "path/filepath" "testing" "github.com/alexei/tinyforge/internal/store" ) func mkDirWith(t *testing.T, dir, fname, content string) { t.Helper() if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, fname), []byte(content), 0o600); err != nil { t.Fatal(err) } } func readIn(t *testing.T, dir, fname string) string { t.Helper() b, err := os.ReadFile(filepath.Join(dir, fname)) if err != nil { t.Fatalf("read %s/%s: %v", dir, fname, err) } return string(b) } func TestSwapVolumeDir_ReplacesAndPreservesOld(t *testing.T) { root := t.TempDir() live := filepath.Join(root, "data") tmp := filepath.Join(root, ".data.tmp") old := filepath.Join(root, ".data.old") mkDirWith(t, live, "f.txt", "ORIGINAL") mkDirWith(t, tmp, "f.txt", "RESTORED") hadOld, err := swapVolumeDir(live, tmp, old) if err != nil { t.Fatalf("swap: %v", err) } if !hadOld { t.Error("hadOld should be true when a live dir existed") } if got := readIn(t, live, "f.txt"); got != "RESTORED" { t.Errorf("live = %q, want RESTORED", got) } if got := readIn(t, old, "f.txt"); got != "ORIGINAL" { t.Errorf("old = %q, want ORIGINAL (prior live preserved)", got) } } func TestSwapVolumeDir_MissingLive(t *testing.T) { root := t.TempDir() live := filepath.Join(root, "data") // does not exist tmp := filepath.Join(root, ".data.tmp") old := filepath.Join(root, ".data.old") mkDirWith(t, tmp, "f.txt", "RESTORED") hadOld, err := swapVolumeDir(live, tmp, old) if err != nil { t.Fatalf("swap: %v", err) } if hadOld { t.Error("hadOld should be false when no live dir existed") } if got := readIn(t, live, "f.txt"); got != "RESTORED" { t.Errorf("live = %q, want RESTORED", got) } } func TestSwapVolumeDir_RevertsOnSecondRenameFailure(t *testing.T) { // The data-loss-critical path: the live→old rename succeeds, then tmp→live // fails (here: tmp is absent). swapVolumeDir MUST self-revert old→live so // the live dir is never left missing, and old must not be left dangling. root := t.TempDir() live := filepath.Join(root, "data") old := filepath.Join(root, ".data.old") mkDirWith(t, live, "f.txt", "ORIGINAL") hadOld, err := swapVolumeDir(live, filepath.Join(root, ".data.tmp" /* absent */), old) if err == nil { t.Fatal("expected swap to fail when tmp is absent") } if got := readIn(t, live, "f.txt"); got != "ORIGINAL" { t.Errorf("live = %q, want ORIGINAL restored by self-revert", got) } if _, statErr := os.Stat(old); !os.IsNotExist(statErr) { t.Errorf("old dir should have been renamed back to live, not left dangling") } _ = hadOld } func TestStagingDirs_SameParentAsLive(t *testing.T) { // R2 invariant: tmp and old must be siblings of the live dir's parent so // every rename in the swap is intra-filesystem (atomic). live := filepath.FromSlash("/srv/data/postgres") tmp, old := stagingDirs(live, "tok", 3) wantParent := filepath.Dir(live) if filepath.Dir(tmp) != wantParent || filepath.Dir(old) != wantParent { t.Errorf("staging dirs not siblings of live's parent: tmp=%s old=%s parent=%s", tmp, old, wantParent) } if tmp == old { t.Error("tmp and old must be distinct paths") } } func TestRollbackSwaps_RestoresOriginals(t *testing.T) { root := t.TempDir() var done []swap for _, name := range []string{"vol0", "vol1"} { live := filepath.Join(root, name) tmp := filepath.Join(root, "."+name+".tmp") old := filepath.Join(root, "."+name+".old") mkDirWith(t, live, "f.txt", "ORIGINAL-"+name) mkDirWith(t, tmp, "f.txt", "RESTORED-"+name) hadOld, err := swapVolumeDir(live, tmp, old) if err != nil { t.Fatalf("swap %s: %v", name, err) } done = append(done, swap{live: live, old: old, tmp: tmp, hadOld: hadOld}) } // Both are now RESTORED; rolling back must return both to ORIGINAL. rollbackSwaps(done) for _, name := range []string{"vol0", "vol1"} { if got := readIn(t, filepath.Join(root, name), "f.txt"); got != "ORIGINAL-"+name { t.Errorf("%s after rollback = %q, want ORIGINAL-%s", name, got, name) } } } func TestRollbackSwaps_PartialLeavesUnswappedIntact(t *testing.T) { root := t.TempDir() // vol0 swaps successfully; vol1 "fails" (its tmp is absent) so only vol0 is // recorded in done. Rollback of vol0 must restore the original. live0 := filepath.Join(root, "vol0") tmp0 := filepath.Join(root, ".vol0.tmp") old0 := filepath.Join(root, ".vol0.old") mkDirWith(t, live0, "f.txt", "ORIGINAL-0") mkDirWith(t, tmp0, "f.txt", "RESTORED-0") hadOld, err := swapVolumeDir(live0, tmp0, old0) if err != nil { t.Fatalf("swap vol0: %v", err) } live1 := filepath.Join(root, "vol1") mkDirWith(t, live1, "f.txt", "ORIGINAL-1") if _, err := swapVolumeDir(live1, filepath.Join(root, ".vol1.tmp" /* absent */), filepath.Join(root, ".vol1.old")); err == nil { t.Fatal("expected vol1 swap to fail (tmp absent)") } rollbackSwaps([]swap{{live: live0, old: old0, tmp: tmp0, hadOld: hadOld}}) if got := readIn(t, live0, "f.txt"); got != "ORIGINAL-0" { t.Errorf("vol0 after rollback = %q, want ORIGINAL-0", got) } // vol1 was never swapped; its original must be untouched. if got := readIn(t, live1, "f.txt"); got != "ORIGINAL-1" { t.Errorf("vol1 = %q, want ORIGINAL-1 (never swapped)", got) } } func TestPreflightResolve_AllOrNothing(t *testing.T) { eng, st, base := newRestoreEngine(t) _ = eng w, err := st.CreateWorkload(store.Workload{ Name: "app", Kind: "project", SourceKind: "image", SourceConfig: `{"image":"x","port":80,"volumes":[` + `{"source":"data","target":"/data","scope":"project"},` + `{"source":"var","target":"/var","scope":"stage"}]}`, }) if err != nil { t.Fatalf("create workload: %v", err) } settings, _ := st.GetSettings() // A snapshotted target no longer declared by the workload ⇒ whole abort (C3). if _, err := preflightResolve(st, w, settings, []SnapshotVolume{ {Index: 0, Target: "/data", Scope: "project", Source: "data"}, {Index: 1, Target: "/gone", Scope: "project", Source: "gone"}, }); err == nil { t.Fatal("expected all-or-nothing abort when a target is no longer declared") } // All declared ⇒ resolves every volume under BaseVolumePath. resolved, err := preflightResolve(st, w, settings, []SnapshotVolume{ {Index: 0, Target: "/data", Scope: "project", Source: "data"}, {Index: 1, Target: "/var", Scope: "stage", Source: "var"}, }) if err != nil { t.Fatalf("preflight: %v", err) } if len(resolved) != 2 { t.Fatalf("resolved %d volumes, want 2", len(resolved)) } for _, rv := range resolved { if ok, _ := pathWithinBase(base, rv.LivePath); !ok { t.Errorf("volume %q resolved to %q, outside base %q", rv.Target, rv.LivePath, base) } } } // TestPreflightResolve_IgnoresManifestSource is the regression guard for the // security fix: a tampered manifest whose Source tries to escape (../../etc) // must NOT redirect the swap target — the host path is re-derived from the // workload's CURRENT trusted config (Source "data"), staying under the base. func TestPreflightResolve_IgnoresManifestSource(t *testing.T) { _, st, base := newRestoreEngine(t) w, err := st.CreateWorkload(store.Workload{ Name: "app", Kind: "project", SourceKind: "image", SourceConfig: `{"image":"x","port":80,"volumes":[{"source":"data","target":"/data","scope":"project"}]}`, }) if err != nil { t.Fatalf("create workload: %v", err) } settings, _ := st.GetSettings() resolved, err := preflightResolve(st, w, settings, []SnapshotVolume{ {Index: 0, Target: "/data", Scope: "project", Source: "../../../../etc"}, }) if err != nil { t.Fatalf("preflight: %v", err) } if len(resolved) != 1 { t.Fatalf("resolved %d, want 1", len(resolved)) } if ok, _ := pathWithinBase(base, resolved[0].LivePath); !ok { t.Errorf("manifest Source escaped containment: resolved %q outside base %q", resolved[0].LivePath, base) } if filepath.Base(resolved[0].LivePath) != "data" { t.Errorf("expected target derived from current config (data), got %q", resolved[0].LivePath) } } func TestArchiveUncompressedSize(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "a.txt"), "hello") // 5 if err := os.MkdirAll(filepath.Join(root, "sub"), 0o700); err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(root, "sub", "b.txt"), "world!") // 6 dest := filepath.Join(t.TempDir(), "snap.tar.gz") if _, err := writeArchive(dest, []VolumeRef{{Target: "/d", Scope: "project", Source: "d", HostPath: root}}); err != nil { t.Fatal(err) } per, total, err := archiveUncompressedSize(dest, maxRestoreUncompressedBytes) if err != nil { t.Fatalf("size: %v", err) } if total != 11 { t.Errorf("total = %d, want 11", total) } if per[0] != 11 { t.Errorf("perIndex[0] = %d, want 11", per[0]) } if _, _, err := archiveUncompressedSize(dest, 4); err == nil { t.Error("expected sizing to abort past the decompression cap") } } func TestFreeDiskBytes(t *testing.T) { n, err := freeDiskBytes(t.TempDir()) if err != nil { t.Fatalf("freeDiskBytes: %v", err) } if n == 0 { t.Error("expected non-zero free space on the temp filesystem") } }