package verify_test
import (
"bytes"
"crypto/sha256"
"fmt"
"strings"
"testing"
"time"
"github.com/danmestas/go-libfossil/internal/blob"
"github.com/danmestas/go-libfossil/internal/deck"
libfossil "github.com/danmestas/go-libfossil/internal/fsltype"
"github.com/danmestas/go-libfossil/internal/manifest"
"github.com/danmestas/go-libfossil/internal/repo"
_ "github.com/danmestas/go-libfossil/internal/testdriver"
"github.com/danmestas/go-libfossil/internal/verify"
"github.com/danmestas/go-libfossil/simio"
)
func newTestRepo(t *testing.T) *repo.Repo {
t.Helper()
dir := t.TempDir()
r, err := repo.Create(dir+"/test.fossil", "test", simio.CryptoRand{}, "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { r.Close() })
return r
}
func TestVerify_EmptyRepo(t *testing.T) {
r := newTestRepo(t)
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
if !report.OK() {
t.Fatalf("expected clean verify on empty repo, got %d issues", len(report.Issues))
}
if report.BlobsChecked != 0 {
t.Fatalf("expected 0 blobs checked on empty repo, got %d", report.BlobsChecked)
}
}
func TestVerify_CleanRepo(t *testing.T) {
r := newTestRepo(t)
_, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "hello.txt", Content: []byte("hello world")}},
Comment: "initial",
User: "test",
})
if err != nil {
t.Fatal(err)
}
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
if !report.OK() {
for _, iss := range report.Issues {
t.Logf("issue: %s", iss.Message)
}
t.Fatalf("expected clean verify, got %d issues", len(report.Issues))
}
if report.BlobsChecked == 0 {
t.Fatal("expected blobs to be checked")
}
if report.BlobsOK != report.BlobsChecked {
t.Fatalf("expected all blobs OK, got %d/%d", report.BlobsOK, report.BlobsChecked)
}
}
func TestVerify_DetectsHashMismatch(t *testing.T) {
r := newTestRepo(t)
_, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("good content")}},
Comment: "initial",
User: "test",
})
if err != nil {
t.Fatal(err)
}
// Corrupt a file blob
_, err = r.DB().Exec(`UPDATE blob SET content = X'0000000000' WHERE rid = (SELECT MAX(rid) FROM blob WHERE size >= 0)`)
if err != nil {
t.Fatal(err)
}
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
if report.OK() {
t.Fatal("expected issues after corruption")
}
if report.BlobsFailed == 0 {
t.Fatal("expected at least one failed blob")
}
found := false
for _, iss := range report.Issues {
if iss.Kind == verify.IssueHashMismatch || iss.Kind == verify.IssueBlobCorrupt {
found = true
break
}
}
if !found {
t.Fatal("expected IssueHashMismatch or IssueBlobCorrupt")
}
}
// TestVerify_RenamedBlobIsHashMismatch stores a blob under a name its bytes do
// not hash to. Its content expands cleanly, so the report must call it a hash
// mismatch, not a corrupt blob (draft-fossil-repo-state-01 Section 4.4).
func TestVerify_RenamedBlobIsHashMismatch(t *testing.T) {
r := newTestRepo(t)
_, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("good content")}},
Comment: "initial",
User: "test",
})
if err != nil {
t.Fatal(err)
}
misnamed := strings.Repeat("ab", 32)
_, err = r.DB().Exec(`UPDATE blob SET uuid = ?
WHERE rid = (SELECT MAX(rid) FROM blob WHERE size >= 0)`, misnamed)
if err != nil {
t.Fatal(err)
}
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
for _, iss := range report.Issues {
if iss.UUID == misnamed && iss.Kind == verify.IssueHashMismatch {
return
}
}
t.Fatalf("issues %+v: none is IssueHashMismatch on %s", report.Issues, misnamed)
}
// TestVerify_MisnamedBaseNotChargedToDelta renames a delta chain's base. The
// dependent delta's bytes hash to its own name, so the mismatch is the base's
// and the delta is only unexpandable.
func TestVerify_MisnamedBaseNotChargedToDelta(t *testing.T) {
r := newTestRepo(t)
base := []byte(strings.Repeat("base content line\n", 40))
baseRid, _, err := blob.Store(r.DB(), base)
if err != nil {
t.Fatal(err)
}
next := append(append([]byte{}, base...), []byte("one more line\n")...)
childRid, childUUID, err := blob.StoreDelta(r.DB(), next, baseRid)
if err != nil {
t.Fatal(err)
}
misnamed := strings.Repeat("cd", 32)
if _, err := r.DB().Exec("UPDATE blob SET uuid=? WHERE rid=?", misnamed, int64(baseRid)); err != nil {
t.Fatal(err)
}
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
for _, iss := range report.Issues {
if iss.UUID == childUUID && iss.Kind == verify.IssueHashMismatch {
t.Fatalf("delta %d charged with a hash mismatch: %+v", childRid, iss)
}
}
for _, iss := range report.Issues {
if iss.UUID == misnamed && iss.Kind == verify.IssueHashMismatch {
return
}
}
t.Fatalf("issues %+v: base %s not reported as a hash mismatch", report.Issues, misnamed)
}
func TestVerify_ReportsAll(t *testing.T) {
r := newTestRepo(t)
rid1, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("alpha")}},
Comment: "first",
User: "test",
})
if err != nil {
t.Fatal(err)
}
_, _, err = manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("alpha")}, {Name: "b.txt", Content: []byte("bravo")}},
Comment: "second",
User: "test",
Parent: rid1,
})
if err != nil {
t.Fatal(err)
}
// Corrupt ALL non-phantom blobs
_, err = r.DB().Exec("UPDATE blob SET content = X'0000' WHERE size >= 0")
if err != nil {
t.Fatal(err)
}
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
if len(report.Issues) < 2 {
t.Fatalf("expected multiple issues (report-all), got %d", len(report.Issues))
}
}
func TestVerify_DetectsDanglingDelta(t *testing.T) {
r := newTestRepo(t)
_, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("content")}},
Comment: "initial",
User: "test",
})
if err != nil {
t.Fatal(err)
}
// Insert a delta row pointing to nonexistent blobs
_, err = r.DB().Exec("INSERT INTO delta(rid, srcid) VALUES(999999, 888888)")
if err != nil {
t.Fatal(err)
}
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
found := false
for _, iss := range report.Issues {
if iss.Kind == verify.IssueDeltaDangling {
found = true
break
}
}
if !found {
t.Fatal("expected IssueDeltaDangling")
}
}
func TestVerify_DetectsOrphanPhantom(t *testing.T) {
r := newTestRepo(t)
// Insert a phantom row with no corresponding blob
_, err := r.DB().Exec("INSERT INTO phantom(rid) VALUES(999999)")
if err != nil {
t.Fatal(err)
}
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
found := false
for _, iss := range report.Issues {
if iss.Kind == verify.IssuePhantomOrphan {
found = true
break
}
}
if !found {
t.Fatal("expected IssuePhantomOrphan")
}
}
func TestVerify_DetectsMissingEvent(t *testing.T) {
r := newTestRepo(t)
_, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("content")}},
Comment: "initial",
User: "test",
})
if err != nil {
t.Fatal(err)
}
_, err = r.DB().Exec("DELETE FROM event")
if err != nil {
t.Fatal(err)
}
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
found := false
for _, iss := range report.Issues {
if iss.Kind == verify.IssueEventMissing {
found = true
break
}
}
if !found {
for _, iss := range report.Issues {
t.Logf("issue: kind=%d %s", iss.Kind, iss.Message)
}
t.Fatal("expected IssueEventMissing")
}
}
func TestVerify_DetectsMissingPlink(t *testing.T) {
r := newTestRepo(t)
rid1, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("alpha")}},
Comment: "first",
User: "test",
})
if err != nil {
t.Fatal(err)
}
_, _, err = manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("alpha")}, {Name: "b.txt", Content: []byte("bravo")}},
Comment: "second",
User: "test",
Parent: rid1,
})
if err != nil {
t.Fatal(err)
}
_, err = r.DB().Exec("DELETE FROM plink")
if err != nil {
t.Fatal(err)
}
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
found := false
for _, iss := range report.Issues {
if iss.Kind == verify.IssuePlinkMissing {
found = true
break
}
}
if !found {
for _, iss := range report.Issues {
t.Logf("issue: kind=%d %s", iss.Kind, iss.Message)
}
t.Fatal("expected IssuePlinkMissing")
}
}
func TestRebuild_ReconstructsFromScratch(t *testing.T) {
r := newTestRepo(t)
rid1, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("alpha")}},
Comment: "first",
User: "test",
})
if err != nil {
t.Fatal(err)
}
_, _, err = manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("alpha")}, {Name: "b.txt", Content: []byte("bravo")}},
Comment: "second",
User: "test",
Parent: rid1,
})
if err != nil {
t.Fatal(err)
}
// Snapshot counts before
var eventCount, plinkCount int
r.DB().QueryRow("SELECT count(*) FROM event").Scan(&eventCount)
r.DB().QueryRow("SELECT count(*) FROM plink").Scan(&plinkCount)
// Delete all derived tables
for _, tbl := range []string{"event", "mlink", "plink", "tagxref", "filename", "leaf", "unclustered", "unsent"} {
r.DB().Exec("DELETE FROM " + tbl)
}
report, err := verify.Rebuild(r)
if err != nil {
t.Fatal(err)
}
if len(report.TablesRebuilt) == 0 {
t.Fatal("expected TablesRebuilt")
}
var newEventCount, newPlinkCount int
r.DB().QueryRow("SELECT count(*) FROM event").Scan(&newEventCount)
r.DB().QueryRow("SELECT count(*) FROM plink").Scan(&newPlinkCount)
if newEventCount != eventCount {
t.Fatalf("event: want %d got %d", eventCount, newEventCount)
}
if newPlinkCount != plinkCount {
t.Fatalf("plink: want %d got %d", plinkCount, newPlinkCount)
}
}
func TestRebuild_CreatesPhantomForMissingFileBlob(t *testing.T) {
r := newTestRepo(t)
const missingUUID = "0123456789abcdef0123456789abcdef01234567"
manifestBytes, err := (&deck.Deck{
Type: deck.Checkin,
C: "missing file blob",
D: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC),
U: deck.User("test"),
F: []deck.FileCard{
{Name: "missing.txt", UUID: missingUUID},
},
}).Marshal()
if err != nil {
t.Fatalf("marshal checkin manifest: %v", err)
}
manifestRID, _, err := blob.Store(r.DB(), manifestBytes)
if err != nil {
t.Fatalf("store checkin manifest: %v", err)
}
if _, err := verify.Rebuild(r); err != nil {
t.Fatalf("rebuild: %v", err)
}
var fid, size int64
if err := r.DB().QueryRow(`
SELECT m.fid, b.size
FROM mlink AS m
JOIN filename AS f ON f.fnid = m.fnid
JOIN blob AS b ON b.rid = m.fid
WHERE m.mid = ? AND f.name = ?
`, manifestRID, "missing.txt").Scan(&fid, &size); err != nil {
t.Fatalf("query missing.txt mlink: %v", err)
}
if fid <= 0 {
t.Fatalf("missing.txt fid = %d, want a positive phantom RID", fid)
}
if size != -1 {
t.Fatalf("missing.txt phantom size = %d, want -1", size)
}
var deletionRows int
if err := r.DB().QueryRow(`
SELECT count(*)
FROM mlink AS m
JOIN filename AS f ON f.fnid = m.fnid
WHERE m.mid = ? AND f.name = ? AND m.fid = 0
`, manifestRID, "missing.txt").Scan(&deletionRows); err != nil {
t.Fatalf("count missing.txt deletion mlinks: %v", err)
}
if deletionRows != 0 {
t.Fatalf("missing.txt has %d fid=0 deletion mlinks, want none", deletionRows)
}
}
func TestRebuild_Idempotent(t *testing.T) {
r := newTestRepo(t)
_, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("hello")}},
Comment: "initial",
User: "test",
})
if err != nil {
t.Fatal(err)
}
report1, err := verify.Rebuild(r)
if err != nil {
t.Fatal(err)
}
report2, err := verify.Rebuild(r)
if err != nil {
t.Fatal(err)
}
if report1.BlobsChecked != report2.BlobsChecked {
t.Fatalf("blobs: %d vs %d", report1.BlobsChecked, report2.BlobsChecked)
}
}
func TestRebuild_ReconstructsTags(t *testing.T) {
r := newTestRepo(t)
_, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("content")}},
Comment: "initial on trunk",
User: "test",
})
if err != nil {
t.Fatal(err)
}
var tagxrefCount int
r.DB().QueryRow("SELECT count(*) FROM tagxref").Scan(&tagxrefCount)
if tagxrefCount == 0 {
t.Fatal("expected tagxref rows after checkin with trunk tags")
}
for _, tbl := range []string{"event", "mlink", "plink", "tagxref", "filename", "leaf", "unclustered", "unsent"} {
r.DB().Exec("DELETE FROM " + tbl)
}
report, err := verify.Rebuild(r)
if err != nil {
t.Fatal(err)
}
_ = report
var newTagxrefCount int
r.DB().QueryRow("SELECT count(*) FROM tagxref").Scan(&newTagxrefCount)
if newTagxrefCount == 0 {
t.Fatal("expected tagxref rows after rebuild")
}
// Verify repo is clean
vReport, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
if !vReport.OK() {
for _, iss := range vReport.Issues {
t.Logf("issue: %s", iss.Message)
}
t.Fatalf("expected clean verify after rebuild, got %d issues", len(vReport.Issues))
}
}
func TestVerify_DetectsIncorrectLeaf(t *testing.T) {
r := newTestRepo(t)
_, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("content")}},
Comment: "initial",
User: "test",
})
if err != nil {
t.Fatal(err)
}
_, err = r.DB().Exec("DELETE FROM leaf")
if err != nil {
t.Fatal(err)
}
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
found := false
for _, iss := range report.Issues {
if iss.Kind == verify.IssueLeafIncorrect {
found = true
break
}
}
if !found {
for _, iss := range report.Issues {
t.Logf("issue: kind=%d %s", iss.Kind, iss.Message)
}
t.Fatal("expected IssueLeafIncorrect")
}
}
func TestRebuild_BuggifyResilience(t *testing.T) {
r := newTestRepo(t)
// Create repo BEFORE enabling BUGGIFY
var files []manifest.File
for i := 0; i < 20; i++ {
files = append(files, manifest.File{
Name: fmt.Sprintf("file%d.txt", i),
Content: []byte(fmt.Sprintf("content %d", i)),
})
}
_, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: files,
Comment: "buggify test",
User: "test",
})
if err != nil {
t.Fatal(err)
}
// Enable BUGGIFY after checkin
simio.EnableBuggify(42)
defer simio.DisableBuggify()
report, err := verify.Rebuild(r)
if err != nil {
// Rebuild may error if manifest blob is corrupted by BUGGIFY — acceptable
t.Logf("Rebuild under BUGGIFY returned error (expected): %v", err)
return
}
if len(report.TablesRebuilt) == 0 {
t.Fatal("expected TablesRebuilt after successful rebuild")
}
t.Logf("BUGGIFY rebuild: %d blobs checked, %d failed, %d skipped",
report.BlobsChecked, report.BlobsFailed, report.BlobsSkipped)
}
func TestVerify_AfterRebuild_IsClean(t *testing.T) {
r := newTestRepo(t)
rid1, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("alpha")}},
Comment: "first",
User: "test",
})
if err != nil {
t.Fatal(err)
}
_, _, err = manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{
{Name: "a.txt", Content: []byte("alpha modified")},
{Name: "b.txt", Content: []byte("bravo")},
},
Comment: "second",
User: "test",
Parent: rid1,
})
if err != nil {
t.Fatal(err)
}
// Rebuild from scratch
_, err = verify.Rebuild(r)
if err != nil {
t.Fatal(err)
}
// Verify should be completely clean
report, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
if !report.OK() {
for _, iss := range report.Issues {
t.Logf("issue: kind=%d table=%s %s", iss.Kind, iss.Table, iss.Message)
}
t.Fatalf("expected clean verify after rebuild, got %d issues", len(report.Issues))
}
}
func TestRebuild_PreservesSparseFileHistory(t *testing.T) {
r := newTestRepo(t)
ts := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
rid1, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{
{Name: "a.txt", Content: []byte("a1")},
{Name: "b.txt", Content: []byte("b1")},
},
Comment: "initial",
User: "u",
Time: ts,
})
if err != nil {
t.Fatalf("initial checkin: %v", err)
}
rid2, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{
{Name: "a.txt", Content: []byte("a2")},
{Name: "b.txt", Content: []byte("b1")}, // unchanged
},
Comment: "modify a",
User: "u",
Time: ts.Add(time.Hour),
Parent: rid1,
})
if err != nil {
t.Fatalf("second checkin: %v", err)
}
// Wipe derived tables and rebuild — canonical mlink derivation preserves
// sparse delta behavior: unchanged files get no mlink row.
for _, tbl := range []string{"event", "mlink", "plink", "tagxref", "filename", "leaf", "unclustered", "unsent"} {
r.DB().Exec("DELETE FROM " + tbl)
}
report, err := verify.Rebuild(r)
if err != nil {
t.Fatalf("Rebuild: %v", err)
}
if report.BlobsFailed > 0 {
t.Fatalf("Rebuild had %d blob failures", report.BlobsFailed)
}
// After rebuild, FileHistory should still work.
histA, err := manifest.FileHistory(r, manifest.FileHistoryOpts{Path: "a.txt"})
if err != nil {
t.Fatalf("FileHistory a.txt: %v", err)
}
wantA := []struct {
checkinRID libfossil.FslID
action manifest.FileAction
}{
{checkinRID: rid2, action: manifest.FileModified},
{checkinRID: rid1, action: manifest.FileAdded},
}
if len(histA) != len(wantA) {
t.Fatalf("a.txt: expected %d versions after rebuild, got %d", len(wantA), len(histA))
}
for i, want := range wantA {
if got := histA[i]; got.CheckinRID != want.checkinRID || got.Action != want.action {
t.Errorf("a.txt version %d = {CheckinRID: %d, Action: %s}, want {CheckinRID: %d, Action: %s}",
i, got.CheckinRID, got.Action, want.checkinRID, want.action)
}
}
// b.txt: canonical sparse mlinks include it only in the initial commit
// (pid=0 → added).
histB, err := manifest.FileHistory(r, manifest.FileHistoryOpts{Path: "b.txt"})
if err != nil {
t.Fatalf("FileHistory b.txt: %v", err)
}
wantB := []struct {
checkinRID libfossil.FslID
action manifest.FileAction
}{
{checkinRID: rid1, action: manifest.FileAdded},
}
if len(histB) != len(wantB) {
t.Fatalf("b.txt: expected %d version after rebuild, got %d", len(wantB), len(histB))
}
for i, want := range wantB {
if got := histB[i]; got.CheckinRID != want.checkinRID || got.Action != want.action {
t.Errorf("b.txt version %d = {CheckinRID: %d, Action: %s}, want {CheckinRID: %d, Action: %s}",
i, got.CheckinRID, got.Action, want.checkinRID, want.action)
}
}
}
// TestRebuild_DeltaManifest verifies that rebuild correctly handles delta
// manifests (B-card). The delta's F-cards only contain changed files.
// Rebuild should create mlink rows for changed files only — matching
// fossil rebuild behavior (not expanding to the full file set).
func TestRebuild_DeltaManifest(t *testing.T) {
r := newTestRepo(t)
// First checkin: two files
rid1, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{
{Name: "a.txt", Content: []byte("alpha")},
{Name: "b.txt", Content: []byte("bravo")},
},
Comment: "first",
User: "test",
})
if err != nil {
t.Fatal(err)
}
// Second checkin: delta manifest (only a.txt changes, b.txt inherited)
_, _, err = manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{
{Name: "a.txt", Content: []byte("alpha modified")},
{Name: "b.txt", Content: []byte("bravo")},
},
Comment: "second (delta)",
User: "test",
Parent: rid1,
Delta: true,
})
if err != nil {
t.Fatal(err)
}
// Delete all derived tables
for _, tbl := range []string{"event", "mlink", "plink", "tagxref", "filename", "leaf", "unclustered", "unsent"} {
r.DB().Exec("DELETE FROM " + tbl)
}
// Rebuild
_, err = verify.Rebuild(r)
if err != nil {
t.Fatal(err)
}
// After rebuild, mlink should exist and repo should be clean
var mlinkCount int
r.DB().QueryRow("SELECT count(*) FROM mlink").Scan(&mlinkCount)
if mlinkCount == 0 {
t.Fatal("expected mlink rows after rebuild with delta manifest")
}
// Verify clean
vReport, err := verify.Verify(r)
if err != nil {
t.Fatal(err)
}
if !vReport.OK() {
for _, iss := range vReport.Issues {
t.Logf("issue: %s", iss.Message)
}
t.Fatalf("expected clean verify after delta rebuild, got %d issues", len(vReport.Issues))
}
}
func TestRebuild_PreservesCanonicalMlinksForLocalMergeWithDeletion(t *testing.T) {
r := newTestRepo(t)
base := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
rootFiles := []manifest.File{
{Name: "a.txt", Content: []byte("base")},
{Name: "delete.txt", Content: []byte("remove me")},
{Name: "keep-1.txt", Content: []byte("keep 1")},
{Name: "keep-2.txt", Content: []byte("keep 2")},
{Name: "keep-3.txt", Content: []byte("keep 3")},
}
rootRID, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: rootFiles,
Comment: "root",
User: "test",
Time: base,
})
if err != nil {
t.Fatalf("root checkin: %v", err)
}
trunkRID, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{
{Name: "a.txt", Content: []byte("trunk")},
{Name: "delete.txt", Content: []byte("remove me")},
{Name: "keep-1.txt", Content: []byte("keep 1")},
{Name: "keep-2.txt", Content: []byte("keep 2")},
{Name: "keep-3.txt", Content: []byte("keep 3")},
},
Comment: "trunk change",
User: "test",
Parent: rootRID,
Time: base.Add(time.Minute),
Delta: true,
})
if err != nil {
t.Fatalf("trunk checkin: %v", err)
}
featureRID, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{
{Name: "a.txt", Content: []byte("feature")},
{Name: "delete.txt", Content: []byte("remove me")},
{Name: "feature.txt", Content: []byte("feature only")},
{Name: "keep-1.txt", Content: []byte("keep 1")},
{Name: "keep-2.txt", Content: []byte("keep 2")},
{Name: "keep-3.txt", Content: []byte("keep 3")},
},
Comment: "feature branch",
User: "test",
Parent: rootRID,
Time: base.Add(2 * time.Minute),
Delta: true,
Tags: []deck.TagCard{
{Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "feature"},
{Type: deck.TagSingleton, Name: "sym-feature", UUID: "*"},
},
})
if err != nil {
t.Fatalf("feature checkin: %v", err)
}
_, _, err = manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{
{Name: "a.txt", Content: []byte("merged")},
{Name: "feature.txt", Content: []byte("feature only")},
{Name: "keep-1.txt", Content: []byte("keep 1")},
{Name: "keep-2.txt", Content: []byte("keep 2")},
{Name: "keep-3.txt", Content: []byte("keep 3")},
},
Comment: "merge feature",
User: "test",
Parent: trunkRID,
MergeParents: []libfossil.FslID{featureRID},
Time: base.Add(3 * time.Minute),
Delta: true,
})
if err != nil {
t.Fatalf("merge checkin: %v", err)
}
snapshotMlinks := func() (string, int) {
rows, err := r.DB().Query(`
SELECT mid, fid, COALESCE(pmid, 0), COALESCE(pid, 0), fnid,
COALESCE(pfnid, 0), COALESCE(mperm, 0), COALESCE(isaux, 0)
FROM mlink
ORDER BY mid, fid, pmid, pid, fnid, pfnid, mperm, isaux
`)
if err != nil {
t.Fatalf("query mlink snapshot: %v", err)
}
defer rows.Close()
var snapshot bytes.Buffer
count := 0
for rows.Next() {
var mid, fid, pmid, pid, fnid, pfnid, mperm, isaux int64
if err := rows.Scan(&mid, &fid, &pmid, &pid, &fnid, &pfnid, &mperm, &isaux); err != nil {
t.Fatalf("scan mlink snapshot: %v", err)
}
fmt.Fprintf(&snapshot, "%d,%d,%d,%d,%d,%d,%d,%d\n", mid, fid, pmid, pid, fnid, pfnid, mperm, isaux)
count++
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate mlink snapshot: %v", err)
}
digest := sha256.Sum256(snapshot.Bytes())
return fmt.Sprintf("%x", digest), count
}
var auxiliaryRows, deletionRows int
if err := r.DB().QueryRow("SELECT count(*) FROM mlink WHERE isaux > 0").Scan(&auxiliaryRows); err != nil {
t.Fatalf("count auxiliary mlinks: %v", err)
}
if auxiliaryRows == 0 {
t.Fatal("expected a positive auxiliary mlink row before rebuild")
}
if err := r.DB().QueryRow("SELECT count(*) FROM mlink WHERE fid = 0").Scan(&deletionRows); err != nil {
t.Fatalf("count deletion mlinks: %v", err)
}
if deletionRows == 0 {
t.Fatal("expected an fid=0 deletion mlink row before rebuild")
}
beforeDigest, beforeRows := snapshotMlinks()
if beforeRows == 0 || beforeDigest == "" {
t.Fatal("expected a nonempty canonical mlink snapshot before rebuild")
}
if _, err := verify.Rebuild(r); err != nil {
t.Fatalf("rebuild: %v", err)
}
afterDigest, afterRows := snapshotMlinks()
if afterRows == 0 || afterDigest == "" {
t.Fatal("expected a nonempty rebuilt mlink snapshot")
}
if afterDigest != beforeDigest {
t.Fatalf("mlink snapshot digest changed after rebuild: before=%s after=%s", beforeDigest, afterDigest)
}
}
func TestRebuild_RejectsOversizedCanonicalMlinkSources(t *testing.T) {
const maxCanonicalMlinkSources = 1024
const fileUUID = "ffffffffffffffffffffffffffffffffffffffff"
hash := func(n int) string {
return fmt.Sprintf("%040x", n)
}
tests := []struct {
name string
deck func() *deck.Deck
}{
{
name: "primary plus too many merge parents",
deck: func() *deck.Deck {
parents := make([]string, 0, maxCanonicalMlinkSources+2)
for i := range maxCanonicalMlinkSources + 2 {
parents = append(parents, hash(i+1))
}
return &deck.Deck{
Type: deck.Checkin,
C: "too many merge parents",
D: time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
F: []deck.FileCard{{Name: "file.txt", UUID: fileUUID}},
P: parents,
U: deck.User("test"),
}
},
},
{
name: "too many cherrypick sources",
deck: func() *deck.Deck {
cherrypicks := make([]deck.CherryPick, 0, maxCanonicalMlinkSources+1)
for i := range maxCanonicalMlinkSources + 1 {
cherrypicks = append(cherrypicks, deck.CherryPick{Target: hash(i + 1)})
}
return &deck.Deck{
Type: deck.Checkin,
C: "too many cherrypick sources",
D: time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
F: []deck.FileCard{{Name: "file.txt", UUID: fileUUID}},
P: []string{hash(maxCanonicalMlinkSources + 2)},
Q: cherrypicks,
U: deck.User("test"),
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := newTestRepo(t)
manifestBytes, err := tc.deck().Marshal()
if err != nil {
t.Fatalf("marshal checkin manifest: %v", err)
}
if _, _, err := blob.Store(r.DB(), manifestBytes); err != nil {
t.Fatalf("store checkin manifest: %v", err)
}
var rebuildErr error
func() {
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("Rebuild panicked: %v", recovered)
}
}()
_, rebuildErr = verify.Rebuild(r)
}()
if rebuildErr == nil {
t.Fatal("Rebuild succeeded for an oversized canonical mlink source set")
}
})
}
}
// Rebuild equivalence tests (Go rebuild == fossil rebuild) live in sim/rebuild_test.go.
// They require the fossil binary and belong with other integration tests.
// TestRebuild_FailedSweepKeepsDerivedState stores a check-in the crosslink
// sweep refuses (more cherrypick sources than it links) beside a good one.
// Rebuild must fail as one transaction, as fossil's rebuild_db does, leaving
// the derived rows it found in place rather than the emptied tables.
func TestRebuild_FailedSweepKeepsDerivedState(t *testing.T) {
r := newTestRepo(t)
if _, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{{Name: "a.txt", Content: []byte("alpha")}},
Comment: "first",
User: "test",
}); err != nil {
t.Fatal(err)
}
before := derivedSnapshot(t, r)
if before == "" {
t.Fatal("expected derived rows before Rebuild")
}
cherrypicks := make([]deck.CherryPick, 1025)
for i := range cherrypicks {
cherrypicks[i] = deck.CherryPick{Target: fmt.Sprintf("%040x", i+1)}
}
bad, err := (&deck.Deck{
Type: deck.Checkin,
C: "too many cherrypick sources",
D: time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
P: []string{fmt.Sprintf("%040x", 2000)},
Q: cherrypicks,
U: deck.User("test"),
}).Marshal()
if err != nil {
t.Fatal(err)
}
if _, _, err := blob.Store(r.DB(), bad); err != nil {
t.Fatal(err)
}
if _, err := verify.Rebuild(r); err == nil {
t.Fatal("Rebuild succeeded over a check-in the sweep refuses")
}
if after := derivedSnapshot(t, r); after != before {
t.Fatalf("failed Rebuild changed derived state:\nbefore:\n%s\nafter:\n%s", before, after)
}
}
// derivedSnapshot renders the crosslink-derived rows Rebuild rewrites.
func derivedSnapshot(t *testing.T, r *repo.Repo) string {
t.Helper()
var b bytes.Buffer
for _, query := range []string{
"SELECT objid, type, mtime FROM event ORDER BY objid",
"SELECT pid, cid, isprim FROM plink ORDER BY pid, cid",
"SELECT mid, fid, fnid FROM mlink ORDER BY mid, fnid",
"SELECT rid FROM leaf ORDER BY rid",
"SELECT tagid, rid, tagtype, srcid FROM tagxref ORDER BY tagid, rid",
} {
rows, err := r.DB().Query(query)
if err != nil {
t.Fatal(err)
}
cols, _ := rows.Columns()
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
rows.Close()
t.Fatal(err)
}
fmt.Fprintf(&b, "%v\n", vals)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
rows.Close()
}
return b.String()
}