217 lines
8.4 KiB
Go
217 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The move-plan lane: signed plan, sandboxed moves, dry-run then
|
|
// apply with rollback and verify, archive-only — no delete, no overwrite.
|
|
func TestDcnPlanCheckApplyVerify(t *testing.T) {
|
|
root := t.TempDir()
|
|
mk := func(rel, body string) {
|
|
p := filepath.Join(root, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
mk("Downloads/setup.exe", "xxxxxxxx")
|
|
mk("Downloads/photo.jpg", "img")
|
|
mk("Documents/notes.txt", "n")
|
|
mk("keep/.env", "secret")
|
|
if err := os.MkdirAll(filepath.Join(root, "Existing"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
size8 := int64(8)
|
|
excludes := []string{".env*"}
|
|
|
|
// dry run finds every kind of problem and touches nothing
|
|
bad := []dcnMove{
|
|
{From: "Downloads/setup.exe", To: "Existing"}, // target exists
|
|
{From: "../outside", To: "Archive/x"}, // escape
|
|
{From: "keep/.env", To: "Archive/env"}, // excluded
|
|
{From: "nope.txt", To: "Archive/nope.txt"}, // missing source
|
|
{From: "Documents", To: "Documents/inner"}, // into itself
|
|
{From: "Downloads/photo.jpg", To: "Downloads/photo.jpg"}, // same
|
|
}
|
|
pr := dcnCheckPlan(root, excludes, bad)
|
|
if len(pr) != len(bad) {
|
|
t.Fatalf("every bad move must be a problem: %+v", pr)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Downloads", "setup.exe")); err != nil {
|
|
t.Fatal("dry run must not touch files")
|
|
}
|
|
|
|
// a good plan: reorganize + archive
|
|
good := &dcnPlanTerms{Kind: "dcn_move_plan", StoreID: 1, PlanID: 77, Root: root, Archive: "Archive",
|
|
Moves: []dcnMove{
|
|
{From: "Downloads/setup.exe", To: "Archive/installers/setup.exe", SizeBytes: &size8},
|
|
{From: "Downloads/photo.jpg", To: "Pictures/photo.jpg"},
|
|
{From: "Documents/notes.txt", To: "Documents/Notes/notes.txt"},
|
|
}}
|
|
if pr := dcnCheckPlan(root, excludes, good.Moves); len(pr) != 0 {
|
|
t.Fatalf("good plan must pass the check: %+v", pr)
|
|
}
|
|
rep := dcnApplyPlan(root, excludes, good)
|
|
if rep["state"] != "ok" || rep["applied"] != 3 || rep["verified"] != 3 {
|
|
t.Fatalf("apply+verify: %+v", rep)
|
|
}
|
|
for _, p := range []string{"Archive/installers/setup.exe", "Pictures/photo.jpg", "Documents/Notes/notes.txt"} {
|
|
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(p))); err != nil {
|
|
t.Fatalf("%s must exist after apply", p)
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Downloads", "setup.exe")); err == nil {
|
|
t.Fatal("source must be gone after a move")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "keep", ".env")); err != nil {
|
|
t.Fatal("untouched files stay untouched")
|
|
}
|
|
|
|
// a plan that fails mid-way rolls back what it did
|
|
mk("A/one.txt", "1")
|
|
mk("A/two.txt", "2")
|
|
if err := os.MkdirAll(filepath.Join(root, "B", "two.txt"), 0o755); err != nil { // a DIR where a file wants to go
|
|
t.Fatal(err)
|
|
}
|
|
// pre-check catches the collision (target exists) → refused, nothing moved
|
|
rb := &dcnPlanTerms{Kind: "dcn_move_plan", StoreID: 1, PlanID: 78, Root: root,
|
|
Moves: []dcnMove{{From: "A/one.txt", To: "B/one.txt"}, {From: "A/two.txt", To: "B/two.txt"}}}
|
|
rep = dcnApplyPlan(root, excludes, rb)
|
|
if rep["state"] != "refused" {
|
|
t.Fatalf("a colliding plan must be refused whole: %+v", rep)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "A", "one.txt")); err != nil {
|
|
t.Fatal("a refused plan must change nothing")
|
|
}
|
|
|
|
// signature wall: a plan must be signed by the pinned key and parse as a plan
|
|
pub, priv, _ := ed25519.GenerateKey(nil)
|
|
pubB64 := base64.StdEncoding.EncodeToString(pub)
|
|
terms := map[string]any{"kind": "dcn_move_plan", "store_id": 1, "plan_id": 79, "server": "box", "root": root,
|
|
"archive": "Archive", "moves": []map[string]any{{"from": "a", "to": "b"}},
|
|
"expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)}
|
|
rec := signedRec(t, priv, terms)
|
|
if p, err := dcnVerifyPlan(pubB64, rec.TermsCanonical, rec.Signature); err != nil || p.PlanID != 79 {
|
|
t.Fatalf("a signed plan must verify: %v", err)
|
|
}
|
|
other, _, _ := ed25519.GenerateKey(nil)
|
|
if _, err := dcnVerifyPlan(base64.StdEncoding.EncodeToString(other), rec.TermsCanonical, rec.Signature); err == nil {
|
|
t.Fatal("a plan signed by another key must not verify")
|
|
}
|
|
terms["kind"] = "dcn_store_grant"
|
|
rec2 := signedRec(t, priv, terms)
|
|
if _, err := dcnVerifyPlan(pubB64, rec2.TermsCanonical, rec2.Signature); err == nil {
|
|
t.Fatal("a store grant must not pass as a plan")
|
|
}
|
|
|
|
// the vocabulary carries the op, and there is still no delete
|
|
ops := mgmtOpVocabulary()
|
|
hasPlan := false
|
|
for _, o := range ops {
|
|
if o == "dcn_plan_apply" {
|
|
hasPlan = true
|
|
}
|
|
if o == "dcn_delete" || o == "dcn_remove" || o == "stepup_verify" {
|
|
t.Fatal("no delete op may exist")
|
|
}
|
|
}
|
|
if !hasPlan {
|
|
t.Fatal("dcn_plan_apply must be in the reported vocabulary")
|
|
}
|
|
_ = json.Marshal
|
|
}
|
|
|
|
// Walls the first cut missed: paths that clean to the same target are one target;
|
|
// a stale size refuses BEFORE anything moves; a failure mid-plan really rolls
|
|
// back; a plan needs an expiry; the report never leaves the root.
|
|
func TestDcnPlanReviewWalls(t *testing.T) {
|
|
root := t.TempDir()
|
|
mk := func(rel, body string) {
|
|
p := filepath.Join(root, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
mk("one.txt", "ONE")
|
|
mk("two.txt", "TWO")
|
|
|
|
// two targets that normalize to the same file — the second must be a problem
|
|
pr := dcnCheckPlan(root, nil, []dcnMove{
|
|
{From: "one.txt", To: "Archive/x.txt"}, {From: "two.txt", To: "./Archive//x.txt"}})
|
|
if len(pr) != 1 || pr[0].Index != 1 {
|
|
t.Fatalf("normalized duplicate target must be refused: %+v", pr)
|
|
}
|
|
pr = dcnCheckPlan(root, nil, []dcnMove{
|
|
{From: "one.txt", To: "Archive/x.txt"}, {From: "two.txt", To: "archive/X.TXT"}})
|
|
if len(pr) != 1 {
|
|
t.Fatalf("case-folded duplicate target must be refused: %+v", pr)
|
|
}
|
|
// the apply path refuses the whole plan and touches nothing
|
|
rep := dcnApplyPlan(root, nil, &dcnPlanTerms{PlanID: 1, Moves: []dcnMove{
|
|
{From: "one.txt", To: "Archive/x.txt"}, {From: "two.txt", To: "./Archive//x.txt"}}})
|
|
if rep["state"] != "refused" {
|
|
t.Fatalf("apply must refuse a colliding plan: %+v", rep)
|
|
}
|
|
if b, _ := os.ReadFile(filepath.Join(root, "one.txt")); string(b) != "ONE" {
|
|
t.Fatal("nothing may move when a plan is refused")
|
|
}
|
|
|
|
// size in the plan ≠ size on disk → refused before moving
|
|
wrong := int64(99)
|
|
pr = dcnCheckPlan(root, nil, []dcnMove{{From: "one.txt", To: "Archive/one.txt", SizeBytes: &wrong}})
|
|
if len(pr) != 1 || !strings.Contains(pr[0].Why, "size") {
|
|
t.Fatalf("a stale catalog size must refuse before the move: %+v", pr)
|
|
}
|
|
|
|
// a real mid-plan failure rolls the first move back. Move 2's target
|
|
// parent is a FILE ("blocker"), so MkdirAll fails after move 1 succeeded.
|
|
mk("blocker", "i am a file")
|
|
rep = dcnApplyPlan(root, nil, &dcnPlanTerms{PlanID: 2, Moves: []dcnMove{
|
|
{From: "one.txt", To: "Moved/one.txt"}, {From: "two.txt", To: "blocker/two.txt"}}})
|
|
if rep["state"] != "failed" || rep["failed_at"] != 1 || rep["rolled_back"] != 1 {
|
|
t.Fatalf("mid-plan failure must roll back: %+v", rep)
|
|
}
|
|
if b, _ := os.ReadFile(filepath.Join(root, "one.txt")); string(b) != "ONE" {
|
|
t.Fatal("rolled-back source must be back in place with its content")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Moved", "one.txt")); err == nil {
|
|
t.Fatal("rolled-back target must be gone")
|
|
}
|
|
|
|
// a plan without an expiry never verifies
|
|
pub, priv, _ := ed25519.GenerateKey(nil)
|
|
pubB64 := base64.StdEncoding.EncodeToString(pub)
|
|
terms := map[string]any{"kind": "dcn_move_plan", "store_id": 1, "plan_id": 3, "root": root,
|
|
"archive": "Archive", "moves": []map[string]any{{"from": "a", "to": "b"}}}
|
|
rec := signedRec(t, priv, terms)
|
|
if _, err := dcnVerifyPlan(pubB64, rec.TermsCanonical, rec.Signature); err == nil {
|
|
t.Fatal("a plan with no expiry must not verify")
|
|
}
|
|
|
|
// a symlinked .agent pointing outside the root → no report file written
|
|
outside := t.TempDir()
|
|
root2 := t.TempDir()
|
|
if err := os.Symlink(outside, filepath.Join(root2, ".agent")); err != nil {
|
|
t.Skip("symlinks unavailable")
|
|
}
|
|
if p := dcnReportPath(root2, 7); p != "" {
|
|
t.Fatalf("report path must not resolve outside the root: %q", p)
|
|
}
|
|
if p := dcnReportPath(root, 7); p == "" || !strings.HasPrefix(p, root) {
|
|
t.Fatalf("report path inside the root must resolve: %q", p)
|
|
}
|
|
}
|