From 4a355385d81037eaab218e96c550d6d596a424d0 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 12:02:48 -0600 Subject: [PATCH 1/4] test(b2): pin account-info session size boundaries --- b2/accountinfo_edges_test.go | 88 ++++++++++++++++++++++++++++++++++++ b2/accountinfo_test.go | 4 +- 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 b2/accountinfo_edges_test.go diff --git a/b2/accountinfo_edges_test.go b/b2/accountinfo_edges_test.go new file mode 100644 index 0000000..80bf830 --- /dev/null +++ b/b2/accountinfo_edges_test.go @@ -0,0 +1,88 @@ +package b2 + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAccountInfoWriteSizeBoundary(t *testing.T) { + for _, test := range []struct { + name string + size int + want bool + }{ + {"cap minus one", 1_048_575, true}, + {"cap", 1_048_576, true}, + {"cap plus one", 1_048_577, false}, + } { + t.Run(test.name, func(t *testing.T) { + data := sampleAuthData() + data.AuthToken = "" + empty, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + data.AuthToken = strings.Repeat("x", test.size-len(empty)-1) + path := filepath.Join(t.TempDir(), "private", "session.json") + store := NewAccountInfoFile(path) + store.Set(data) + if !test.want { + if err := store.Err(); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("Set %d-byte session error = %v, want size rejection", test.size, err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("rejected %d-byte session created a file: %v", test.size, err) + } + return + } + if err := store.Err(); err != nil { + t.Fatalf("Set %d-byte session: %v", test.size, err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if len(content) != test.size { + t.Fatalf("session file bytes = %d, want %d", len(content), test.size) + } + }) + } +} + +func TestAccountInfoReadSizeBoundary(t *testing.T) { + for _, test := range []struct { + name string + size int + want bool + }{ + {"cap minus one", 1_048_575, true}, + {"cap", 1_048_576, true}, + {"cap plus one", 1_048_577, false}, + } { + t.Run(test.name, func(t *testing.T) { + parent := filepath.Join(t.TempDir(), "private") + if err := os.Mkdir(parent, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(parent, "session.json") + content := append([]byte("{}"), bytes.Repeat([]byte(" "), test.size-2)...) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + store := NewAccountInfoFile(path) + _, ok := store.AuthData() + if test.want { + if !ok || store.Err() != nil { + t.Fatalf("AuthData() for %d-byte session: ok=%t err=%v, want loaded", test.size, ok, store.Err()) + } + } else if ok || !errors.Is(store.Err(), errUnavailableAccountInfoSession) { + t.Fatalf("AuthData() for %d-byte session: ok=%t err=%v, want size rejection", test.size, ok, store.Err()) + } + }) + } +} diff --git a/b2/accountinfo_test.go b/b2/accountinfo_test.go index 378c4ed..039223a 100644 --- a/b2/accountinfo_test.go +++ b/b2/accountinfo_test.go @@ -63,8 +63,8 @@ func TestAccountInfoFileRoundTripAndPermissions(t *testing.T) { if err != nil { t.Fatal(err) } - if !json.Valid(content) || strings.Contains(string(content), "application-key-secret") { - t.Fatal("file is not valid JSON or contains the application key") + if !json.Valid(content) { + t.Fatal("file is not valid JSON") } fileInfo, err := os.Stat(path) if err != nil { From cf8423903f67fed2da8aa23766d85db1c290c172 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 12:03:07 -0600 Subject: [PATCH 2/4] test(b2): pin core object and progress edges --- b2/core_edges_test.go | 100 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 b2/core_edges_test.go diff --git a/b2/core_edges_test.go b/b2/core_edges_test.go new file mode 100644 index 0000000..481d415 --- /dev/null +++ b/b2/core_edges_test.go @@ -0,0 +1,100 @@ +package b2 + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestObjectLockUpdateInvalidatesCachedInfo(t *testing.T) { + for _, test := range []struct { + name string + update func(*Object) error + }{ + {"retention", func(o *Object) error { + return o.UpdateFileRetention(context.Background(), &FileRetention{Mode: RetentionGovernance, RetainUntil: time.UnixMilli(1_735_689_600_000)}, false) + }}, + {"legal hold", func(o *Object) error { + return o.UpdateFileLegalHold(context.Background(), LegalHoldOn) + }}, + } { + t.Run(test.name, func(t *testing.T) { + wire := newLockWire(t) + object := wire.object(t) + ctx := context.Background() + wire.info = `{"fileId":"id","fileName":"name","action":"upload","contentLength":1,"contentType":"text/plain","fileInfo":{"marker":"before"}}` + before, err := object.Attrs(ctx) + if err != nil { + t.Fatal(err) + } + if before.Info["marker"] != "before" { + t.Fatalf("initial file info marker = %q, want before", before.Info["marker"]) + } + calls := wire.calls + if err := test.update(object); err != nil { + t.Fatal(err) + } + wire.info = `{"fileId":"id","fileName":"name","action":"upload","contentLength":1,"contentType":"text/plain","fileInfo":{"marker":"after"}}` + after, err := object.Attrs(ctx) + if err != nil { + t.Fatal(err) + } + if after.Info["marker"] != "after" || wire.calls != calls+2 || wire.path != "/b2api/v4/b2_get_file_info" { + t.Fatalf("post-update marker=%q calls=%d (want %d) last path=%q", after.Info["marker"], wire.calls, calls+2, wire.path) + } + }) + } +} + +func TestObjectDeleteRejectsFolderStatus(t *testing.T) { + file := &testFile{n: "folder", a: "folder", files: map[string]string{"folder": "present"}} + object := objectForDeleteOptionTest(file) + if err := object.Delete(context.Background()); err == nil || !strings.Contains(err.Error(), "not a regular file or hide marker") { + t.Fatalf("Delete(folder) error = %v, want non-regular-file rejection", err) + } + if got := file.files["folder"]; got != "present" { + t.Fatalf("Delete(folder) changed file state to %q", got) + } +} + +func TestMemoryBufferCloseIsIdempotent(t *testing.T) { + buffer := newMemoryBuffer() + if _, err := buffer.Write([]byte("payload")); err != nil { + t.Fatal(err) + } + if err := buffer.Close(); err != nil { + t.Fatal(err) + } + if buffer.buf != nil { + t.Fatal("Close retained the buffer") + } + if err := buffer.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } +} + +func TestProgressZeroWakeDoesNotDuplicateCallback(t *testing.T) { + var samples []int64 + p := &progressReporter{ + callback: func(completed, _ int64) { samples = append(samples, completed) }, + total: 1, + wake: make(chan struct{}), // A send acknowledges that run received the wake. + stop: make(chan struct{}), + done: make(chan struct{}), + } + go p.run() + p.add(1) + deadline := time.After(5 * time.Second) + for i := 0; i < 2; i++ { + select { + case p.wake <- struct{}{}: + case <-deadline: + t.Fatal("progress reporter did not receive a wake") + } + } + p.finish() + if len(samples) != 2 || samples[0] != 1 || samples[1] != 1 { + t.Fatalf("progress callbacks = %v, want one nonzero update and one terminal call", samples) + } +} From 787e213b3d59489be2b99ccaea6cfe25b6ef1c9b Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 12:03:24 -0600 Subject: [PATCH 3/4] test(b2): record core edge mutation proofs --- intent/b2-edge-assertions/plan.md | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 intent/b2-edge-assertions/plan.md diff --git a/intent/b2-edge-assertions/plan.md b/intent/b2-edge-assertions/plan.md new file mode 100644 index 0000000..ddf8915 --- /dev/null +++ b/intent/b2-edge-assertions/plan.md @@ -0,0 +1,34 @@ +| Item | Mutation | Failing output | Passing after revert | +| --- | --- | --- | --- | +| 1 · session secret | Already covered by `TestAccountInfoFileResumeAndClearOn401`; removed the vacuous check from `TestAccountInfoFileRoundTripAndPermissions` | — | — | +| 2 · retention cache | `baseline.go` `err == nil` → `err != nil` | `post-update marker="before" calls=2 (want 3)` | `TestObjectLockUpdateInvalidatesCachedInfo/retention`: PASS | +| 2 · retention cache | Delete the invalidation guard and body | `post-update marker="before" calls=2 (want 3)` | `TestObjectLockUpdateInvalidatesCachedInfo/retention`: PASS | +| 2 · legal-hold cache | `baseline.go` `err == nil` → `err != nil` | `post-update marker="before" calls=2 (want 3)` | `TestObjectLockUpdateInvalidatesCachedInfo/legal_hold`: PASS | +| 2 · legal-hold cache | Delete the invalidation guard and body | `post-update marker="before" calls=2 (want 3)` | `TestObjectLockUpdateInvalidatesCachedInfo/legal_hold`: PASS | +| 3 · write cap | `accountinfo.go` `>` → `>=` | `Set 1048576-byte session: account info: session file exceeds 1048576 bytes` | `TestAccountInfoWriteSizeBoundary`: PASS | +| 3 · write cap | Delete the size guard | `Set 1048577-byte session error = , want size rejection` | `TestAccountInfoWriteSizeBoundary`: PASS | +| 4 · read cap | `accountinfo.go` `>` → `>=` | `AuthData() for 1048576-byte session: ok=false ... want loaded` | `TestAccountInfoReadSizeBoundary`: PASS | +| 4 · read cap | Delete the size guard | `AuthData() for 1048577-byte session: ok=true err=, want size rejection` | `TestAccountInfoReadSizeBoundary`: PASS | +| 5 · temp cleanup | Stopped: needs a hook after `CreateTemp` and before deferred `os.Remove` to make cleanup fail deterministically; pre-existing read-only permissions fail at `CreateTemp` instead | Not run | Not run | +| 6 · file sync | Stopped: needs an injectable temporary-file handle or `Sync` hook to observe the call | Not run | Not run | +| 7 · folder delete | Negate the regular-file/hide/copy status guard | `Delete(folder) error = , want non-regular-file rejection` | `TestObjectDeleteRejectsFolderStatus`: PASS | +| 7 · folder delete | Delete the status guard | `Delete(folder) error = , want non-regular-file rejection` | `TestObjectDeleteRejectsFolderStatus`: PASS | +| 8 · buffer close | `mb.buf == nil` → `mb.buf != nil` | `Close retained the buffer` | `TestMemoryBufferCloseIsIdempotent`: PASS | +| 8 · buffer close | Delete the nil guard | `panic: runtime error: invalid memory address or nil pointer dereference` at second `Close` | `TestMemoryBufferCloseIsIdempotent`: PASS | +| 8 · pool return | Stopped: `sync.Pool.Get` may discard a just-put value, including under `-race`; proving `Put` needs an injectable pool interface or hook | Not run | Not run | +| 9 · zero wake | `n > 0` → `n <= 0` | `progress callbacks = [0 0], want one nonzero update and one terminal call` | `TestProgressZeroWakeDoesNotDuplicateCallback`: PASS | +| 9 · zero wake | Delete the `n > 0` guard | `progress callbacks = [1 1 1], want one nonzero update and one terminal call` | `TestProgressZeroWakeDoesNotDuplicateCallback`: PASS | + +```sh +unset B2_APPLICATION_KEY_ID B2_APPLICATION_KEY B2_ACCOUNT_ID B2_ACCOUNT_KEY +go test -count=1 ./b2 -run 'TestObjectLockUpdateInvalidatesCachedInfo/(retention|legal_hold)$' +go test -count=1 ./b2 -run '^TestAccountInfo(Write|Read)SizeBoundary$' +go test -count=1 ./b2 -run '^TestObjectDeleteRejectsFolderStatus$' +go test -count=1 ./b2 -run '^TestMemoryBufferCloseIsIdempotent$' +go test -count=1 ./b2 -run '^TestProgressZeroWakeDoesNotDuplicateCallback$' +go test -race -count=5 -shuffle=on ./b2/... +go test -race -count=30 ./b2 -run 'Test(MemoryBufferCloseIsIdempotent|ProgressZeroWakeDoesNotDuplicateCallback)$' +go vet ./... +GOOS=windows go vet ./b2/... +make ci +``` From 617e15bec17693104ff7190111dccd825bfed367 Mon Sep 17 00:00:00 2001 From: Felipe Fumero Date: Fri, 30 Oct 2026 15:09:51 -0600 Subject: [PATCH 4/4] chore: move the C3 mutation table to the PR description Part of #204 --- intent/b2-edge-assertions/plan.md | 34 ------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 intent/b2-edge-assertions/plan.md diff --git a/intent/b2-edge-assertions/plan.md b/intent/b2-edge-assertions/plan.md deleted file mode 100644 index ddf8915..0000000 --- a/intent/b2-edge-assertions/plan.md +++ /dev/null @@ -1,34 +0,0 @@ -| Item | Mutation | Failing output | Passing after revert | -| --- | --- | --- | --- | -| 1 · session secret | Already covered by `TestAccountInfoFileResumeAndClearOn401`; removed the vacuous check from `TestAccountInfoFileRoundTripAndPermissions` | — | — | -| 2 · retention cache | `baseline.go` `err == nil` → `err != nil` | `post-update marker="before" calls=2 (want 3)` | `TestObjectLockUpdateInvalidatesCachedInfo/retention`: PASS | -| 2 · retention cache | Delete the invalidation guard and body | `post-update marker="before" calls=2 (want 3)` | `TestObjectLockUpdateInvalidatesCachedInfo/retention`: PASS | -| 2 · legal-hold cache | `baseline.go` `err == nil` → `err != nil` | `post-update marker="before" calls=2 (want 3)` | `TestObjectLockUpdateInvalidatesCachedInfo/legal_hold`: PASS | -| 2 · legal-hold cache | Delete the invalidation guard and body | `post-update marker="before" calls=2 (want 3)` | `TestObjectLockUpdateInvalidatesCachedInfo/legal_hold`: PASS | -| 3 · write cap | `accountinfo.go` `>` → `>=` | `Set 1048576-byte session: account info: session file exceeds 1048576 bytes` | `TestAccountInfoWriteSizeBoundary`: PASS | -| 3 · write cap | Delete the size guard | `Set 1048577-byte session error = , want size rejection` | `TestAccountInfoWriteSizeBoundary`: PASS | -| 4 · read cap | `accountinfo.go` `>` → `>=` | `AuthData() for 1048576-byte session: ok=false ... want loaded` | `TestAccountInfoReadSizeBoundary`: PASS | -| 4 · read cap | Delete the size guard | `AuthData() for 1048577-byte session: ok=true err=, want size rejection` | `TestAccountInfoReadSizeBoundary`: PASS | -| 5 · temp cleanup | Stopped: needs a hook after `CreateTemp` and before deferred `os.Remove` to make cleanup fail deterministically; pre-existing read-only permissions fail at `CreateTemp` instead | Not run | Not run | -| 6 · file sync | Stopped: needs an injectable temporary-file handle or `Sync` hook to observe the call | Not run | Not run | -| 7 · folder delete | Negate the regular-file/hide/copy status guard | `Delete(folder) error = , want non-regular-file rejection` | `TestObjectDeleteRejectsFolderStatus`: PASS | -| 7 · folder delete | Delete the status guard | `Delete(folder) error = , want non-regular-file rejection` | `TestObjectDeleteRejectsFolderStatus`: PASS | -| 8 · buffer close | `mb.buf == nil` → `mb.buf != nil` | `Close retained the buffer` | `TestMemoryBufferCloseIsIdempotent`: PASS | -| 8 · buffer close | Delete the nil guard | `panic: runtime error: invalid memory address or nil pointer dereference` at second `Close` | `TestMemoryBufferCloseIsIdempotent`: PASS | -| 8 · pool return | Stopped: `sync.Pool.Get` may discard a just-put value, including under `-race`; proving `Put` needs an injectable pool interface or hook | Not run | Not run | -| 9 · zero wake | `n > 0` → `n <= 0` | `progress callbacks = [0 0], want one nonzero update and one terminal call` | `TestProgressZeroWakeDoesNotDuplicateCallback`: PASS | -| 9 · zero wake | Delete the `n > 0` guard | `progress callbacks = [1 1 1], want one nonzero update and one terminal call` | `TestProgressZeroWakeDoesNotDuplicateCallback`: PASS | - -```sh -unset B2_APPLICATION_KEY_ID B2_APPLICATION_KEY B2_ACCOUNT_ID B2_ACCOUNT_KEY -go test -count=1 ./b2 -run 'TestObjectLockUpdateInvalidatesCachedInfo/(retention|legal_hold)$' -go test -count=1 ./b2 -run '^TestAccountInfo(Write|Read)SizeBoundary$' -go test -count=1 ./b2 -run '^TestObjectDeleteRejectsFolderStatus$' -go test -count=1 ./b2 -run '^TestMemoryBufferCloseIsIdempotent$' -go test -count=1 ./b2 -run '^TestProgressZeroWakeDoesNotDuplicateCallback$' -go test -race -count=5 -shuffle=on ./b2/... -go test -race -count=30 ./b2 -run 'Test(MemoryBufferCloseIsIdempotent|ProgressZeroWakeDoesNotDuplicateCallback)$' -go vet ./... -GOOS=windows go vet ./b2/... -make ci -```