| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "runtime" |
| 13 | "sync" |
| 14 | "syscall" |
| 15 | "testing" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/registry" |
| 18 | ) |
| 19 | |
| 20 | const ( |
| 21 | heldTestHost = "held.example.com" |
| 22 | heldTestOp = "mv_abcdefghijklmnopqrstuvwxyz" |
| 23 | ) |
| 24 | |
| 25 | // heldPriv signs every held fixture's pointers and promotion records, and |
| 26 | // heldPub verifies them: the release key a held stage is handed. |
| 27 | var ( |
| 28 | heldPriv = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize)) |
| 29 | heldPub = heldPriv.Public().(ed25519.PublicKey) |
| 30 | ) |
| 31 | |
| 32 | // heldFixture ships two builds of bab/console and moves the stable channel |
| 33 | // to the newer one: the pinned release (v1) is deliberately NOT the head. |
| 34 | // v1 was promoted to canary/x before that, so it has a promotion record and |
| 35 | // a held stage of it is §4.1-authorized (settings spec rev 11 §4.3a). |
| 36 | func heldFixture(t *testing.T) (context.Context, Store, string) { |
| 37 | t.Helper() |
| 38 | ctx := context.Background() |
| 39 | s := NewDirStore(t.TempDir()) |
| 40 | shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("pinned build")) |
| 41 | shipRelease(t, s, "bab", "console", "v2", adoptPlatform, []byte("channel head")) |
| 42 | if err := Promote(ctx, s, heldPriv, "bab", "console", "canary/x", "v1"); err != nil { |
| 43 | t.Fatalf("Promote: %v", err) |
| 44 | } |
| 45 | if err := Promote(ctx, s, heldPriv, "bab", "console", "stable", "v2"); err != nil { |
| 46 | t.Fatalf("Promote: %v", err) |
| 47 | } |
| 48 | return ctx, s, t.TempDir() |
| 49 | } |
| 50 | |
| 51 | func heldRoute() registry.Route { |
| 52 | return registry.Route{Host: heldTestHost, Kind: "instance", App: "console", Account: "bab", Channel: "stable", Status: "landing", HoldOp: heldTestOp} |
| 53 | } |
| 54 | |
| 55 | // TestMaterializeHeldStagesThePinnedVersion: the held row runs exactly the |
| 56 | // pinned release, never the channel head, and LiveVersion reads it back. |
| 57 | func TestMaterializeHeldStagesThePinnedVersion(t *testing.T) { |
| 58 | ctx, s, opt := heldFixture(t) |
| 59 | m, err := MaterializeHeld(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), openGuard) |
| 60 | if err != nil { |
| 61 | t.Fatalf("MaterializeHeld: %v", err) |
| 62 | } |
| 63 | if m == nil || m.Version != "v1" { |
| 64 | t.Fatalf("manifest = %+v, want v1's", m) |
| 65 | } |
| 66 | if got, err := LiveVersion(opt, heldTestHost); err != nil || got != "v1" { |
| 67 | t.Fatalf("LiveVersion = %q, %v; want the pinned v1, not the channel head", got, err) |
| 68 | } |
| 69 | body, err := os.ReadFile(LiveLink(opt, heldTestHost)) |
| 70 | if err != nil || string(body) != "pinned build" { |
| 71 | t.Fatalf("live binary = %q, %v", body, err) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | // TestMaterializeHeldStageBlocked: a process that cannot write the version |
| 76 | // store gets ErrStageBlocked wrapping the cause and writes nothing. EROFS is |
| 77 | // what ProtectSystem=strict answers, and it is not fs.ErrPermission. |
| 78 | func TestMaterializeHeldStageBlocked(t *testing.T) { |
| 79 | for _, errno := range []syscall.Errno{syscall.EROFS, syscall.EACCES} { |
| 80 | t.Run(errno.Error(), func(t *testing.T) { |
| 81 | ctx, s, opt := heldFixture(t) |
| 82 | orig := materializeHeldStage |
| 83 | t.Cleanup(func() { materializeHeldStage = orig }) |
| 84 | materializeHeldStage = func(context.Context, Store, string, string, string, string, string, string) (*Manifest, error) { |
| 85 | return nil, &os.PathError{Op: "mkdir", Path: filepath.Join(opt, "versions"), Err: errno} |
| 86 | } |
| 87 | _, err := MaterializeHeld(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), openGuard) |
| 88 | if !errors.Is(err, ErrStageBlocked) || !errors.Is(err, errno) { |
| 89 | t.Fatalf("MaterializeHeld: %v, want ErrStageBlocked wrapping %v", err, errno) |
| 90 | } |
| 91 | if ents, _ := os.ReadDir(opt); len(ents) != 0 { |
| 92 | t.Fatalf("optDir is not empty: %v", ents) |
| 93 | } |
| 94 | }) |
| 95 | } |
| 96 | other := &os.PathError{Op: "open", Path: "x", Err: syscall.ENOENT} |
| 97 | if StageBlocked(other) || !StageBlocked(&os.PathError{Err: syscall.EPERM}) { |
| 98 | t.Fatal("StageBlocked classifies EROFS, EPERM and EACCES only") |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | // TestMaterializeHeldRefusesWhileHostClaimIsWired mirrors RenderHeld's |
| 103 | // ruling F10: the live link is written outside any claim selection, so with |
| 104 | // CARLOS_HOST_CLAIM on the call refuses and writes nothing. |
| 105 | func TestMaterializeHeldRefusesWhileHostClaimIsWired(t *testing.T) { |
| 106 | ctx, s, opt := heldFixture(t) |
| 107 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 108 | if _, err := MaterializeHeld(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), openGuard); !errors.Is(err, ErrHeldClaimWired) { |
| 109 | t.Fatalf("MaterializeHeld: %v, want ErrHeldClaimWired", err) |
| 110 | } |
| 111 | if ents, _ := os.ReadDir(opt); len(ents) != 0 { |
| 112 | t.Fatalf("optDir is not empty: %v", ents) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // heldRecordingStore counts every call that reaches the store underneath. |
| 117 | type heldRecordingStore struct { |
| 118 | Store |
| 119 | mu sync.Mutex |
| 120 | calls []string |
| 121 | } |
| 122 | |
| 123 | func (r *heldRecordingStore) note(s string) { r.mu.Lock(); r.calls = append(r.calls, s); r.mu.Unlock() } |
| 124 | func (r *heldRecordingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 125 | r.note("get " + key) |
| 126 | return r.Store.Get(ctx, key) |
| 127 | } |
| 128 | func (r *heldRecordingStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 129 | r.note("list " + prefix) |
| 130 | return r.Store.List(ctx, prefix) |
| 131 | } |
| 132 | |
| 133 | func writeHeldRequest(t *testing.T, dataDir, name string, req HeldStageRequest) string { |
| 134 | t.Helper() |
| 135 | dir := HeldStageDir(dataDir) |
| 136 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | raw, err := json.Marshal(req) |
| 140 | if err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | path := filepath.Join(dir, name) |
| 144 | if err := os.WriteFile(path, raw, 0o644); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | return path |
| 148 | } |
| 149 | |
| 150 | func goodHeldRequest() HeldStageRequest { |
| 151 | return HeldStageRequest{Account: "bab", App: "console", Host: heldTestHost, Version: "v1", Op: heldTestOp, Box: "uk-box"} |
| 152 | } |
| 153 | |
| 154 | func gone(t *testing.T, path string) { |
| 155 | t.Helper() |
| 156 | if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| 157 | t.Fatalf("%s is still there: %v", path, err) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | // TestServeHeldStageRequestsRefusesStaleAndTraversal: the root side of the |
| 162 | // held-stage relay validates every component before any filesystem effect, |
| 163 | // asks authorize, and only then materializes. A request it refuses is |
| 164 | // removed and never repoints live/<host>. |
| 165 | func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| 166 | t.Run("no request directory is a no-op", func(t *testing.T) { |
| 167 | ctx, s, opt := heldFixture(t) |
| 168 | rec := &heldRecordingStore{Store: s} |
| 169 | dd := t.TempDir() |
| 170 | called := false |
| 171 | if err := ServeHeldStageRequests(ctx, rec, heldPub, nr(t), opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| 172 | t.Fatalf("ServeHeldStageRequests: %v", err) |
| 173 | } |
| 174 | if called || len(rec.calls) != 0 { |
| 175 | t.Fatalf("authorize called %v, store calls %v", called, rec.calls) |
| 176 | } |
| 177 | if ents, _ := os.ReadDir(dd); len(ents) != 0 { |
| 178 | t.Fatalf("dataDir gained %v", ents) |
| 179 | } |
| 180 | if ents, _ := os.ReadDir(opt); len(ents) != 0 { |
| 181 | t.Fatalf("optDir gained %v", ents) |
| 182 | } |
| 183 | }) |
| 184 | |
| 185 | t.Run("an op the claim no longer names", func(t *testing.T) { |
| 186 | ctx, s, opt := heldFixture(t) |
| 187 | dd := t.TempDir() |
| 188 | req := goodHeldRequest() |
| 189 | req.Op = "mv_bcdefghijklmnopqrstuvwxyza" |
| 190 | path := writeHeldRequest(t, dd, req.Host+".json", req) |
| 191 | authorize := func(r HeldStageRequest, then func() error) error { |
| 192 | if r.Op != heldTestOp { |
| 193 | return fmt.Errorf("%w: the claim names %s", ErrStaleHeldStage, heldTestOp) |
| 194 | } |
| 195 | return runThen(then) |
| 196 | } |
| 197 | if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), authorize); err != nil { |
| 198 | t.Fatalf("ServeHeldStageRequests: %v", err) |
| 199 | } |
| 200 | gone(t, path) |
| 201 | gone(t, LiveLink(opt, heldTestHost)) |
| 202 | gone(t, filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| 203 | }) |
| 204 | |
| 205 | for name, tc := range map[string]struct { |
| 206 | file string |
| 207 | mutate func(*HeldStageRequest) |
| 208 | }{ |
| 209 | "host traverses": {"evil.json", func(r *HeldStageRequest) { r.Host = "../x" }}, |
| 210 | "host is not the file name": {heldTestHost + ".json", func(r *HeldStageRequest) { r.Host = "other.example.com" }}, |
| 211 | "host carries a NUL": {"nul.json", func(r *HeldStageRequest) { r.Host = "nul\x00" }}, |
| 212 | "version traverses": {heldTestHost + ".json", func(r *HeldStageRequest) { r.Version = ".." }}, |
| 213 | "account is malformed": {heldTestHost + ".json", func(r *HeldStageRequest) { r.Account = "../bab" }}, |
| 214 | "app is malformed": {heldTestHost + ".json", func(r *HeldStageRequest) { r.App = "a/b" }}, |
| 215 | "op is empty": {heldTestHost + ".json", func(r *HeldStageRequest) { r.Op = "" }}, |
| 216 | "box is empty": {heldTestHost + ".json", func(r *HeldStageRequest) { r.Box = "" }}, |
| 217 | } { |
| 218 | t.Run(name, func(t *testing.T) { |
| 219 | ctx, s, opt := heldFixture(t) |
| 220 | rec := &heldRecordingStore{Store: s} |
| 221 | dd := t.TempDir() |
| 222 | req := goodHeldRequest() |
| 223 | tc.mutate(&req) |
| 224 | path := writeHeldRequest(t, dd, tc.file, req) |
| 225 | called := false |
| 226 | if err := ServeHeldStageRequests(ctx, rec, heldPub, nr(t), opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| 227 | t.Fatalf("ServeHeldStageRequests: %v", err) |
| 228 | } |
| 229 | gone(t, path) |
| 230 | if called || len(rec.calls) != 0 { |
| 231 | t.Fatalf("a refused request reached authorize (%v) or the store (%v)", called, rec.calls) |
| 232 | } |
| 233 | if ents, _ := os.ReadDir(opt); len(ents) != 0 { |
| 234 | t.Fatalf("optDir gained %v", ents) |
| 235 | } |
| 236 | }) |
| 237 | } |
| 238 | |
| 239 | t.Run("garbage is removed", func(t *testing.T) { |
| 240 | ctx, s, opt := heldFixture(t) |
| 241 | dd := t.TempDir() |
| 242 | path := filepath.Join(HeldStageDir(dd), heldTestHost+".json") |
| 243 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), passAuth(nil)); err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | gone(t, path) |
| 253 | }) |
| 254 | |
| 255 | t.Run("a symlinked request is removed, never followed", func(t *testing.T) { |
| 256 | ctx, s, opt := heldFixture(t) |
| 257 | dd := t.TempDir() |
| 258 | target := filepath.Join(t.TempDir(), "target.json") |
| 259 | raw, _ := json.Marshal(goodHeldRequest()) |
| 260 | if err := os.WriteFile(target, raw, 0o644); err != nil { |
| 261 | t.Fatal(err) |
| 262 | } |
| 263 | if err := os.MkdirAll(HeldStageDir(dd), 0o755); err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | link := filepath.Join(HeldStageDir(dd), heldTestHost+".json") |
| 267 | if err := os.Symlink(target, link); err != nil { |
| 268 | t.Fatal(err) |
| 269 | } |
| 270 | called := false |
| 271 | if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | gone(t, link) |
| 275 | if called { |
| 276 | t.Fatal("a symlinked request reached authorize") |
| 277 | } |
| 278 | if _, err := os.Stat(target); err != nil { |
| 279 | t.Fatalf("the link's target was touched: %v", err) |
| 280 | } |
| 281 | }) |
| 282 | |
| 283 | t.Run("a failed materialization keeps the request", func(t *testing.T) { |
| 284 | ctx, s, opt := heldFixture(t) |
| 285 | dd := t.TempDir() |
| 286 | req := goodHeldRequest() |
| 287 | req.Version = "v9" // never shipped |
| 288 | // ...but authorized (a signed record), so the stage fails after |
| 289 | // §4.1 lets it through, not at it: an unauthorized build is |
| 290 | // dropped (TestServeHeldStageRequestsDropsUnauthorizedBuilds). |
| 291 | if err := RootSigner(heldPriv).WritePromotionRecord(ctx, s, PromotionRecord{Account: "bab", App: "console", Version: "v9", |
| 292 | ManifestDigest: ManifestDigest([]byte("v9")), Channel: "canary/x"}); err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | path := writeHeldRequest(t, dd, req.Host+".json", req) |
| 296 | if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), passAuth(nil)); err != nil { |
| 297 | t.Fatal(err) |
| 298 | } |
| 299 | if _, err := os.Stat(path); err != nil { |
| 300 | t.Fatalf("the request was dropped after a failed stage: %v", err) |
| 301 | } |
| 302 | gone(t, LiveLink(opt, heldTestHost)) |
| 303 | }) |
| 304 | |
| 305 | t.Run("the happy path materializes, acks and removes", func(t *testing.T) { |
| 306 | ctx, s, opt := heldFixture(t) |
| 307 | dd := t.TempDir() |
| 308 | req := goodHeldRequest() |
| 309 | path := writeHeldRequest(t, dd, req.Host+".json", req) |
| 310 | var seen []HeldStageRequest |
| 311 | if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), passAuth(func(r HeldStageRequest) { seen = append(seen, r) })); err != nil { |
| 312 | t.Fatalf("ServeHeldStageRequests: %v", err) |
| 313 | } |
| 314 | if len(seen) != 2 || seen[0] != req || seen[1] != req { |
| 315 | t.Fatalf("authorize saw %+v, want %+v before staging and again around publication", seen, req) |
| 316 | } |
| 317 | gone(t, path) |
| 318 | if got, err := LiveVersion(opt, heldTestHost); err != nil || got != "v1" { |
| 319 | t.Fatalf("LiveVersion = %q, %v; want v1", got, err) |
| 320 | } |
| 321 | raw, err := os.ReadFile(filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| 322 | if err != nil { |
| 323 | t.Fatalf("no held-staged ack: %v", err) |
| 324 | } |
| 325 | var ack HeldStaged |
| 326 | if err := json.Unmarshal(raw, &ack); err != nil || ack.Version != "v1" || ack.Op != heldTestOp || ack.At.IsZero() { |
| 327 | t.Fatalf("held-staged ack = %s (%v)", raw, err) |
| 328 | } |
| 329 | }) |
| 330 | } |
| 331 | |
| 332 | // openGuard publishes unconditionally: the guard of a test whose authority |
| 333 | // is not the point. |
| 334 | func openGuard(publish func() error) error { return publish() } |
| 335 | |
| 336 | func runThen(then func() error) error { |
| 337 | if then == nil { |
| 338 | return nil |
| 339 | } |
| 340 | return then() |
| 341 | } |
| 342 | |
| 343 | // passAuth is an authority that accepts everything, noting each call. |
| 344 | func passAuth(note func(HeldStageRequest)) func(HeldStageRequest, func() error) error { |
| 345 | return func(r HeldStageRequest, then func() error) error { |
| 346 | if note != nil { |
| 347 | note(r) |
| 348 | } |
| 349 | return runThen(then) |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | // TestMaterializeHeldNeedsAGuard: publication without an authority would |
| 354 | // repoint live/<host> on the word of a request alone, so a nil guard is |
| 355 | // refused after staging and before anything is published. |
| 356 | func TestMaterializeHeldNeedsAGuard(t *testing.T) { |
| 357 | ctx, s, opt := heldFixture(t) |
| 358 | if _, err := MaterializeHeld(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), nil); err == nil { |
| 359 | t.Fatal("MaterializeHeld published with no guard") |
| 360 | } |
| 361 | gone(t, LiveLink(opt, heldTestHost)) |
| 362 | } |
| 363 | |
| 364 | // TestHeldPublicationRechecksItsAuthority: the authority that admitted a |
| 365 | // request before staging is asked again around publication, and a move |
| 366 | // aborted or replaced while the release was staging leaves live/<host> |
| 367 | // untouched, drops the request, and reports ErrStaleHeldStage. |
| 368 | func TestHeldPublicationRechecksItsAuthority(t *testing.T) { |
| 369 | ctx, s, opt := heldFixture(t) |
| 370 | orig := materializeHeldStage |
| 371 | t.Cleanup(func() { materializeHeldStage = orig }) |
| 372 | staged := false |
| 373 | materializeHeldStage = func(ctx context.Context, s Store, optDir, acct, app, ver, host, want string) (*Manifest, error) { |
| 374 | m, err := orig(ctx, s, optDir, acct, app, ver, host, want) |
| 375 | staged = true // the move is replaced while this runs |
| 376 | return m, err |
| 377 | } |
| 378 | published := false |
| 379 | authorize := func(_ HeldStageRequest, then func() error) error { |
| 380 | if staged { |
| 381 | return fmt.Errorf("%w: the claim names another op", ErrStaleHeldStage) |
| 382 | } |
| 383 | return runThen(then) |
| 384 | } |
| 385 | _, err := MaterializeHeld(ctx, s, heldPub, nr(t), opt, heldRoute(), "v1", offGate(t), func(publish func() error) error { |
| 386 | return authorize(goodHeldRequest(), func() error { published = true; return publish() }) |
| 387 | }) |
| 388 | if !errors.Is(err, ErrStaleHeldStage) || published { |
| 389 | t.Fatalf("MaterializeHeld: %v (published %v), want ErrStaleHeldStage and nothing published", err, published) |
| 390 | } |
| 391 | gone(t, LiveLink(opt, heldTestHost)) |
| 392 | |
| 393 | staged = false |
| 394 | dd := t.TempDir() |
| 395 | path := writeHeldRequest(t, dd, heldTestHost+".json", goodHeldRequest()) |
| 396 | if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), authorize); err != nil { |
| 397 | t.Fatal(err) |
| 398 | } |
| 399 | gone(t, path) |
| 400 | gone(t, LiveLink(opt, heldTestHost)) |
| 401 | gone(t, filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| 402 | } |
| 403 | |
| 404 | // offGate is a D3 gate on a box with no proof policy: the held stage's |
| 405 | // behaviour is exactly what it was before D3-held. |
| 406 | func offGate(t *testing.T) *ProofGate { |
| 407 | t.Helper() |
| 408 | return NewProofGate(filepath.Join(t.TempDir(), "proof-policy.json"), t.TempDir()) |
| 409 | } |
| 410 | |
| 411 | func TestHeldStageHardlinkedRequestIsRefused(t *testing.T) { |
| 412 | if runtime.GOOS != "linux" { |
| 413 | t.Skip("Linux descriptor boundary") |
| 414 | } |
| 415 | ctx, s, opt := heldFixture(t) |
| 416 | dd := t.TempDir() |
| 417 | target := filepath.Join(t.TempDir(), "target.json") |
| 418 | raw, err := json.Marshal(goodHeldRequest()) |
| 419 | if err != nil { |
| 420 | t.Fatal(err) |
| 421 | } |
| 422 | if err := os.WriteFile(target, raw, 0o644); err != nil { |
| 423 | t.Fatal(err) |
| 424 | } |
| 425 | if err := os.MkdirAll(HeldStageDir(dd), 0o755); err != nil { |
| 426 | t.Fatal(err) |
| 427 | } |
| 428 | request := filepath.Join(HeldStageDir(dd), heldTestHost+".json") |
| 429 | if err := os.Link(target, request); err != nil { |
| 430 | t.Fatal(err) |
| 431 | } |
| 432 | called := false |
| 433 | if err := ServeHeldStageRequests(ctx, s, heldPub, nr(t), opt, dd, offGate(t), passAuth(func(HeldStageRequest) { called = true })); err != nil { |
| 434 | t.Fatal(err) |
| 435 | } |
| 436 | if called { |
| 437 | t.Fatal("hardlinked request reached authorize") |
| 438 | } |
| 439 | gone(t, request) |
| 440 | gone(t, filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| 441 | after, err := os.ReadFile(target) |
| 442 | if err != nil || string(after) != string(raw) { |
| 443 | t.Fatalf("hardlink target changed: %q %v", after, err) |
| 444 | } |
| 445 | } |
| 446 | |