Artifact c2725b539ff85050447e930b207ad82d615a49c7838f8c16f53973b186be129b:
- File
internal/blob/blob_test.go
— part of check-in
[23cfc00bfe]
at
2026-10-11 23:28:59
on branch main
— fix(blob): fill a phantom in place when its content is stored (#278) (#319)
blob.Store, StoreDelta and StoreDeltaRaw now share writeRow, which fills a phantom row of the same name in place (content, size, and removal from the phantom table) as fossil's content_put does, and returns that rid. existingName no longer treats a phantom as stored content, so a check-in of a file matching a known phantom stores the file instead of recording a manifest that points at nothing. (user: [email protected], size: 18647) [annotate] [blame] [check-ins using]
package blob import ( "bytes" "fmt" "path/filepath" "strings" "testing" "github.com/danmestas/go-libfossil/db" "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/testdriver" "github.com/danmestas/go-libfossil/simio" ) func setupTestDB(t *testing.T) *db.DB { t.Helper() path := filepath.Join(t.TempDir(), "test.fossil") d, err := db.Open(path) if err != nil { t.Fatalf("db.Open: %v", err) } if err := db.CreateRepoSchema(d); err != nil { t.Fatalf("CreateRepoSchema: %v", err) } t.Cleanup(func() { d.Close() }) return d } func TestStoreAndLoad(t *testing.T) { d := setupTestDB(t) content := []byte("hello fossil world") rid, uuid, err := Store(d, content) if err != nil { t.Fatalf("Store: %v", err) } if rid <= 0 { t.Fatalf("rid = %d, want > 0", rid) } if len(uuid) != 40 && len(uuid) != 64 { t.Fatalf("uuid length = %d, want 40 or 64", len(uuid)) } got, err := Load(d, rid) if err != nil { t.Fatalf("Load: %v", err) } if !bytes.Equal(got, content) { t.Fatalf("Load = %q, want %q", got, content) } } // TestStoreEmptyContent is a regression test for issue #68: committing a // zero-length file panicked with "content length must be > 0" instead of // storing the well-known empty artifact. An empty file is legal input — // Fossil repositories routinely contain it (e.g. da39a3ee... under SHA1) — // so Store must accept it like any other content. func TestStoreEmptyContent(t *testing.T) { d := setupTestDB(t) rid, uuid, err := Store(d, []byte{}) if err != nil { t.Fatalf("Store(empty): %v", err) } if rid <= 0 { t.Fatalf("rid = %d, want > 0", rid) } const emptySHA1 = "da39a3ee5e6b4b0d3255bfef95601890afd80709" if uuid != emptySHA1 { t.Fatalf("uuid = %q, want well-known empty-content hash %q", uuid, emptySHA1) } got, err := Load(d, rid) if err != nil { t.Fatalf("Load(empty): %v", err) } if len(got) != 0 { t.Fatalf("Load(empty) = %q, want zero-length", got) } } // TestStoreNilContentTreatedAsEmpty verifies nil content (as distinct from // a zero-length but non-nil slice) is stored the same way as empty — a // caller building manifest.File{Content: nil} for an empty file should not // need to remember to substitute []byte{}. func TestStoreNilContentTreatedAsEmpty(t *testing.T) { d := setupTestDB(t) rid, uuid, err := Store(d, nil) if err != nil { t.Fatalf("Store(nil): %v", err) } if rid <= 0 { t.Fatalf("rid = %d, want > 0", rid) } const emptySHA1 = "da39a3ee5e6b4b0d3255bfef95601890afd80709" if uuid != emptySHA1 { t.Fatalf("uuid = %q, want well-known empty-content hash %q", uuid, emptySHA1) } } func TestExists(t *testing.T) { d := setupTestDB(t) content := []byte("existence test") _, uuid, err := Store(d, content) if err != nil { t.Fatalf("Store: %v", err) } rid, ok := Exists(d, uuid) if !ok { t.Fatal("Exists returned false for stored blob") } if rid <= 0 { t.Fatalf("Exists rid = %d, want > 0", rid) } _, ok = Exists(d, "0000000000000000000000000000000000000000") if ok { t.Fatal("Exists returned true for non-existent blob") } } func TestStorePhantom(t *testing.T) { d := setupTestDB(t) uuid := "da39a3ee5e6b4b0d3255bfef95601890afd80709" rid, err := StorePhantom(d, uuid) if err != nil { t.Fatalf("StorePhantom: %v", err) } if rid <= 0 { t.Fatalf("phantom rid = %d, want > 0", rid) } var count int d.QueryRow("SELECT count(*) FROM phantom WHERE rid=?", rid).Scan(&count) if count != 1 { t.Fatalf("phantom table count = %d, want 1", count) } var size int64 d.QueryRow("SELECT size FROM blob WHERE rid=?", rid).Scan(&size) if size != -1 { t.Fatalf("phantom size = %d, want -1", size) } } func TestStoreDelta(t *testing.T) { d := setupTestDB(t) source := []byte("original content here") target := []byte("original content modified") srcRid, _, err := Store(d, source) if err != nil { t.Fatalf("Store source: %v", err) } tgtRid, _, err := StoreDelta(d, target, srcRid) if err != nil { t.Fatalf("StoreDelta: %v", err) } if tgtRid <= 0 { t.Fatalf("tgtRid = %d, want > 0", tgtRid) } var srcid int64 err = d.QueryRow("SELECT srcid FROM delta WHERE rid=?", tgtRid).Scan(&srcid) if err != nil { t.Fatalf("delta row missing: %v", err) } if srcid != int64(srcRid) { t.Fatalf("delta.srcid = %d, want %d", srcid, srcRid) } } // TestLoadInflatesWhenStoredLengthEqualsSize is a regression test for issue // #235. A delta row's blob.size is the expanded artifact's length, while its // content is the compressed delta; when those two lengths coincide, Load // returned the still-compressed bytes as if they were stored raw. Whether a // given input hits the coincidence depends on compress/flate's exact output, // which changed in Go 1.27, so the row is built here with the lengths forced // equal rather than hoping a fixture lands on them. func TestLoadInflatesWhenStoredLengthEqualsSize(t *testing.T) { d := setupTestDB(t) deltaBytes := delta.Create( []byte("the original source content for delta testing purposes here"), []byte("the original source content for MODIFIED testing purposes here"), ) compressed, err := Compress(deltaBytes) if err != nil { t.Fatalf("Compress: %v", err) } res, err := d.Exec( "INSERT INTO blob(uuid, size, content, rcvid) VALUES(?, ?, ?, 1)", strings.Repeat("e", 40), len(compressed), compressed, ) if err != nil { t.Fatalf("insert: %v", err) } rid, err := res.LastInsertId() if err != nil { t.Fatalf("lastid: %v", err) } got, err := Load(d, libfossil.FslID(rid)) if err != nil { t.Fatalf("Load: %v", err) } if !bytes.Equal(got, deltaBytes) { t.Fatalf("Load = %x, want the inflated delta %x", got, deltaBytes) } } func TestStoreMarksUnclustered(t *testing.T) { d := setupTestDB(t) content := []byte("unclustered test blob") rid, _, err := Store(d, content) if err != nil { t.Fatalf("Store: %v", err) } var count int d.QueryRow("SELECT count(*) FROM unclustered WHERE rid=?", rid).Scan(&count) if count != 1 { t.Fatalf("unclustered count = %d, want 1", count) } } func TestStoreDeltaMarksUnclustered(t *testing.T) { d := setupTestDB(t) source := []byte("delta source content here") target := []byte("delta target content here") srcRid, _, _ := Store(d, source) tgtRid, _, err := StoreDelta(d, target, srcRid) if err != nil { t.Fatalf("StoreDelta: %v", err) } for _, rid := range []int64{int64(srcRid), int64(tgtRid)} { var count int d.QueryRow("SELECT count(*) FROM unclustered WHERE rid=?", rid).Scan(&count) if count != 1 { t.Fatalf("unclustered count for rid %d = %d, want 1", rid, count) } } } // TestStoreDeltaRawAgainstPhantomBase is the storage-layer test for the // core of the delta-before-base fix: a delta whose base has not arrived // yet must be persisted immediately, linked to a phantomized base — not // discarded, and not stored by phantomizing the target instead (the // inversion the bug used to make). This constructs the delta-before-base // ordering explicitly: the base is phantomized (never given real content) // before the delta referencing it is ever stored. func TestStoreDeltaRawAgainstPhantomBase(t *testing.T) { d := setupTestDB(t) source := []byte("original content here, long enough to compress well") target := []byte("original content here, long enough to compress well, and modified") deltaBytes := delta.Create(source, target) targetUUID := hash.SHA1(target) baseUUID := hash.SHA1(source) // Base arrives nowhere in this test — only its UUID is known, via the // delta card that references it. Resolve-or-create-phantom, mirroring // Fossil's rid_from_uuid(&src, 1, ...) in src/xfer.c. srcRid, err := StorePhantom(d, baseUUID) if err != nil { t.Fatalf("StorePhantom base: %v", err) } rid, err := StoreDeltaRaw(d, targetUUID, deltaBytes, srcRid, nil) if err != nil { t.Fatalf("StoreDeltaRaw: %v", err) } if rid <= 0 { t.Fatalf("rid = %d, want > 0", rid) } // The target must be a real row, not a phantom: the delta bytes were // persisted, not discarded. A size of -1 here would mean we fell back // to the old (buggy) behavior of phantomizing the target instead. var size int64 if err := d.QueryRow("SELECT size FROM blob WHERE rid=?", rid).Scan(&size); err != nil { t.Fatalf("query target size: %v", err) } if size != int64(len(target)) { t.Fatalf("target size = %d, want %d (target must not be phantomized)", size, len(target)) } var targetPhantomCount int d.QueryRow("SELECT count(*) FROM phantom WHERE rid=?", rid).Scan(&targetPhantomCount) if targetPhantomCount != 0 { t.Fatalf("target rid %d found in phantom table, want absent", rid) } // The delta-to-source link must exist immediately, even though the // source is still unresolved. var srcid int64 if err := d.QueryRow("SELECT srcid FROM delta WHERE rid=?", rid).Scan(&srcid); err != nil { t.Fatalf("delta row missing for rid=%d: %v", rid, err) } if srcid != int64(srcRid) { t.Fatalf("delta.srcid = %d, want %d", srcid, srcRid) } // The base is the one left phantomized — the direction the fix // inverts relative to the old behavior. var baseSize int64 d.QueryRow("SELECT size FROM blob WHERE rid=?", srcRid).Scan(&baseSize) if baseSize != -1 { t.Fatalf("base size = %d, want -1 (base must remain phantom until it arrives)", baseSize) } } // TestStoreDeltaRawExistingRealBlobIsNoOp mirrors StoreDelta/Store's // idempotency: if a real (non-phantom) blob already exists for uuid, // StoreDeltaRaw must not touch it. func TestStoreDeltaRawExistingRealBlobIsNoOp(t *testing.T) { d := setupTestDB(t) source := []byte("source content for no-op test, long enough to compress") target := []byte("target content for no-op test, long enough to compress") rid, _, err := Store(d, target) if err != nil { t.Fatalf("Store target: %v", err) } srcRid, _, err := Store(d, source) if err != nil { t.Fatalf("Store source: %v", err) } deltaBytes := delta.Create(source, target) targetUUID := hash.SHA1(target) gotRid, err := StoreDeltaRaw(d, targetUUID, deltaBytes, srcRid, nil) if err != nil { t.Fatalf("StoreDeltaRaw: %v", err) } if gotRid != rid { t.Fatalf("rid = %d, want %d (existing real blob)", gotRid, rid) } var deltaCount int d.QueryRow("SELECT count(*) FROM delta WHERE rid=?", rid).Scan(&deltaCount) if deltaCount != 0 { t.Fatalf("delta row count = %d, want 0 (already-real blob must not gain a delta link)", deltaCount) } } // TestStoreDeltaRawOverflowHeaderRejected is the storage-layer counterpart // to delta.TestOutputSize_OverflowRejected: the corruption this guards // against lives in the DB row, not in a parser's return value, so this // asserts against the row directly. A crafted header that overflows the // integer parse must not reach the blob table at all -- if it did, and // the overflow wrapped to math.MaxUint64 (int64(-1)), the resulting row // would report size=-1 despite holding real content: indistinguishable // from a genuine phantom, and no phantom-table row to make it // re-requestable. Permanent, silent corruption from a crafted header. func TestStoreDeltaRawOverflowHeaderRejected(t *testing.T) { d := setupTestDB(t) baseUUID := "da39a3ee5e6b4b0d3255bfef95601890afd80709" srcRid, err := StorePhantom(d, baseUUID) if err != nil { t.Fatalf("StorePhantom base: %v", err) } targetUUID := "0000000000000000000000000000000000000001" overflowHeader := []byte("~~~~~~~~~~~\n;0,") if _, err := StoreDeltaRaw(d, targetUUID, overflowHeader, srcRid, nil); err == nil { t.Fatal("StoreDeltaRaw(overflow header) = nil error, want rejection") } rid, exists := Exists(d, targetUUID) if !exists { // Never having created a row at all is the correct outcome too. return } var size int64 if err := d.QueryRow("SELECT size FROM blob WHERE rid=?", rid).Scan(&size); err != nil { t.Fatalf("query size: %v", err) } if size < 0 { t.Fatalf("target size = %d, want >= 0 or no row at all -- a rejected store must never "+ "leave behind a row whose size collides with the phantom sentinel", size) } } // TestStoreDeltaRawEmptyPayloadReturnsError is a regression test for a // remote-triggerable panic: a peer sending a delta file card with // zero-length content must produce an error, not crash the process. This // asserts the returned error directly rather than relying on a // recover()-based probe, so a regression back to a panic fails the test // (and the process) instead of being masked by matching on panic text. func TestStoreDeltaRawEmptyPayloadReturnsError(t *testing.T) { d := setupTestDB(t) baseUUID := "da39a3ee5e6b4b0d3255bfef95601890afd80709" srcRid, err := StorePhantom(d, baseUUID) if err != nil { t.Fatalf("StorePhantom base: %v", err) } targetUUID := "0000000000000000000000000000000000000002" _, err = StoreDeltaRaw(d, targetUUID, []byte{}, srcRid, nil) if err == nil { t.Fatal("StoreDeltaRaw(empty payload) = nil error, want an error " + "(wire-supplied empty content must not panic)") } } // TestStoreDeltaRawStoresVerbatimBlobWhenGiven is the storage-layer // regression test for issue #112 on the delta path: when a caller supplies // storedBlob (bytes already in Fossil's on-disk blob format, e.g. received // over the wire), StoreDeltaRaw must write those bytes into blob.content // unchanged rather than recompressing deltaBytes with its own zlib writer. func TestStoreDeltaRawStoresVerbatimBlobWhenGiven(t *testing.T) { d := setupTestDB(t) source := []byte("original content here, long enough to compress well") target := []byte("original content here, long enough to compress well, and modified") deltaBytes := delta.Create(source, target) targetUUID := hash.SHA1(target) baseUUID := hash.SHA1(source) srcRid, err := StorePhantom(d, baseUUID) if err != nil { t.Fatalf("StorePhantom base: %v", err) } // A deliberately distinctive "verbatim" blob -- not what Compress would // produce -- so the test fails loudly if StoreDeltaRaw recompresses // deltaBytes instead of using storedBlob. storedBlob, err := Compress(deltaBytes) if err != nil { t.Fatalf("Compress(deltaBytes): %v", err) } storedBlob = append(append([]byte{}, storedBlob...), []byte("-marker-not-part-of-real-zlib")...) rid, err := StoreDeltaRaw(d, targetUUID, deltaBytes, srcRid, storedBlob) if err != nil { t.Fatalf("StoreDeltaRaw: %v", err) } var gotContent []byte if err := d.QueryRow("SELECT content FROM blob WHERE rid=?", rid).Scan(&gotContent); err != nil { t.Fatalf("query content: %v", err) } if !bytes.Equal(gotContent, storedBlob) { t.Fatalf("blob.content was recompressed instead of stored verbatim:\n got %x\n want %x", gotContent, storedBlob) } } func TestStoreExistingBlobSkipsUnclustered(t *testing.T) { d := setupTestDB(t) content := []byte("idempotent blob test") rid, _, _ := Store(d, content) d.Exec("DELETE FROM unclustered WHERE rid=?", rid) rid2, _, err := Store(d, content) if err != nil { t.Fatalf("Store: %v", err) } if rid2 != rid { t.Fatalf("rid = %d, want %d (same blob)", rid2, rid) } var count int d.QueryRow("SELECT count(*) FROM unclustered WHERE rid=?", rid).Scan(&count) if count != 0 { t.Fatalf("unclustered count = %d, want 0 (already-existing blob)", count) } } // TestStoreVerifyCatchesBuggify enables BUGGIFY and runs Store in a loop. // The Decompress BUGGIFY site (2% truncation) should cause at least one // verify failure, confirming the round-trip check catches corruption. func TestStoreVerifyCatchesBuggify(t *testing.T) { var caught int for seed := int64(0); seed < 50; seed++ { d := setupTestDB(t) simio.EnableBuggify(seed) content := []byte(fmt.Sprintf("buggify store test seed=%d with enough data to compress", seed)) _, _, err := Store(d, content) simio.DisableBuggify() if err != nil && strings.Contains(err.Error(), "verify") { caught++ } } if caught == 0 { t.Fatal("expected at least one verify failure across 50 BUGGIFY seeds") } t.Logf("caught %d verify failures out of 50 seeds", caught) } // TestStoreDeltaVerifyCatchesBuggify enables BUGGIFY and runs StoreDelta in a loop. // The Decompress BUGGIFY site should cause verify failures on delta round-trip. func TestStoreDeltaVerifyCatchesBuggify(t *testing.T) { var caught int // StoreDelta calls Decompress multiple times (source load + verify read-back), // so we need more seeds to hit the 2% BUGGIFY on the right call. for seed := int64(0); seed < 200; seed++ { d := setupTestDB(t) // Store source without BUGGIFY so we have a clean base. source := []byte("delta source content that is long enough to compress well in the test") srcRid, _, err := Store(d, source) if err != nil { t.Fatalf("seed %d: Store source: %v", seed, err) } simio.EnableBuggify(seed) target := []byte(fmt.Sprintf("delta target content modified for seed=%d with padding to ensure compression", seed)) _, _, err = StoreDelta(d, target, srcRid) simio.DisableBuggify() if err != nil && strings.Contains(err.Error(), "verify") { caught++ } } if caught == 0 { t.Fatal("expected at least one verify failure across 200 BUGGIFY seeds") } t.Logf("caught %d verify failures out of 200 seeds", caught) } func BenchmarkStore(b *testing.B) { d := func() *db.DB { path := filepath.Join(b.TempDir(), "bench.fossil") d, _ := db.Open(path) db.CreateRepoSchema(d) return d }() defer d.Close() data := bytes.Repeat([]byte("benchmark data"), 100) b.ResetTimer() for i := 0; i < b.N; i++ { content := append(data, byte(i), byte(i>>8)) Store(d, content) } } // Storing content whose name is a phantom fills that phantom in place: // the same rid, readable afterwards, no longer listed in the phantom table. func TestStoreFillsPhantom(t *testing.T) { d := setupTestDB(t) content := []byte("content that arrives after it was referenced") phantomRid, err := StorePhantom(d, hash.SHA1(content)) if err != nil { t.Fatalf("StorePhantom: %v", err) } rid, _, err := Store(d, content) if err != nil { t.Fatalf("Store: %v", err) } if rid != phantomRid { t.Fatalf("Store rid = %d, want the phantom's rid %d", rid, phantomRid) } got, err := Load(d, rid) if err != nil { t.Fatalf("Load after Store over phantom: %v", err) } if !bytes.Equal(got, content) { t.Fatalf("Load = %q, want %q", got, content) } var n int if err := d.QueryRow("SELECT count(*) FROM phantom WHERE rid=?", rid).Scan(&n); err != nil || n != 0 { t.Fatalf("phantom rows for rid = %d (err %v), want 0", n, err) } }