Artifact 186b89f0fbf5ea3b057e274eaaf8e3c295d66bc8f4c024fe6f769e44e09f057b:
- File
conformance/horizon_deepen_test.go
— part of check-in
[8f89198ca1]
at
2026-10-22 18:00:28
on branch feat/horizon-cones
— feat(sync): refuse a push whose branch cancellation names a stale seed
A check-in that creates a branch cancels its parent's branch tag by the name its clone derived, and under a structural horizon that name came from the seed. Section 5.7 has the origin check it against its own derivation: the handler now looks at each check-in a push stored, once crosslinked, and when the parent is on another branch here it rolls the whole push back with an error naming the stale seed and the parent's branch, spelled as zit's pushSeedCancelFault spells it. A check-in is admitted when the push or the store holds a control artifact cancelling the parent's real branch on it, and so is one whose parent this origin cannot place.
zit's check reads only the push for the correction; this one also reads the store, as the draft allows. Interop runs both ways against zit 9bf7ebfe0f. (user: [email protected], size: 14823) [annotate] [blame] [check-ins using]
package conformance import ( "context" "errors" "net/http/httptest" "path/filepath" "slices" "strings" "testing" "time" gofossil "github.com/danmestas/go-libfossil" "github.com/danmestas/go-libfossil/internal/branch" "github.com/danmestas/go-libfossil/internal/cone" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/horizon" "github.com/danmestas/go-libfossil/internal/horizon/seed" "github.com/danmestas/go-libfossil/internal/merge" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/sync" "github.com/danmestas/go-libfossil/internal/tag" "github.com/danmestas/go-libfossil/internal/xfer" ) // The merge-base query and deepen (draft-fossil-horizon-00 Appendix A step // 6: §5.5, §5.7, §5.8, §5.9, §6.2.4) over hzStruct: go-libfossil's handler // is the origin, its structural clone the client. func horizonDeepenCases() []Case { return []Case{horizonMergeBaseQuery, horizonMergeBaseBound, horizonDeepen, horizonDeepenPoint, horizonDeepenFailed, horizonMergeCrossing, horizonMergeDeepen, horizonPushCheck} } var ( horizonMergeBaseQuery = Case{ID: "horizon.6.2.4.merge-base", Req: "HZ-6.2-7"} horizonMergeBaseBound = Case{ID: "horizon.8.merge-base-bound", Req: "HZ-8-1"} horizonDeepen = Case{ID: "horizon.5.8.deepen", Req: "HZ-5.8-1"} horizonDeepenPoint = Case{ID: "horizon.5.8.deepen-point", Req: "HZ-5.8-1"} horizonDeepenFailed = Case{ID: "horizon.5.8.deepen-failed", Req: "HZ-5.8-1"} horizonMergeCrossing = Case{ID: "horizon.5.5.merge-crossing", Req: "HZ-5.5-1"} horizonMergeDeepen = Case{ID: "horizon.5.5.merge-deepen", Req: "HZ-5.5-2"} horizonPushCheck = Case{ID: "horizon.5.7.push-check", Req: "HZ-5.7-2"} ) func TestHorizonDeepen(t *testing.T) { check(t, horizonMergeBaseQuery, func() error { return hzMergeBaseQuery(t) }) check(t, horizonMergeBaseBound, func() error { return hzMergeBaseBound(t) }) check(t, horizonDeepen, func() error { return hzDeepen(t) }) check(t, horizonDeepenPoint, func() error { return hzDeepenPoint(t) }) check(t, horizonDeepenFailed, func() error { return hzDeepenFailed(t) }) check(t, horizonMergeCrossing, func() error { return hzMergeCrossing(t) }) check(t, horizonMergeDeepen, func() error { return hzMergeDeepen(t) }) check(t, horizonPushCheck, func() error { return hzPushCheck(t) }) } // hzMergeBaseQuery: a structural clone asks its origin for the merge base of // two check-ins, one below its horizon, and the origin answers from its own // graph: the base, or - for check-ins sharing no ancestor. func hzMergeBaseQuery(t *testing.T) error { x := newHZStruct(t) r, _, err := x.clone(t, x.peer()) if err != nil { return err } for _, q := range [][3]string{{x.c4, x.feat1, x.c1}, {x.feat2, x.c4, x.c3}} { res, err := sync.Sync(context.Background(), r, x.peer(), sync.SyncOpts{Pull: true, MergeBaseOf: [2]string{q[0], q[1]}}) if err != nil || !res.MergeBaseAnswered || res.MergeBase != q[2] { return expectf("base of %.10s and %.10s: %v answered=%v %q, want %.10s", q[0], q[1], err, res.MergeBaseAnswered, res.MergeBase, q[2]) } } root := hzCommit(t, x.s.f, "unrelated", []libfossil.FslID{0}, nil) res, err := sync.Sync(context.Background(), r, x.peer(), sync.SyncOpts{Pull: true, MergeBaseOf: [2]string{x.c4, hzUUID(t, x.s.f.r, root)}}) if err != nil || !res.MergeBaseAnswered || res.MergeBase != "" { return expectf("unrelated: %v answered=%v %q, want -", err, res.MergeBaseAnswered, res.MergeBase) } return nil } // hzMergeBaseBound: the walk behind an answer stops at its visited-node // bound, as the origin's other graph walks do (§8). func hzMergeBaseBound(t *testing.T) error { x := newHZStruct(t) if _, err := merge.FindCommonAncestorWithin(context.Background(), x.s.f.r, x.s.c4, x.s.feat1, 2); !errors.Is(err, merge.ErrBound) { return expectf("a walk past its bound: %v, want merge.ErrBound", err) } base, err := merge.FindCommonAncestorWithin(context.Background(), x.s.f.r, x.s.c4, x.s.feat1, 64) if err != nil || base != x.s.c1 { return expectf("within the bound: %d %v, want c1", base, err) } return nil } // hzDeepen: deepening a structural clone from C = c3 to its parent c2 moves // the stored horizon, discards the seed for c3 before the synchronization // (the request offers none), takes c2's manifest and the seed for c2, and // replaces nothing held: K's check-ins derive densely, only c1 and feat1 // stay below. func hzDeepen(t *testing.T) error { x := newHZStruct(t) r, _, err := x.clone(t, x.peer()) if err != nil { return err } before, err := hzHeldNames(r) if err != nil { return err } peer := x.peer() res, err := sync.Deepen(context.Background(), r, peer, x.c2, sync.SyncOpts{}) if err != nil || res.HorizonFallback || res.StructuralServed == 0 { return expectf("deepen to c2: %v %+v", err, res) } if err := hzStoredHorizon(r, x.c2, cone.DepthStructural); err != nil { return err } first := peer.sent()[0] if got := hzPragmas(first, "horizon-seed"); len(got) != 1 || got[0][0] != "-" { return expectf("the deepen's first request offers seed %q, want - (the seed for c3 discarded first)", got) } if got := hzPragmas(first, "horizon"); len(got) != 1 || got[0][0] != x.c2 { return expectf("the deepen's first request names horizon %q, want c2", got) } k, err := horizon.HeldSet(context.Background(), x.s.f.r.DB(), x.s.c2, cone.RuleDescendants) if err != nil { return err } _, want, err := seed.Mint(x.s.f.r.DB(), x.s.c2, k, seedLoginGo) if err != nil { return err } if got := mustHeld(t, r); string(got) != string(want) { return expectf("held seed after the deepen:\n%s\nwant the seed for c2:\n%s", got, want) } after, err := hzHeldNames(r) if err != nil { return err } for name := range before { if !after[name] { return expectf("the deepen dropped held artifact %.10s", name) } } if !after[x.c2] { return expectf("c2's manifest is not held after the deepen") } x.s.k = k if err := x.s.derivesDense(t, r); err != nil { return err } return hzElided(r, map[string]bool{x.c1: true, x.feat1: true}) } // hzDeepenPoint: a deepen toward a check-in that is not an ancestor of C // moves C to their merge base, so K only grows: feat1 lies off C's // ancestry, the clone holds neither its manifest nor its ancestry, so it // asks the origin, and C' is c1. func hzDeepenPoint(t *testing.T) error { x := newHZStruct(t) r, _, err := x.clone(t, x.peer()) if err != nil { return err } peer := x.peer() if _, err := sync.Deepen(context.Background(), r, peer, x.feat1, sync.SyncOpts{}); err != nil { return err } if err := hzStoredHorizon(r, x.c1, cone.DepthStructural); err != nil { return err } if got := hzPragmas(peer.sent()[0], "horizon-mergebase"); len(got) != 1 || got[0][0] != x.c3 || got[0][1] != x.feat1 { return expectf("the deepen asked %q, want the base of c3 and feat1", got) } return hzElided(r, nil) } // hzDeepenFailed: a deepen whose synchronization fails leaves the store as it // was, the horizon and the seed for C both, as zit's does. func hzDeepenFailed(t *testing.T) error { x := newHZStruct(t) r, _, err := x.clone(t, x.peer()) if err != nil { return err } held := mustHeld(t, r) if _, err := sync.Deepen(context.Background(), r, hzFailingPeer{}, x.c2, sync.SyncOpts{}); err == nil { return expectf("a deepen through a failing peer succeeded") } if err := hzStoredHorizon(r, x.c3, cone.DepthStructural); err != nil { return err } if got := mustHeld(t, r); string(got) != string(held) { return expectf("the failed deepen left seed %q, want the seed for c3 back", got) } return x.s.derivesDense(t, r) } type hzFailingPeer struct{} func (hzFailingPeer) Exchange(context.Context, *xfer.Message) (*xfer.Message, error) { return nil, errors.New("peer unreachable") } // hzStoredHorizon is nil when r's store cone carries a horizon at c, of the // descendants rule, at depth d. func hzStoredHorizon(r *repo.Repo, c string, d cone.HorizonDepth) error { sc, err := content.StoreCone(r.DB()) if err != nil { return err } if sc == nil { return expectf("the store is dense") } h, ok := sc.Horizon() if !ok || h.Hash() != c || h.Rule() != cone.RuleDescendants || h.Depth() != d { return expectf("stored horizon %v %s %v %v, want %.10s descendants %v", ok, h.Hash(), h.Rule(), h.Depth(), c, d) } return nil } // hzHeldNames is every artifact r holds, by name. func hzHeldNames(r *repo.Repo) (map[string]bool, error) { rows, err := r.DB().Query("SELECT uuid FROM blob WHERE size>=0") if err != nil { return nil, err } defer rows.Close() out := map[string]bool{} for rows.Next() { var u string if err := rows.Scan(&u); err != nil { return nil, err } out[u] = true } return out, rows.Err() } // hzMergeFixture is hzStruct's origin with y, a check-in merging feat1 into // c4, served over HTTP, and a structural clone of it at C = c3 with a // checkout at feat2. The merge base of feat2 and y is feat1, below the // horizon: the store holds y and feat2 but not their base. type hzMergeFixture struct { x *hzStruct y string origin *hzRecorder url string r *gofossil.Repo co *gofossil.Checkout } func newHZMergeFixture(t *testing.T) *hzMergeFixture { t.Helper() x := newHZStruct(t) f := &hzMergeFixture{x: x, origin: &hzRecorder{r: x.s.f.r}} f.y = hzUUID(t, x.s.f.r, hzCommit(t, x.s.f, "y", []libfossil.FslID{x.s.c4, x.s.feat1}, nil)) server := httptest.NewServer(f.origin) t.Cleanup(server.Close) f.url = server.URL hc, err := gofossil.NewHorizonCone(nil, nil, gofossil.Horizon{Checkin: x.c3, Structural: true}) if err != nil { t.Fatal(err) } r, res, err := gofossil.Clone(context.Background(), filepath.Join(t.TempDir(), "structural.fossil"), gofossil.NewHTTPTransport(f.url), gofossil.CloneOpts{StoreCone: hc, PullPhantoms: true}) if err != nil || res.HorizonFallback { t.Fatalf("structural clone: %v %+v", err, res) } t.Cleanup(func() { r.Close() }) f.r = r co, err := r.CreateCheckout(t.TempDir(), gofossil.CheckoutCreateOpts{}) if err != nil { t.Fatal(err) } t.Cleanup(func() { co.Close() }) if err := co.Extract(f.rid(t, x.feat2), gofossil.ExtractOpts{}); err != nil { t.Fatal(err) } f.co = co f.origin.take() return f } func (f *hzMergeFixture) rid(t *testing.T, uuid string) int64 { t.Helper() rid, err := f.r.ResolveVersion(uuid) if err != nil { t.Fatal(err) } return rid } // hzMergeCrossing: a merge whose base lies below the structural horizon is // detected before anything changes, and the diagnostic names the horizon, // the base, and the remedy. func hzMergeCrossing(t *testing.T) error { f := newHZMergeFixture(t) _, err := f.co.Merge(gofossil.CheckoutMergeOpts{Version: f.rid(t, f.y)}) if !errors.Is(err, gofossil.ErrBelowHorizon) { return expectf("merge across the horizon: %v, want ErrBelowHorizon", err) } for _, want := range []string{"below the structural horizon at " + f.x.c3, f.x.feat1, "deepen"} { if !strings.Contains(err.Error(), want) { return expectf("diagnostic %q lacks %q", err, want) } } if v, _, err := f.co.Version(); err != nil || v != f.rid(t, f.x.feat2) { return expectf("the refused merge moved the checkout to %d (%v)", v, err) } changed, err := f.co.HasChanges() if err != nil || changed { return expectf("the refused merge left changes %v (%v)", changed, err) } return nil } // hzMergeDeepen: given a peer, the merge asks the origin for the base, // deepens to a common ancestor of C and the base, and merges over the real // base it then holds. func hzMergeDeepen(t *testing.T) error { f := newHZMergeFixture(t) res, err := f.co.MergeContext(context.Background(), gofossil.CheckoutMergeOpts{Version: f.rid(t, f.y)}, gofossil.RequiredContentOpts{Peer: gofossil.NewHTTPTransport(f.url)}) if err != nil { return expectf("merge with a peer: %v", err) } if res.Ancestor != f.rid(t, f.x.feat1) { return expectf("merged against rid %d, want feat1's", res.Ancestor) } asked := 0 for _, e := range f.origin.take() { asked += len(hzPragmas(e.request, "horizon-mergebase")) } if asked == 0 { return expectf("the merge asked the origin no merge-base query") } sc, err := f.r.StoreCone() if err != nil { return err } if h, ok := sc.Horizon(); !ok || h.Checkin != f.x.c1 || !h.Structural { return expectf("store horizon after the merge %+v %v, want c1 structural", h, ok) } return nil } // hzPushCheck: the origin moves c4 to branch moved after a structural clone // took its seed. The clone, re-seeded by nothing yet, creates a branch from // c4 cancelling sym-trunk; the origin refuses the push, naming the stale seed // and moved, and stores nothing. After a pull re-seeds the clone, the clone // writes the control artifact cancelling sym-moved on its check-in and // pushes again, and the origin admits both and derives the check-in on the // new branch alone. func hzPushCheck(t *testing.T) error { x := newHZStruct(t) r, _, err := x.clone(t, x.peer()) if err != nil { return err } x.s.tag(t, x.s.c4, "branch", tag.TagPropagating, "moved") x.s.tag(t, x.s.c4, "sym-moved", tag.TagPropagating, "") x.s.tag(t, x.s.c4, "sym-trunk", tag.TagCancel, "") c4, err := hzRID(r, x.c4) if err != nil { return err } when := x.s.f.when.Add(24 * time.Hour) nb, _, err := branch.Create(r, branch.CreateOpts{Name: "nb", Parent: c4, User: "u", Time: when}) if err != nil { return err } nbUUID := hzUUID(t, r, nb) res, err := sync.Sync(context.Background(), r, x.peer(), sync.SyncOpts{Push: true}) if !hzRefusedStale(err, res, "'moved'") || !hzRefusedStale(err, res, "'trunk'") { return expectf("push of a stale cancellation: %v %+v", err, res) } if names, err := hzHeldNames(x.s.f.r); err != nil || names[nbUUID] { return expectf("the origin holds the refused check-in (%v)", err) } if res, err := sync.Sync(context.Background(), r, x.peer(), sync.SyncOpts{Pull: true}); err != nil || res.HorizonFallback { return expectf("re-sync: %v %+v", err, res) } if _, err := tag.AddTag(r, tag.TagOpts{TargetRID: nb, TagName: "sym-moved", TagType: tag.TagCancel, User: "u", Time: when.Add(time.Hour)}); err != nil { return err } res, err = sync.Sync(context.Background(), r, x.peer(), sync.SyncOpts{Push: true}) if err != nil || len(res.Errors) != 0 { return expectf("push with the correction: %v %q", err, res.Errors) } got, err := hzTagState(x.s.f.r.DB(), nbUUID) if err != nil || !slices.Contains(got, "branch/2=nb") || slices.ContainsFunc(got, func(s string) bool { return strings.HasPrefix(s, "sym-moved") || strings.HasPrefix(s, "sym-trunk") }) { return expectf("the origin derives the corrected check-in as %v (%v)", got, err) } return nil }