Artifact eb9aa06b6b6abda44a430d6de97552ab9f2e417896d92c758c047519d8870a3a:
- File
internal/sync/horizon_pushcheck.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: 5215) [annotate] [blame] [check-ins using]
package sync import ( "database/sql" "errors" "fmt" "strings" "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/seedcheck" ) // staleCancel is the push-time check of draft-fossil-horizon-00 §5.7, run // over what the request stored once it is crosslinked. A branch-creating // check-in cancels its parent's branch by the name its clone derived, which // under a structural horizon came from the seed. When this origin derives // the parent on another branch, the seed was stale: staleCancel returns the // refusal, which rolls the whole push back. A check-in is admitted when this // push or the store holds a control artifact cancelling the parent's real // branch on it (the correction), and so is one whose parent this origin // cannot place on a branch: the check protects against a wrong seed, as // zit's pushSeedCancelFault does, not against every unusual push. func (h *handler) staleCancel() (string, error) { q := h.repo.DB() for _, uuid := range h.received { rid, ok := blob.Exists(q, uuid) if !ok { continue } var checkin bool if err := q.QueryRow("SELECT EXISTS(SELECT 1 FROM event WHERE objid=? AND type='ci')", rid).Scan(&checkin); err != nil { return "", fmt.Errorf("handler: push check: %w", err) } if !checkin { continue } data, err := content.Expand(q, rid) if err != nil { return "", fmt.Errorf("handler: push check: %w", err) } d, err := deck.Parse(data) if err != nil || len(d.P) == 0 { continue } for _, t := range d.T { cancelled, ok := strings.CutPrefix(t.Name, "sym-") if t.Type != deck.TagCancel || t.UUID != "*" || !ok { continue } on, placed, err := branchOfCheckin(q, d.P[0]) if err != nil { return "", err } if !placed || on == cancelled { continue } corrected, err := cancelledOn(q, int64(rid), "sym-"+on) if err != nil { return "", err } if corrected { continue } return fmt.Sprintf("stale seed: the parent of %s is on branch '%s', not '%s'; re-sync, then push "+ "a control artifact cancelling sym-%s on %s (a move to its branch) with it", abbrev(uuid), on, cancelled, on, uuid), nil } } return "", nil } // seedRefusedText is the error an origin refuses a pushed seed with (horizon // §5.2.3, as the zit 0.7.1 erratum words it). const seedRefusedText = "seed refused: a seed is private to the clone that holds it; it is never pushed" // ownSeedReadable reports whether a delta this request stored before its // basis was readable has become readable and is a seed (horizon §5.2.3): // the seed is judged within the request that carried it, which is then // refused whole. The cascade may have judged it already, refusing its hash; // so is a re-push of a hash refused before. A delta still unreadable stays // pending and is judged by whatever later fills its basis; one found not to // be a seed loses its pending row. func (h *handler) ownSeedReadable() (bool, error) { if len(h.danglingHere) == 0 { return false, nil } if h.pass == nil { panic("handler.ownSeedReadable: a stored delta implies a receive pass") } q := h.repo.DB() for _, d := range h.danglingHere { refused, err := seedcheck.IsRefusedUUID(q, d.uuid) if err != nil || refused { return refused, err } if !content.IsAvailable(q, d.rid) { continue } // The pass's basis cache keeps every node it rebuilds, so a chain of // deltas is expanded once along its length, not once per delta. data, err := h.pass.basis.ExpandUnchecked(q, d.rid) if err != nil { return false, fmt.Errorf("handler: seed check: %w", err) } if seedcheck.IsSeedBytes(data) { return true, nil } if err := seedcheck.Clear(q, d.rid); err != nil { return false, err } } return false, nil } // branchOfCheckin is the branch this repository derives for the check-in // named uuid; placed is false when it holds no branch row for it. func branchOfCheckin(q db.Querier, uuid string) (string, bool, error) { var on string err := q.QueryRow(`SELECT tagxref.value FROM tagxref JOIN tag USING(tagid) JOIN blob ON blob.rid=tagxref.rid WHERE blob.uuid=? AND tag.tagname='branch' AND tagxref.tagtype>0`, uuid).Scan(&on) if errors.Is(err, sql.ErrNoRows) { return "", false, nil } if err != nil { return "", false, fmt.Errorf("handler: push check: %w", err) } return on, true, nil } // cancelledOn reports a control artifact's cancellation of tag on rid: a // cancel row sourced from an artifact other than rid itself. func cancelledOn(q db.Querier, rid int64, tag string) (bool, error) { var found bool err := q.QueryRow(`SELECT EXISTS(SELECT 1 FROM tagxref JOIN tag USING(tagid) WHERE tagxref.rid=? AND tag.tagname=? AND tagxref.tagtype=0 AND tagxref.srcid>0 AND tagxref.srcid<>tagxref.rid)`, rid, tag).Scan(&found) if err != nil { return false, fmt.Errorf("handler: push check: %w", err) } return found, nil } // abbrev is a hash's first ten digits, as a diagnostic names a check-in. func abbrev(uuid string) string { return uuid[:min(len(uuid), 10)] }