Artifact 5e0245ad1b1716d5680cf3ac526a7cbc08f03650f04588ef1d332f8612faa2b7:
- File
internal/seedcheck/seedcheck_test.go
— part of check-in
[eac16cf7ed]
at
2026-10-24 13:08:15
on branch main
— fix(sync): bound basis-chain walks at the read depth
Any pusher could hold the write lock about 30s per push with a two-delta cycle. The receive pass's basis-chain walks, and the horizon seed check's pending-row walk, ran to 1<<24 steps before giving up.
All of them now stop at content.MaxDeltaChainDepth, the depth at which content.Expand stops, so no walk outlasts what a read could ever use. What crossing the bound means at each site:
- chainEnd (rootOf, for a dangling delta being stored) keeps a visited set. A chain that loops is refused, as it was, but at the revisit rather than after 1<<24 steps. A chain longer than the bound returns errChainTooDeep. storeDeltaContent then stores the delta untracked: the push succeeds, as a push onto a chain between 16k and 16M deep always did, and since no basis can make the delta readable there is nothing to check against its name later. Storing matches the current behaviour, and an honest transfer never fails for a deep chain it did not build. - verifyFrom's cascade carries each row's depth from the basis that arrived, its own chain included. It does not check, or descend past, a row deeper than the bound: no read can produce those bytes, so the pass owes them no check. A delta that does not hash to its name past the bound no longer aborts the pass. - pathRows loses its step counter. Its visited marks already stop it at a row on a path, so it visits each row once, and the cascade's depth check bounds what it verifies. - seedcheck's chainRoot gives a chain that loops or runs past the bound no root, consistent with ErrChainUnresolvable: the row stays pending, withheld, and is never judged.
Tests: - Closing a two-delta cycle is refused in under 5s (it took 27s). - A push onto a chain one past the bound is stored. - chainEnd stops exactly at its limit. - A dangling delta with a wrong name one node past the bound does not abort the push, whether the basis that arrives is full text or a delta deep on a held chain, while one within the bound is still checked and readable. - MarkPending gives an over-deep chain no root, so filling its far end examines nothing, and returns at once on a cycle. - TestHorizonOverDeepPendingChainDoesNotBlockFill still passes. - Removing each bound, the cycle check, the untracked store or the root depth fails a test. - chainDepth's own early stop is an equivalent mutant: the cascade's root has just been expanded, so its chain is within the bound unless the store is damaged. (user: [email protected], size: 9028) [annotate] [blame] [check-ins using] [more...]
package seedcheck_test import ( "bytes" "fmt" "path/filepath" "slices" "testing" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/delta" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/seedcheck" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/simio" ) // pending stores target as a delta on base, which the store lacks, and // records it pending, as the push handler does. func pending(t *testing.T, r *repo.Repo, base, target []byte) libfossil.FslID { t.Helper() src, err := blob.StorePhantom(r.DB(), hash.SHA3(base)) if err != nil { t.Fatal(err) } d, err := delta.Create(base, target) if err != nil { t.Fatal(err) } rid, err := blob.StoreDeltaRaw(r.DB(), hash.SHA1(target), d, src, nil) if err != nil { t.Fatal(err) } if err := seedcheck.MarkPending(r.DB(), rid); err != nil { t.Fatal(err) } return rid } // Filling one basis judges the rows pending on it and no others, however // many are pending elsewhere: a push cannot make every later fill pay for // the deltas it planted. func TestJudgeFilledExaminesOnlyItsSubtree(t *testing.T) { r, err := repo.Create(filepath.Join(t.TempDir(), "r.fossil"), "u", simio.CryptoRand{}, "") if err != nil { t.Fatal(err) } defer r.Close() const planted = 200 for i := range planted { pending(t, r, fmt.Appendf(nil, "unrelated basis %d\n", i), fmt.Appendf(nil, "unrelated %d\n", i)) } base := []byte("the basis this fill supplies\n") sd := &deck.Deck{Type: deck.Control, D: time.Date(2026, 10, 1, 16, 0, 0, 0, time.UTC), U: deck.User("zit-seed"), T: []deck.TagCard{{Type: deck.TagSingleton, Name: seedcheck.Tag, UUID: "0123456789abcdef0123456789abcdef01234567"}}} seed, err := sd.Marshal() if err != nil { t.Fatal(err) } mine := pending(t, r, base, seed) chained := pending(t, r, seed, append([]byte("on the seed\n"), seed...)) filled, _, err := blob.Store(r.DB(), base) if err != nil { t.Fatal(err) } examined, err := seedcheck.JudgeFilled(r.DB(), []libfossil.FslID{filled}) if err != nil { t.Fatal(err) } if examined != 1 { t.Fatalf("examined %d pending rows, want the 1 pending on the filled basis", examined) } if refused, err := seedcheck.IsRefusedUUID(r.DB(), hash.SHA1(seed)); err != nil || !refused { t.Fatalf("the readable seed was not refused (%v)", err) } withheld, err := seedcheck.Withheld(r.DB()) if err != nil { t.Fatal(err) } if !withheld[mine] || !withheld[chained] { t.Fatal("the refused seed, or the delta on it, is not withheld") } if len(withheld) != planted+2 { t.Fatalf("%d rows withheld, want the %d planted rows still pending and the seed and its dependent", len(withheld), planted) } } // A row that cannot be expanded does not end the batch: the fills after it // are still judged. func TestJudgeFilledContinuesPastUnreadableRow(t *testing.T) { r, err := repo.Create(filepath.Join(t.TempDir(), "r.fossil"), "u", simio.CryptoRand{}, "") if err != nil { t.Fatal(err) } defer r.Close() broken := []byte("a basis whose dependent is corrupt\n") src, err := blob.StorePhantom(r.DB(), hash.SHA3(broken)) if err != nil { t.Fatal(err) } // A delta made against other bytes: well formed, but its checksum fails // when applied to the basis that arrives. long := bytes.Repeat([]byte("a much longer basis than the one that arrives\n"), 8) target := append(slices.Clone(long), []byte("and one line more\n")...) wrong, err := delta.Create(long, target) if err != nil { t.Fatal(err) } bad, err := blob.StoreDeltaRaw(r.DB(), hash.SHA1(target), wrong, src, nil) if err != nil { t.Fatal(err) } if err := seedcheck.MarkPending(r.DB(), bad); err != nil { t.Fatal(err) } // And one whose delta applies but fails its checksum: made against bytes // of the arriving basis's length that differ from it. twin := bytes.Repeat([]byte{'x'}, len(broken)) twinTarget := append(slices.Clone(twin), []byte("a tail\n")...) checksummed, err := delta.Create(twin, twinTarget) if err != nil { t.Fatal(err) } failing, err := blob.StoreDeltaRaw(r.DB(), hash.SHA1(twinTarget), checksummed, src, nil) if err != nil { t.Fatal(err) } if err := seedcheck.MarkPending(r.DB(), failing); err != nil { t.Fatal(err) } base := []byte("the second fill's basis\n") sd := &deck.Deck{Type: deck.Control, D: time.Date(2026, 10, 1, 16, 0, 0, 0, time.UTC), U: deck.User(seedcheck.Label), T: []deck.TagCard{{Type: deck.TagSingleton, Name: "x", UUID: "0123456789abcdef0123456789abcdef01234567"}}} seed, err := sd.Marshal() if err != nil { t.Fatal(err) } pending(t, r, base, seed) first, _, err := blob.Store(r.DB(), broken) if err != nil { t.Fatal(err) } second, _, err := blob.Store(r.DB(), base) if err != nil { t.Fatal(err) } if _, err := seedcheck.JudgeFilled(r.DB(), []libfossil.FslID{first, second}); err != nil { t.Fatalf("JudgeFilled: %v", err) } if refused, err := seedcheck.IsRefusedUUID(r.DB(), hash.SHA1(seed)); err != nil || !refused { t.Fatalf("the second fill's seed was not judged (%v)", err) } if withheld, err := seedcheck.IsWithheld(r.DB(), bad); err != nil || !withheld { t.Fatalf("the unreadable row is no longer withheld (%v)", err) } } // A row that fails for a reason other than an unresolvable or mismatched // chain, a store fault such as content that does not decompress, is not // skipped silently: JudgeFilled reports it. func TestJudgeFilledSurfacesStoreFaults(t *testing.T) { r, err := repo.Create(filepath.Join(t.TempDir(), "r.fossil"), "u", simio.CryptoRand{}, "") if err != nil { t.Fatal(err) } defer r.Close() base := []byte("a basis\n") rid := pending(t, r, base, []byte("a basis\nand a dependent\n")) if _, err := r.DB().Exec("UPDATE blob SET content=x'00000010deadbeef' WHERE rid=?", rid); err != nil { t.Fatal(err) } filled, _, err := blob.Store(r.DB(), base) if err != nil { t.Fatal(err) } if _, err := seedcheck.JudgeFilled(r.DB(), []libfossil.FslID{filled}); err == nil { t.Fatal("JudgeFilled skipped a row whose content does not decompress") } } // plantChain stores n content-less delta rows, each on the next, the last // on root, and returns the top row: only the chain's shape is read. func plantChain(t *testing.T, r *repo.Repo, root libfossil.FslID, n int) libfossil.FslID { t.Helper() var top libfossil.FslID err := r.WithTx(func(tx *db.Tx) error { below := root for i := 0; i < n; i++ { res, err := tx.Exec("INSERT INTO blob(uuid, size, content, rcvid) VALUES(printf('%040x', ?), -1, NULL, 1)", i+1) if err != nil { return err } id, err := res.LastInsertId() if err != nil { return err } if _, err := tx.Exec("INSERT INTO delta(rid, srcid) VALUES(?, ?)", id, below); err != nil { return err } below, top = libfossil.FslID(id), libfossil.FslID(id) } return nil }) if err != nil { t.Fatal(err) } return top } // A pending row whose chain runs past content.MaxDeltaChainDepth is put // under no root: filling the far end examines nothing, since nothing could // read the row anyway. func TestMarkPendingOverDeepChainHasNoRoot(t *testing.T) { r, err := repo.Create(filepath.Join(t.TempDir(), "r.fossil"), "u", simio.CryptoRand{}, "") if err != nil { t.Fatal(err) } defer r.Close() base := []byte("the far end\n") root, err := blob.StorePhantom(r.DB(), hash.SHA3(base)) if err != nil { t.Fatal(err) } top := plantChain(t, r, root, content.MaxDeltaChainDepth+1) if err := seedcheck.MarkPending(r.DB(), top); err != nil { t.Fatal(err) } filled, _, err := blob.Store(r.DB(), base) if err != nil { t.Fatal(err) } examined, err := seedcheck.JudgeFilled(r.DB(), []libfossil.FslID{filled}) if err != nil || examined != 0 { t.Fatalf("filling the far end examined %d rows (%v), want 0", examined, err) } if withheld, err := seedcheck.IsWithheld(r.DB(), top); err != nil || !withheld { t.Fatalf("the over-deep row is not withheld (%v)", err) } } // Marking a row on a cycle ends at once, under no root. func TestMarkPendingCycleEndsQuickly(t *testing.T) { r, err := repo.Create(filepath.Join(t.TempDir(), "r.fossil"), "u", simio.CryptoRand{}, "") if err != nil { t.Fatal(err) } defer r.Close() a := plantChain(t, r, 0, 2) if _, err := r.DB().Exec("UPDATE delta SET srcid=? WHERE srcid=0", a); err != nil { t.Fatal(err) } start := time.Now() if err := seedcheck.MarkPending(r.DB(), a); err != nil { t.Fatal(err) } if elapsed := time.Since(start); elapsed > 5*time.Second { t.Fatalf("marking a row on a cycle took %v", elapsed) } if withheld, err := seedcheck.IsWithheld(r.DB(), a); err != nil || !withheld { t.Fatalf("the row on the cycle is not withheld (%v)", err) } }