package release import ( "crypto/ed25519" "encoding/json" "errors" "os" "os/exec" "path/filepath" "testing" "time" ) func TestPlan14ReconstructionReplacement(t *testing.T) { for _, attack := range []string{"valid", "wrong predecessor", "omitted host", "wrong source", "stale revision", "bad signature", "visible sync"} { t.Run(attack, func(t *testing.T) { h, s, b := rootHistoryFixture(t) pub, priv, _ := ed25519.GenerateKey(nil) status, err := h.ReconstructionStatus() if err != nil { t.Fatal(err) } now := time.Now().UTC() old := HistoryReconstruction{Schema: 1, Kind: "carlos-release-history", Identity: status.Identity, Revision: status.Revision, Operation: "old", IssuedAt: now, Account: b.Account, App: b.App, Hosts: []HistoryReconstructionHost{{Host: b.Host, Source: "before", Revision: 1, Refusals: []Refusal{{Schema: 1, Host: b.Host, App: b.App, Version: "bad", Kind: RefusalNotUp, RefusedAt: now}}}}, Holds: map[string]HistoryReconstructionHold{"stable": {Window: time.Hour, SetAt: now}}} wire, err := SignRootEnvelope(RootSigner(priv), old) if err != nil { t.Fatal(err) } h.Store = &reconstructionCrashStore{healthyRetryStore: s, stage: "intent", fail: true} if err := h.Reconstruct(pub, wire, b.Account, b.App, now, func(HistoryBinding) (string, error) { return "before", nil }); err == nil { t.Fatal("did not interrupt") } h.Store = s state, err := h.reconstructionState() if err != nil { t.Fatal(err) } replacement := old replacement.Operation = "replacement" replacement.Replaces = state.Pending.Digest replacement.Hosts = []HistoryReconstructionHost{{Host: b.Host, Source: "after", Revision: 1}} replacement.Holds = nil switch attack { case "wrong predecessor": replacement.Replaces = "wrong" case "omitted host": replacement.Hosts = nil case "wrong source": replacement.Hosts[0].Source = "wrong" case "stale revision": replacement.Hosts[0].Revision++ case "bad signature": _, priv, _ = ed25519.GenerateKey(nil) case "visible sync": h.Store = &reconstructionCrashStore{healthyRetryStore: s, stage: "intent", fail: true} } wire, err = SignRootEnvelope(RootSigner(priv), replacement) if err != nil { t.Fatal(err) } resolve := func(HistoryBinding) (string, error) { return "after", nil } st := NewDirStore(t.TempDir()) if _, err := st.Put(t.Context(), HistoryReconstructionKey(b.Account, b.App, status.Identity, status.Revision), wire, ""); err != nil { t.Fatal(err) } err = h.ReceiveReconstruction(t.Context(), st, pub, b, now, resolve) if attack == "visible sync" { if err == nil { t.Fatal("unsynced replacement succeeded") } if _, err = h.Refusals(b); err == nil { t.Fatal("pending replacement granted history") } h.Store = s err = h.ReceiveReconstruction(t.Context(), st, pub, b, now, resolve) } if attack != "valid" && attack != "visible sync" { if err == nil { t.Fatal("invalid replacement succeeded") } if _, err = h.Refusals(b); err == nil { t.Fatal("abandonment removed deny") } return } if err != nil { t.Fatal(err) } refs, err := h.Refusals(b) if err != nil || len(refs) != 1 || refs[0].Version != "bad" { t.Fatalf("lost retained refusal: %+v %v", refs, err) } raw, err := s.Read(rootBakeKey) if err != nil { t.Fatal(err) } var bake rootBakeRecord if err := json.Unmarshal(raw, &bake); err != nil { t.Fatal(err) } if bake.Clock.Holds[channelClockKey(b.Account, b.App, "stable")].Window != time.Hour { t.Fatal("replacement shortened hold") } if err = h.Reconstruct(pub, wire, b.Account, b.App, now, resolve); err != nil { t.Fatal("replacement replay", err) } }) } } func TestPlan14ReconstructionReplacementCrash(t *testing.T) { now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) resolve := func(HistoryBinding) (string, error) { return "after", nil } if dir := os.Getenv("PLAN14_REPLACEMENT_CHILD"); dir != "" { pub, err := os.ReadFile(filepath.Join(dir, "pub")) if err != nil { t.Fatal(err) } wire, err := os.ReadFile(filepath.Join(dir, "wire")) if err != nil { t.Fatal(err) } s := &reconstructionCrashStore{healthyRetryStore: &healthyRetryStore{dir: dir}, stage: os.Getenv("PLAN14_REPLACEMENT_STAGE")} h := &RootHistory{Store: s} t.Fatal("crash boundary not reached", h.Reconstruct(pub, wire, "bab", "console", now, resolve)) } for _, stage := range []string{"intent", "host", "app", rootBakeKey, "complete"} { t.Run(stage, func(t *testing.T) { h, s, b := rootHistoryFixture(t) pub, priv, _ := ed25519.GenerateKey(nil) status, err := h.ReconstructionStatus() if err != nil { t.Fatal(err) } old := HistoryReconstruction{Schema: 1, Kind: "carlos-release-history", Identity: status.Identity, Revision: status.Revision, Operation: "old", IssuedAt: now, Account: b.Account, App: b.App, Hosts: []HistoryReconstructionHost{{Host: b.Host, Source: "before", Revision: 1, Refusals: []Refusal{{Schema: 1, Host: b.Host, App: b.App, Version: "bad", Kind: RefusalNotUp, RefusedAt: now}}}}} wire, err := SignRootEnvelope(RootSigner(priv), old) if err != nil { t.Fatal(err) } h.Store = &reconstructionCrashStore{healthyRetryStore: s, stage: "intent", fail: true} if err := h.Reconstruct(pub, wire, b.Account, b.App, now, func(HistoryBinding) (string, error) { return "before", nil }); err == nil { t.Fatal("missing interrupted intent") } h.Store = s state, err := h.reconstructionState() if err != nil { t.Fatal(err) } g := old g.Operation = "replacement" g.Replaces = state.Pending.Digest g.Hosts = []HistoryReconstructionHost{{Host: b.Host, Source: "after", Revision: 1}} wire, err = SignRootEnvelope(RootSigner(priv), g) if err != nil { t.Fatal(err) } for name, raw := range map[string][]byte{"pub": pub, "wire": wire} { if err := os.WriteFile(filepath.Join(s.dir, name), raw, 0600); err != nil { t.Fatal(err) } } boundary := stage if stage == "host" { boundary = historyKey(b) } if stage == "app" { boundary = historyAppKey(b.Account, b.App) } cmd := exec.Command(os.Args[0], "-test.run=^TestPlan14ReconstructionReplacementCrash$") cmd.Env = append(os.Environ(), "PLAN14_REPLACEMENT_CHILD="+s.dir, "PLAN14_REPLACEMENT_STAGE="+boundary) out, err := cmd.CombinedOutput() var exited *exec.ExitError if !errors.As(err, &exited) || exited.ExitCode() != 83 { t.Fatalf("child: %v %s", err, out) } if stage != "complete" { if _, err := h.Refusals(b); err == nil { t.Fatal("partial replacement granted history") } } if err := h.ResumeReconstruction(resolve); err != nil { t.Fatal(err) } refs, err := h.Refusals(b) if err != nil || len(refs) != 1 || refs[0].Version != "bad" { t.Fatalf("lost refusal after crash: %+v %v", refs, err) } if err := h.Reconstruct(pub, wire, b.Account, b.App, now, resolve); err != nil { t.Fatal("replay", err) } }) } }