test(headless): assert screenshot hard links and cover skip and rename failure paths
- commit
- b35bb200cb13a83c429b471ae9fe7602f1fa1091
- author
- brice · · 2026-10-22 04:53 UTC
- parent
- b970441
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +81 -13
M internal/adapters/headless/output_test.go +81 -13
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "strconv" | |
| 15 | 15 | "strings" | |
| 16 | 16 | "sync" | |
| 17 | + | "syscall" | |
| 17 | 18 | "testing" | |
| 18 | 19 | "time" | |
| 19 | 20 | ||
| @@ -299,6 +300,17 @@ func TestLinkPNGSecure(t *testing.T) { | |||
| 299 | 300 | if !bytes.Equal(a, b) { | |
| 300 | 301 | t.Fatal("latest.png differs from the frame") | |
| 301 | 302 | } | |
| 303 | + | fi, err := os.Stat(frame) | |
| 304 | + | if err != nil { | |
| 305 | + | t.Fatal(err) | |
| 306 | + | } | |
| 307 | + | li, err := os.Stat(latest) | |
| 308 | + | if err != nil { | |
| 309 | + | t.Fatal(err) | |
| 310 | + | } | |
| 311 | + | if !os.SameFile(fi, li) { | |
| 312 | + | t.Fatal("latest.png is a copy, not a hard link to the frame") | |
| 313 | + | } | |
| 302 | 314 | got, err := png.Decode(bytes.NewReader(b)) | |
| 303 | 315 | if err != nil { | |
| 304 | 316 | t.Fatal(err) | |
| @@ -319,40 +331,73 @@ func TestLinkPNGSecure(t *testing.T) { | |||
| 319 | 331 | } | |
| 320 | 332 | } | |
| 321 | 333 | ||
| 322 | - | // A frame that crossed a security change is not published as latest.png, and | |
| 323 | - | // a source that cannot be linked is encoded instead. | |
| 334 | + | // A frame that crossed a security change is not published as latest.png, a | |
| 335 | + | // failed rename leaves no temporary file, and a source that cannot be linked | |
| 336 | + | // is encoded instead. | |
| 324 | 337 | func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) { | |
| 325 | 338 | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |
| 326 | 339 | good := ports.SecurityState{Generation: 1} | |
| 327 | - | t.Run("protected", func(t *testing.T) { | |
| 340 | + | // frameIn writes the frame to link and returns its directory and path. | |
| 341 | + | frameIn := func(t *testing.T) (string, string) { | |
| 342 | + | t.Helper() | |
| 328 | 343 | dir := t.TempDir() | |
| 329 | 344 | frame := filepath.Join(dir, "frame-000001.png") | |
| 330 | 345 | if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil { | |
| 331 | 346 | t.Fatal(err) | |
| 332 | 347 | } | |
| 348 | + | return dir, frame | |
| 349 | + | } | |
| 350 | + | // onlyFrame fails unless the frame is all the directory holds: no | |
| 351 | + | // latest.png and no latest.png.tmp. | |
| 352 | + | onlyFrame := func(t *testing.T, dir string) { | |
| 353 | + | t.Helper() | |
| 354 | + | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | |
| 355 | + | t.Fatalf("latest.png or a temp file delivered: %v", entries) | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | t.Run("protected state", func(t *testing.T) { | |
| 359 | + | dir, frame := frameIn(t) | |
| 360 | + | // A protected state is refused without reading the live snapshot. | |
| 361 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 362 | + | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, ports.SecurityState{Generation: 1, Protected: true}); err != nil || written { | |
| 363 | + | t.Fatalf("link = %v, %v", written, err) | |
| 364 | + | } | |
| 365 | + | onlyFrame(t, dir) | |
| 366 | + | }) | |
| 367 | + | t.Run("protected snapshot", func(t *testing.T) { | |
| 368 | + | dir, frame := frameIn(t) | |
| 333 | 369 | gate := portsmocks.NewMockSessionSecurity(t) | |
| 334 | 370 | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | |
| 335 | 371 | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written { | |
| 336 | 372 | t.Fatalf("link = %v, %v", written, err) | |
| 337 | 373 | } | |
| 338 | - | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | |
| 339 | - | t.Fatalf("latest.png or a temp file delivered: %v", entries) | |
| 340 | - | } | |
| 374 | + | onlyFrame(t, dir) | |
| 341 | 375 | }) | |
| 342 | 376 | t.Run("changed after the link", func(t *testing.T) { | |
| 343 | - | dir := t.TempDir() | |
| 344 | - | frame := filepath.Join(dir, "frame-000001.png") | |
| 345 | - | if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil { | |
| 346 | - | t.Fatal(err) | |
| 347 | - | } | |
| 377 | + | dir, frame := frameIn(t) | |
| 348 | 378 | gate := portsmocks.NewMockSessionSecurity(t) | |
| 349 | 379 | gate.EXPECT().Snapshot().Return(good).Once() | |
| 350 | 380 | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once() | |
| 351 | 381 | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written { | |
| 352 | 382 | t.Fatalf("link = %v, %v", written, err) | |
| 353 | 383 | } | |
| 354 | - | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | |
| 355 | - | t.Fatalf("latest.png or a temp file delivered: %v", entries) | |
| 384 | + | onlyFrame(t, dir) | |
| 385 | + | }) | |
| 386 | + | t.Run("rename failure", func(t *testing.T) { | |
| 387 | + | dir, frame := frameIn(t) | |
| 388 | + | // A non-empty directory cannot be replaced by a file. | |
| 389 | + | latest := filepath.Join(dir, "latest.png") | |
| 390 | + | if err := os.MkdirAll(filepath.Join(latest, "keep"), 0o700); err != nil { | |
| 391 | + | t.Fatal(err) | |
| 392 | + | } | |
| 393 | + | if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err == nil || written { | |
| 394 | + | t.Fatalf("link = %v, %v; want an error", written, err) | |
| 395 | + | } | |
| 396 | + | if _, err := os.Stat(latest + ".tmp"); !os.IsNotExist(err) { | |
| 397 | + | t.Fatalf("temporary link left behind: %v", err) | |
| 398 | + | } | |
| 399 | + | if _, err := os.Stat(filepath.Join(latest, "keep")); err != nil { | |
| 400 | + | t.Fatalf("existing directory touched: %v", err) | |
| 356 | 401 | } | |
| 357 | 402 | }) | |
| 358 | 403 | t.Run("no source file", func(t *testing.T) { | |
| @@ -361,6 +406,13 @@ func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) { | |||
| 361 | 406 | if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written { | |
| 362 | 407 | t.Fatalf("link = %v, %v", written, err) | |
| 363 | 408 | } | |
| 409 | + | fi, err := os.Stat(latest) | |
| 410 | + | if err != nil { | |
| 411 | + | t.Fatal(err) | |
| 412 | + | } | |
| 413 | + | if !fi.Mode().IsRegular() { | |
| 414 | + | t.Fatalf("latest.png is %v", fi.Mode()) | |
| 415 | + | } | |
| 364 | 416 | f, err := os.Open(latest) | |
| 365 | 417 | if err != nil { | |
| 366 | 418 | t.Fatal(err) | |
| @@ -369,9 +421,25 @@ func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) { | |||
| 369 | 421 | if _, err := png.Decode(f); err != nil { | |
| 370 | 422 | t.Fatal(err) | |
| 371 | 423 | } | |
| 424 | + | // Encoded on its own: nothing else in the directory shares it. | |
| 425 | + | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | |
| 426 | + | t.Fatalf("unexpected files: %v", entries) | |
| 427 | + | } | |
| 428 | + | if n, ok := linkCount(fi); ok && n != 1 { | |
| 429 | + | t.Fatalf("latest.png has %d links, want 1", n) | |
| 430 | + | } | |
| 372 | 431 | }) | |
| 373 | 432 | } | |
| 374 | 433 | ||
| 434 | + | // linkCount returns the hard link count of fi where the platform reports it. | |
| 435 | + | func linkCount(fi os.FileInfo) (uint64, bool) { | |
| 436 | + | st, ok := fi.Sys().(*syscall.Stat_t) | |
| 437 | + | if !ok { | |
| 438 | + | return 0, false | |
| 439 | + | } | |
| 440 | + | return uint64(st.Nlink), true | |
| 441 | + | } | |
| 442 | + | ||
| 375 | 443 | func TestWritePNGAtomic(t *testing.T) { | |
| 376 | 444 | dir := t.TempDir() | |
| 377 | 445 | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "strconv" | 14 | "strconv" | ||
| 15 | "strings" | 15 | "strings" | ||
| 16 | "sync" | 16 | "sync" | ||
| 17 | + | "syscall" | |||
| 17 | "testing" | 18 | "testing" | ||
| 18 | "time" | 19 | "time" | ||
| 19 | 20 | ||||
| @@ -299,6 +300,17 @@ func TestLinkPNGSecure(t *testing.T) { | |||||
| 299 | if !bytes.Equal(a, b) { | 300 | if !bytes.Equal(a, b) { | ||
| 300 | t.Fatal("latest.png differs from the frame") | 301 | t.Fatal("latest.png differs from the frame") | ||
| 301 | } | 302 | } | ||
| 303 | + | fi, err := os.Stat(frame) | |||
| 304 | + | if err != nil { | |||
| 305 | + | t.Fatal(err) | |||
| 306 | + | } | |||
| 307 | + | li, err := os.Stat(latest) | |||
| 308 | + | if err != nil { | |||
| 309 | + | t.Fatal(err) | |||
| 310 | + | } | |||
| 311 | + | if !os.SameFile(fi, li) { | |||
| 312 | + | t.Fatal("latest.png is a copy, not a hard link to the frame") | |||
| 313 | + | } | |||
| 302 | got, err := png.Decode(bytes.NewReader(b)) | 314 | got, err := png.Decode(bytes.NewReader(b)) | ||
| 303 | if err != nil { | 315 | if err != nil { | ||
| 304 | t.Fatal(err) | 316 | t.Fatal(err) | ||
| @@ -319,40 +331,73 @@ func TestLinkPNGSecure(t *testing.T) { | |||||
| 319 | } | 331 | } | ||
| 320 | } | 332 | } | ||
| 321 | 333 | ||||
| 322 | - | // A frame that crossed a security change is not published as latest.png, and | 334 | + | // A frame that crossed a security change is not published as latest.png, a |
| 323 | - | // a source that cannot be linked is encoded instead. | 335 | + | // failed rename leaves no temporary file, and a source that cannot be linked |
| 336 | + | // is encoded instead. | |||
| 324 | func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) { | 337 | func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) { | ||
| 325 | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | 338 | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | ||
| 326 | good := ports.SecurityState{Generation: 1} | 339 | good := ports.SecurityState{Generation: 1} | ||
| 327 | - | t.Run("protected", func(t *testing.T) { | 340 | + | // frameIn writes the frame to link and returns its directory and path. |
| 341 | + | frameIn := func(t *testing.T) (string, string) { | |||
| 342 | + | t.Helper() | |||
| 328 | dir := t.TempDir() | 343 | dir := t.TempDir() | ||
| 329 | frame := filepath.Join(dir, "frame-000001.png") | 344 | frame := filepath.Join(dir, "frame-000001.png") | ||
| 330 | if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil { | 345 | if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil { | ||
| 331 | t.Fatal(err) | 346 | t.Fatal(err) | ||
| 332 | } | 347 | } | ||
| 348 | + | return dir, frame | |||
| 349 | + | } | |||
| 350 | + | // onlyFrame fails unless the frame is all the directory holds: no | |||
| 351 | + | // latest.png and no latest.png.tmp. | |||
| 352 | + | onlyFrame := func(t *testing.T, dir string) { | |||
| 353 | + | t.Helper() | |||
| 354 | + | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | |||
| 355 | + | t.Fatalf("latest.png or a temp file delivered: %v", entries) | |||
| 356 | + | } | |||
| 357 | + | } | |||
| 358 | + | t.Run("protected state", func(t *testing.T) { | |||
| 359 | + | dir, frame := frameIn(t) | |||
| 360 | + | // A protected state is refused without reading the live snapshot. | |||
| 361 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 362 | + | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, ports.SecurityState{Generation: 1, Protected: true}); err != nil || written { | |||
| 363 | + | t.Fatalf("link = %v, %v", written, err) | |||
| 364 | + | } | |||
| 365 | + | onlyFrame(t, dir) | |||
| 366 | + | }) | |||
| 367 | + | t.Run("protected snapshot", func(t *testing.T) { | |||
| 368 | + | dir, frame := frameIn(t) | |||
| 333 | gate := portsmocks.NewMockSessionSecurity(t) | 369 | gate := portsmocks.NewMockSessionSecurity(t) | ||
| 334 | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | 370 | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | ||
| 335 | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written { | 371 | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written { | ||
| 336 | t.Fatalf("link = %v, %v", written, err) | 372 | t.Fatalf("link = %v, %v", written, err) | ||
| 337 | } | 373 | } | ||
| 338 | - | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | 374 | + | onlyFrame(t, dir) |
| 339 | - | t.Fatalf("latest.png or a temp file delivered: %v", entries) | |||
| 340 | - | } | |||
| 341 | }) | 375 | }) | ||
| 342 | t.Run("changed after the link", func(t *testing.T) { | 376 | t.Run("changed after the link", func(t *testing.T) { | ||
| 343 | - | dir := t.TempDir() | 377 | + | dir, frame := frameIn(t) |
| 344 | - | frame := filepath.Join(dir, "frame-000001.png") | |||
| 345 | - | if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil { | |||
| 346 | - | t.Fatal(err) | |||
| 347 | - | } | |||
| 348 | gate := portsmocks.NewMockSessionSecurity(t) | 378 | gate := portsmocks.NewMockSessionSecurity(t) | ||
| 349 | gate.EXPECT().Snapshot().Return(good).Once() | 379 | gate.EXPECT().Snapshot().Return(good).Once() | ||
| 350 | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once() | 380 | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once() | ||
| 351 | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written { | 381 | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written { | ||
| 352 | t.Fatalf("link = %v, %v", written, err) | 382 | t.Fatalf("link = %v, %v", written, err) | ||
| 353 | } | 383 | } | ||
| 354 | - | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | 384 | + | onlyFrame(t, dir) |
| 355 | - | t.Fatalf("latest.png or a temp file delivered: %v", entries) | 385 | + | }) |
| 386 | + | t.Run("rename failure", func(t *testing.T) { | |||
| 387 | + | dir, frame := frameIn(t) | |||
| 388 | + | // A non-empty directory cannot be replaced by a file. | |||
| 389 | + | latest := filepath.Join(dir, "latest.png") | |||
| 390 | + | if err := os.MkdirAll(filepath.Join(latest, "keep"), 0o700); err != nil { | |||
| 391 | + | t.Fatal(err) | |||
| 392 | + | } | |||
| 393 | + | if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err == nil || written { | |||
| 394 | + | t.Fatalf("link = %v, %v; want an error", written, err) | |||
| 395 | + | } | |||
| 396 | + | if _, err := os.Stat(latest + ".tmp"); !os.IsNotExist(err) { | |||
| 397 | + | t.Fatalf("temporary link left behind: %v", err) | |||
| 398 | + | } | |||
| 399 | + | if _, err := os.Stat(filepath.Join(latest, "keep")); err != nil { | |||
| 400 | + | t.Fatalf("existing directory touched: %v", err) | |||
| 356 | } | 401 | } | ||
| 357 | }) | 402 | }) | ||
| 358 | t.Run("no source file", func(t *testing.T) { | 403 | t.Run("no source file", func(t *testing.T) { | ||
| @@ -361,6 +406,13 @@ func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) { | |||||
| 361 | if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written { | 406 | if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written { | ||
| 362 | t.Fatalf("link = %v, %v", written, err) | 407 | t.Fatalf("link = %v, %v", written, err) | ||
| 363 | } | 408 | } | ||
| 409 | + | fi, err := os.Stat(latest) | |||
| 410 | + | if err != nil { | |||
| 411 | + | t.Fatal(err) | |||
| 412 | + | } | |||
| 413 | + | if !fi.Mode().IsRegular() { | |||
| 414 | + | t.Fatalf("latest.png is %v", fi.Mode()) | |||
| 415 | + | } | |||
| 364 | f, err := os.Open(latest) | 416 | f, err := os.Open(latest) | ||
| 365 | if err != nil { | 417 | if err != nil { | ||
| 366 | t.Fatal(err) | 418 | t.Fatal(err) | ||
| @@ -369,9 +421,25 @@ func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) { | |||||
| 369 | if _, err := png.Decode(f); err != nil { | 421 | if _, err := png.Decode(f); err != nil { | ||
| 370 | t.Fatal(err) | 422 | t.Fatal(err) | ||
| 371 | } | 423 | } | ||
| 424 | + | // Encoded on its own: nothing else in the directory shares it. | |||
| 425 | + | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | |||
| 426 | + | t.Fatalf("unexpected files: %v", entries) | |||
| 427 | + | } | |||
| 428 | + | if n, ok := linkCount(fi); ok && n != 1 { | |||
| 429 | + | t.Fatalf("latest.png has %d links, want 1", n) | |||
| 430 | + | } | |||
| 372 | }) | 431 | }) | ||
| 373 | } | 432 | } | ||
| 374 | 433 | ||||
| 434 | + | // linkCount returns the hard link count of fi where the platform reports it. | |||
| 435 | + | func linkCount(fi os.FileInfo) (uint64, bool) { | |||
| 436 | + | st, ok := fi.Sys().(*syscall.Stat_t) | |||
| 437 | + | if !ok { | |||
| 438 | + | return 0, false | |||
| 439 | + | } | |||
| 440 | + | return uint64(st.Nlink), true | |||
| 441 | + | } | |||
| 442 | + | ||||
| 375 | func TestWritePNGAtomic(t *testing.T) { | 443 | func TestWritePNGAtomic(t *testing.T) { | ||
| 376 | dir := t.TempDir() | 444 | dir := t.TempDir() | ||
| 377 | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | 445 | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | ||