Artifact a2f161a69a215f14941b8b4d15ea3681f14745e543fde507689f3039292e0236:
- File
internal/seedcheck/seedcheck_test.go
— part of check-in
[ed5e3ce5b5]
at
2026-10-24 03:44:17
on branch main
— fix(sync): refuse pushed seeds by hash; judge only what a fill grounds (#471)
Horizon F.1, push side, and the fourth review's blocking finding.
Refusal by hash: - seedcheck keeps two store-local tables, carried by no config group: seedpending(rid, root) and seedrefused(uuid). - A seed judged after the request that pushed it, because a later push, pull, check-in or tag filled its basis, has its hash refused. Its rows are deleted by Sweep, and only where no receive pass holds rids: end of request, end of sync, check-in, tag and rebuild. A deleted rid is reused by the next row, and a pass's caches are keyed by rid. - Until swept, such a row is withheld and never linked. A delta on a refused seed is left unreadable and pending under no root. - requestPhantoms never asks for a refused hash. ownSeedReadable refuses a re-push of one, and a full-text re-push is refused on its bytes. - A purge still forgets pending rows (ForgetDeleted). Refusals are keyed by hash, so rid reuse cannot pass them on.
Judging only what a fill grounds: - JudgeReadable rescanned every pending row on each fill. A pusher could plant dangling deltas without bound and make every later fill pay for all of them. - Pending rows now record the root of their basis chain, re-rooted when that root is itself stored as a dangling delta. - JudgeFilled judges only the rows pending on the rids just stored, and reports how many it examined. - Callers: AfterDephantomize and its batch, manifest.Checkin (its files and manifest) and tag.AddTag. linkArtifact still judges a pending row it reaches, caching which tables exist per transaction. - The sweep no longer judges.
A seed-shaped artifact received by pull or clone stays ordinary, per the 0.7.1 B.2. Docs and the HZ-5.2-4 scope note cite F.1.
Tests: - A seed package test: one fill examines its own row, not 200 planted elsewhere. Run as a rescan mutant, it fails. - A tag whose control artifact is a seed's basis refuses the seed. - The link step refuses a seed made readable by a raw store. - A re-push refused by hash, and an igot of the hash not gimme'd. - A delta on a refused seed withheld. - A purged pending rid reused by an innocent blob. - Disabling any judge call (cascade, check-in, tag, link) or any Sweep call (request, sync, check-in, tag) fails a test. (user: [email protected], size: 2935) [annotate] [blame] [check-ins using]
package seedcheck_test import ( "fmt" "path/filepath" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "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) } }