| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // Final review finding 1. T7 used to pass whenever live/<host> named the |
| 12 | // pinned version: no authority, no proof policy, no look at the bytes. |
| 13 | // VerifyHeldLive is its reading now: the link names the version, D3 admits |
| 14 | // the release under the policy in force, and bind accepts the bytes the |
| 15 | // link names, read under the lock the publication holds across its swap, |
| 16 | // re-hash and rollback. |
| 17 | |
| 18 | // passLock is a lock that is not one: the bytes check alone. |
| 19 | func passLock(read func() error) error { return read() } |
| 20 | |
| 21 | // chanLock is one lock shared by a publication's guard and a T7 reading, |
| 22 | // as the per-host flock is in production. waiting is signalled each time a |
| 23 | // reading starts to wait for it. |
| 24 | type chanLock struct { |
| 25 | mu chan struct{} |
| 26 | waiting chan struct{} |
| 27 | } |
| 28 | |
| 29 | func newChanLock() *chanLock { |
| 30 | return &chanLock{mu: make(chan struct{}, 1), waiting: make(chan struct{}, 8)} |
| 31 | } |
| 32 | |
| 33 | func (l *chanLock) guard(publish func() error) error { |
| 34 | l.mu <- struct{}{} |
| 35 | defer func() { <-l.mu }() |
| 36 | return publish() |
| 37 | } |
| 38 | |
| 39 | func (l *chanLock) read(fn func() error) error { |
| 40 | l.waiting <- struct{}{} |
| 41 | l.mu <- struct{}{} |
| 42 | defer func() { <-l.mu }() |
| 43 | return fn() |
| 44 | } |
| 45 | |
| 46 | // The review's regression: T7 is read between the swap and a failed |
| 47 | // post-swap verification. The publication holds the lock from before the |
| 48 | // swap until after its rollback, so the reading waits it out and then sees |
| 49 | // the link back on v0: not published, never a pass. |
| 50 | func TestVerifyHeldLiveWaitsOutAFailedPublication(t *testing.T) { |
| 51 | e := newHeldProofEnv(t) |
| 52 | e.require(t, "console") |
| 53 | e.prove(t, e.pinnedSHA) |
| 54 | lock := newChanLock() |
| 55 | agent := NewProofGate(e.policy, e.bake) // the agent's own gate |
| 56 | prev := heldAfterSwap |
| 57 | t.Cleanup(func() { heldAfterSwap = prev }) |
| 58 | res := make(chan error, 1) |
| 59 | var during error = errors.New("the seam never ran") |
| 60 | heldAfterSwap = func() { |
| 61 | // The link names v1 now. Its bytes are replaced, so the |
| 62 | // publication's second bind will refuse and roll back. |
| 63 | if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("replaced after the swap")); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | if v := e.live(); v != "v1" { |
| 67 | t.Fatalf("live = %q inside the swap window, want v1", v) |
| 68 | } |
| 69 | go func() { res <- VerifyHeldLive(e.ctx, e.store, e.opt, heldRoute(), "v1", agent, lock.read) }() |
| 70 | select { |
| 71 | case <-lock.waiting: |
| 72 | case err := <-res: |
| 73 | during = fmt.Errorf("T7 answered %v without waiting for the lock", err) |
| 74 | return |
| 75 | case <-time.After(5 * time.Second): |
| 76 | during = errors.New("T7 never reached the lock") |
| 77 | return |
| 78 | } |
| 79 | select { |
| 80 | case err := <-res: |
| 81 | during = fmt.Errorf("T7 answered %v while the publication held the lock", err) |
| 82 | case <-time.After(200 * time.Millisecond): |
| 83 | during = nil |
| 84 | } |
| 85 | } |
| 86 | _, err := MaterializeHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1", e.gate, lock.guard) |
| 87 | if !errors.Is(err, ErrHeldNotProven) || e.live() != "v0" { |
| 88 | t.Fatalf("the publication: %v, live %q; want ErrHeldNotProven and v0", err, e.live()) |
| 89 | } |
| 90 | if during != nil { |
| 91 | t.Fatal(during) |
| 92 | } |
| 93 | select { |
| 94 | case got := <-res: |
| 95 | if !errors.Is(got, ErrHeldNotLive) { |
| 96 | t.Fatalf("T7 after the rollback = %v, want ErrHeldNotLive", got) |
| 97 | } |
| 98 | case <-time.After(5 * time.Second): |
| 99 | t.Fatal("T7 never answered") |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // Even with no lock, the reading never passes on the swapped link: it |
| 104 | // hashes the bytes the link names, as the publication's second bind does. |
| 105 | func TestVerifyHeldLiveRefusesTheSwappedBytes(t *testing.T) { |
| 106 | e := newHeldProofEnv(t) |
| 107 | e.require(t, "console") |
| 108 | e.prove(t, e.pinnedSHA) |
| 109 | agent := NewProofGate(e.policy, e.bake) |
| 110 | prev := heldAfterSwap |
| 111 | t.Cleanup(func() { heldAfterSwap = prev }) |
| 112 | var during error = errors.New("the seam never ran") |
| 113 | heldAfterSwap = func() { |
| 114 | if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("replaced after the swap")); err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | during = VerifyHeldLive(e.ctx, e.store, e.opt, heldRoute(), "v1", agent, passLock) |
| 118 | } |
| 119 | if _, err := MaterializeHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1", e.gate, openGuard); !errors.Is(err, ErrHeldNotProven) { |
| 120 | t.Fatalf("the publication: %v", err) |
| 121 | } |
| 122 | if !errors.Is(during, ErrHeldNotProven) { |
| 123 | t.Fatalf("T7 inside the swap window = %v, want ErrHeldNotProven", during) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | // An existing link is no evidence by itself: T7 re-checks the policy and |
| 128 | // the bytes every time. Replaced bytes, a rotated prover key and a policy |
| 129 | // that does not load each fail it; a link on another version is not live; |
| 130 | // an app the policy does not list passes as adoption would. |
| 131 | func TestVerifyHeldLiveRechecksAnExistingLink(t *testing.T) { |
| 132 | e := newHeldProofEnv(t) |
| 133 | e.require(t, "console") |
| 134 | e.prove(t, e.pinnedSHA) |
| 135 | if _, err := MaterializeHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1", e.gate, openGuard); err != nil || e.live() != "v1" { |
| 136 | t.Fatalf("publish v1: %v, live %q", err, e.live()) |
| 137 | } |
| 138 | verify := func() error { |
| 139 | return VerifyHeldLive(e.ctx, e.store, e.opt, heldRoute(), "v1", NewProofGate(e.policy, e.bake), passLock) |
| 140 | } |
| 141 | if err := verify(); err != nil { |
| 142 | t.Fatalf("published and proven: %v", err) |
| 143 | } |
| 144 | |
| 145 | // The artifact is replaced after publication. |
| 146 | if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("replaced later")); err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | if err := verify(); !errors.Is(err, ErrHeldNotProven) { |
| 150 | t.Fatalf("replaced bytes: %v, want ErrHeldNotProven", err) |
| 151 | } |
| 152 | if r, _ := ReadRefusal(HeldRefusalBase(e.bake), heldTestHost); r != nil { |
| 153 | t.Fatalf("a reading recorded a refusal: %+v", r) |
| 154 | } |
| 155 | if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("pinned build")); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | if err := verify(); err != nil { |
| 159 | t.Fatalf("bytes restored: %v", err) |
| 160 | } |
| 161 | |
| 162 | // The proof policy changes: another prover key. |
| 163 | _, _, otherPub := proverKey(t) |
| 164 | e.writePolicy(t, fmt.Sprintf(`{"schema":1,"require":[{"app":"console","prover":"dev","key":%q}]}`, otherPub)) |
| 165 | if err := verify(); !errors.Is(err, ErrHeldNotProven) { |
| 166 | t.Fatalf("rotated key: %v, want ErrHeldNotProven", err) |
| 167 | } |
| 168 | // A policy that does not load. |
| 169 | e.writePolicy(t, "{not json") |
| 170 | if err := verify(); !errors.Is(err, ErrHeldNotProven) { |
| 171 | t.Fatalf("broken policy: %v, want ErrHeldNotProven", err) |
| 172 | } |
| 173 | // Uncovered: D3 has nothing to say about these bytes, as for adoption. |
| 174 | e.require(t, "other") |
| 175 | if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("replaced later")); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | if err := verify(); err != nil { |
| 179 | t.Fatalf("uncovered: %v", err) |
| 180 | } |
| 181 | |
| 182 | // Another version live is not published. |
| 183 | if err := PointLive(e.opt, heldTestHost, "bab", "v0", "console"); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | if err := verify(); !errors.Is(err, ErrHeldNotLive) { |
| 187 | t.Fatalf("on v0: %v, want ErrHeldNotLive", err) |
| 188 | } |
| 189 | if err := os.Remove(LiveLink(e.opt, heldTestHost)); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | if err := verify(); !errors.Is(err, ErrHeldNotLive) { |
| 193 | t.Fatalf("no link: %v, want ErrHeldNotLive", err) |
| 194 | } |
| 195 | |
| 196 | // No lock, no gate: refused. |
| 197 | if err := VerifyHeldLive(e.ctx, e.store, e.opt, heldRoute(), "v1", e.gate, nil); err == nil { |
| 198 | t.Fatal("a reading without the lock passed") |
| 199 | } |
| 200 | if err := VerifyHeldLive(e.ctx, e.store, e.opt, heldRoute(), "v1", nil, passLock); err == nil { |
| 201 | t.Fatal("a reading without a gate passed") |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // Region HA Task 8c: the claim path's reading. VerifyHeldLiveUnder reads |
| 206 | // under CARLOS_HOST_CLAIM, and still refuses an unproven link, a link on |
| 207 | // another version, and a reading without the lock. VerifyHeldLive writes |
| 208 | // nothing, so it reads under the claim too (moves Task 7, Paul's T7 ruling |
| 209 | // of 2026-10-12). |
| 210 | func TestVerifyHeldLiveUnderReadsUnderTheClaim(t *testing.T) { |
| 211 | e := newHeldProofEnv(t) |
| 212 | e.require(t, "console") |
| 213 | e.prove(t, e.pinnedSHA) |
| 214 | if _, err := MaterializeHeld(e.ctx, e.store, heldPub, nr(t), e.opt, heldRoute(), "v1", e.gate, openGuard); err != nil || e.live() != "v1" { |
| 215 | t.Fatalf("publish v1: %v, live %q", err, e.live()) |
| 216 | } |
| 217 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 218 | verify := func(lock func(func() error) error) error { |
| 219 | return VerifyHeldLiveUnder(e.ctx, e.store, e.opt, heldRoute(), "v1", NewProofGate(e.policy, e.bake), lock) |
| 220 | } |
| 221 | if err := VerifyHeldLive(e.ctx, e.store, e.opt, heldRoute(), "v1", NewProofGate(e.policy, e.bake), passLock); err != nil { |
| 222 | t.Fatalf("VerifyHeldLive under the claim: %v", err) |
| 223 | } |
| 224 | if err := verify(passLock); err != nil { |
| 225 | t.Fatalf("published and proven, under the claim: %v", err) |
| 226 | } |
| 227 | if err := verify(nil); err == nil { |
| 228 | t.Fatal("a reading without the lock passed") |
| 229 | } |
| 230 | if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("replaced later")); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | if err := verify(passLock); !errors.Is(err, ErrHeldNotProven) { |
| 234 | t.Fatalf("replaced bytes: %v, want ErrHeldNotProven", err) |
| 235 | } |
| 236 | if err := PointLive(e.opt, heldTestHost, "bab", "v0", "console"); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | if err := verify(passLock); !errors.Is(err, ErrHeldNotLive) { |
| 240 | t.Fatalf("on v0: %v, want ErrHeldNotLive", err) |
| 241 | } |
| 242 | } |
| 243 | |