Artifact 7309550701017e7834cba2224e8c6100acf34ae5a5410de7e1b48c898d04ba87:
- File
internal/horizon/name_test.go
— part of check-in
[6f12912a01]
at
2026-10-24 00:37:09
on branch main
— fix(horizon): walk back in one bounded query
ResolveName stepped back one query per check-in, up to 2^32-1 times. A recursive query now walks the primary parents at most depth steps and never more than the check-ins the repository holds, so even a corrupt cycle of primary parents ends. (user: [email protected], size: 4956) [annotate] [blame] [check-ins using]
package horizon_test import ( "errors" "fmt" "math" "slices" "strings" "testing" "time" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/horizon" "github.com/danmestas/go-libfossil/internal/manifest" ) func (d *dag) uuid(rid libfossil.FslID) string { d.t.Helper() var uuid string if err := d.r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", rid).Scan(&uuid); err != nil { d.t.Fatal(err) } return uuid } // TestResolveName resolves names as draft-fossil-horizon-00 ยง6.2.7 does: tip // is the most recent check-in, a full hash names its check-in, and any other // name is the most recent check-in carrying sym-NAME; depth then steps back // along primary parents, stopping at a root. func TestResolveName(t *testing.T) { d := newDAG(t) trunk := d.chain("c1", "c2", "c3", "c4") feat := d.commit("feat", p(trunk[1]), branchTags("feat")...) m := d.commit("m", p(trunk[3], feat)) rel := d.commit("rel", p(m), deck.TagCard{Type: deck.TagSingleton, Name: "sym-v1", UUID: "*"}) side := d.commit("side", p(trunk[2]), branchTags("side")...) for _, tc := range []struct { name string depth uint32 want libfossil.FslID steps uint32 }{ {"tip", 0, side, 0}, {"trunk", 0, rel, 0}, {"trunk", 3, trunk[2], 3}, // rel -> m -> c4 -> c3: m's primary parent is c4 {"feat", 0, feat, 0}, {"v1", 1, m, 1}, {"side", 0, side, 0}, {d.uuid(trunk[3]), 2, trunk[1], 2}, {strings.ToUpper(d.uuid(trunk[3])), 0, trunk[3], 0}, {"side", 10, trunk[0], 3}, // side -> c3 -> c2 -> c1, the root } { got, err := horizon.ResolveName(d.r.DB(), tc.name, tc.depth) if err != nil { t.Fatalf("ResolveName(%.12s, %d): %v", tc.name, tc.depth, err) } if got.Hash != d.uuid(tc.want) || got.Steps != tc.steps { t.Errorf("ResolveName(%.12s, %d) = %.12s after %d steps, want %.12s after %d", tc.name, tc.depth, got.Hash, got.Steps, d.uuid(tc.want), tc.steps) } } // A file's hash names no check-in, and nothing carries sym-nope. var file string if err := d.r.DB().QueryRow( "SELECT uuid FROM blob WHERE rid NOT IN (SELECT objid FROM event) LIMIT 1").Scan(&file); err != nil { t.Fatal(err) } for _, name := range []string{"nope", file, ""} { if _, err := horizon.ResolveName(d.r.DB(), name, 0); !errors.Is(err, horizon.ErrNameNotFound) { t.Errorf("ResolveName(%q) = %v, want ErrNameNotFound", name, err) } } } // TestResolveNameCancelled: a cancelled branch tag no longer names the // check-ins below the cancellation. func TestResolveNameCancelled(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2") d.commit("x", p(c[1]), branchTags("x")...) got, err := horizon.ResolveName(d.r.DB(), "trunk", 0) if err != nil || got.Hash != d.uuid(c[1]) { t.Fatalf("trunk = %.12s, %v; want c2 (x cancels sym-trunk)", got.Hash, err) } } // TestResolveNameTie: check-ins tied on mtime resolve to the lowest full // hash, never the lowest rid, so two origins resolve a name alike. The // siblings are committed until one sorts below the first, which has the // lowest rid. func TestResolveNameTie(t *testing.T) { d := newDAG(t) root := d.commit("root", nil) when := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) var hashes []string for i := 0; i < 64 && (len(hashes) < 2 || slices.Min(hashes) == hashes[0]); i++ { rid, _, err := manifest.Checkin(d.r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "f.txt", Content: []byte(fmt.Sprintf("sibling %d\n", i))}}, Comment: "sibling", User: "test", Time: when, Parent: root, Tags: branchTags("x"), }) if err != nil { t.Fatal(err) } hashes = append(hashes, d.uuid(rid)) } want := slices.Min(hashes) if want == hashes[0] { t.Fatal("fixture: no sibling sorts below the lowest rid's") } for _, name := range []string{"tip", "x"} { got, err := horizon.ResolveName(d.r.DB(), name, 0) if err != nil || got.Hash != want { t.Errorf("ResolveName(%s) = %.12s, %v; want the lowest hash %.12s, not the lowest rid's %.12s", name, got.Hash, err, want, hashes[0]) } } } // TestResolveNameBounded: the walk back costs no more than the history it // walks. On a corrupt graph whose primary parents form a cycle, the deepest // depth ends within the repository's check-in count instead of circling. func TestResolveNameBounded(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2", "c3") if _, err := d.r.DB().Exec("INSERT INTO plink(pid,cid,isprim,mtime) VALUES(?,?,1,0)", c[2], c[0]); err != nil { t.Fatal(err) } done := make(chan error, 1) var got horizon.Resolved go func() { var err error got, err = horizon.ResolveName(d.r.DB(), "trunk", math.MaxUint32) done <- err }() select { case err := <-done: if err != nil { t.Fatal(err) } if got.Steps > 3 { t.Fatalf("walked %d steps over 3 check-ins", got.Steps) } case <-time.After(10 * time.Second): t.Fatal("the walk back did not end") } }