Artifact 43b2ca5a3661920c28cb8515283a895498185a2e9f47bab492a75a0381499786:
- File
internal/sync/storereceivedfile_test.go
— part of check-in
[6273a0750d]
at
2026-07-22 23:03:24
on branch trunk
— fix: correct go.mod module path to go-libfossil (#76) (#117)
* fix: correct go.mod module path to go-libfossil (#76)
The module directive still declared github.com/danmestas/libfossil after the repository was renamed to go-libfossil. pkg.go.dev/godoc scraping matches the declared module path against its hosting path, so the stale path broke SDK doc generation.
Rename the module path across all four in-repo modules (root, the two db/driver submodules, observer/otel, and docs/site) and every internal import, plus the release workflow, the doc-generation script, and the docs.
No tag has been published under the go-libfossil path yet, so the cross-module requires cannot resolve from the proxy. Add replace directives pointing the in-repo requires at their local directories so GOWORK=off builds and the CI matrix resolve without a published version. These replaces are ignored by downstream consumers and can be dropped once new-path tags are cut.
* Guard releases against replace directives; regenerate SDK docs
The go-libfossil rename bridges the in-repo modules with `replace` directives. Go refuses a main module that carries them, so a tag cut while they are present breaks `go install .../cmd/libfossil@<tag>` and every `@version` fetch of the driver submodules. Add a Release workflow step that fails the job before anything is published if any tracked go.mod still contains a replace directive, so the bridge is a CI-enforced TODO rather than a silent landmine. The step comment records what unblocks a release: re-tag the submodules with the corrected module paths, bump the root requires, then drop the replace blocks.
Regenerate the SDK reference docs. The previous pass only substituted URLs over the pre-existing generated files, leaving stale signatures (Repo.Timeline was still documented as taking LogOpts rather than TimelineOpts). Note that gomarkdoc must be run outside a git worktree — inside one it silently strips every source link.
Also reword the README rename note, which read as though the import path had not changed, and re-sort the ncruces require block. (user: [email protected], size: 13182) [annotate] [blame] [check-ins using]
package sync import ( "testing" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/delta" "github.com/danmestas/go-libfossil/internal/hash" _ "github.com/danmestas/go-libfossil/internal/testdriver" ) // TestStoreReceivedFileEmptyDeltaPayloadReturnsError is the end-to-end // regression test for the empty-payload panic: storeReceivedFile guarded // payload == nil, which an empty non-nil slice sails past, and nothing // between wire decode (xfer card Content) and blob.StoreDeltaRaw checked // for zero length. A peer sending a delta file card with empty content // must not crash the process on either the clone/pull receive path or the // server push path — both call storeReceivedFile. func TestStoreReceivedFileEmptyDeltaPayloadReturnsError(t *testing.T) { r := setupSyncTestRepo(t) baseUUID := hash.SHA1([]byte("some base content, unrelated to this test")) targetUUID := hash.SHA1([]byte("some target content, unrelated to this test")) err := storeReceivedFile(r, targetUUID, baseUUID, []byte{}) if err == nil { t.Fatal("storeReceivedFile(empty delta payload) = nil error, want an error " + "(a peer-supplied empty delta must not panic the process)") } } // TestStoreReceivedFileNonHexDeltaSrcReturnsError is a regression test for // a permanent-storage-pollution hole: the blob table's own CHECK // constraint only bounds uuid length (>=40), not hex-ness, so a non-hex // string of valid length -- e.g. 40 'z' characters -- passed it and would // otherwise become a permanent, unfillable blob.StorePhantom row. Worse // on the pull path specifically: loadDBPhantoms re-requests every DB // phantom row every sync round forever, so one hostile delta card bought // perpetual gimme traffic for content that could never arrive. The target // uuid gets this same check via hash.IsValidHash; deltaSrc needs it too. func TestStoreReceivedFileNonHexDeltaSrcReturnsError(t *testing.T) { r := setupSyncTestRepo(t) target := []byte("target content for the non-hex delta source test, padded a bit") targetUUID := hash.SHA1(target) base := []byte("base content for the non-hex delta source test, padded a bit") deltaBytes := delta.Create(base, target) nonHexDeltaSrc := "" for i := 0; i < 40; i++ { nonHexDeltaSrc += "z" // valid length, not a hex digit } if err := storeReceivedFile(r, targetUUID, nonHexDeltaSrc, deltaBytes); err == nil { t.Fatal("storeReceivedFile(non-hex deltaSrc) = nil error, want rejection") } // No phantom row -- and no target row -- should have been created for // the rejected delta source. if _, exists := blob.Exists(r.DB(), nonHexDeltaSrc); exists { t.Fatal("a blob row was created for a non-hex deltaSrc -- rejection must happen " + "before any row is written, not after") } if _, exists := blob.Exists(r.DB(), targetUUID); exists { t.Fatal("a blob row was created for the target despite its delta source being rejected") } } // TestStoreReceivedFileDeltaBeforeBase is the decisive storage-layer test // for the delta-before-base fix (see issue #53). It constructs the // ordering explicitly, rather than relying on a mock transport's round // scheduling to happen to produce it: the delta card for the target is // stored before the base's content has ever reached the repo. // // Before the fix, storeReceivedFile discarded the delta bytes and // phantomized the *target*. After the fix it must persist the delta, // linked to a phantomized *base*, and return success — a delta arriving // before its base is Fossil's normal steady state during a transfer, not // an error (src/content.c:content_put_ex). func TestStoreReceivedFileDeltaBeforeBase(t *testing.T) { r := setupSyncTestRepo(t) base := []byte("original file content, long enough to compress reasonably well") target := []byte("original file content, long enough to compress reasonably well, v2") deltaBytes := delta.Create(base, target) baseUUID := hash.SHA1(base) targetUUID := hash.SHA1(target) // The delta arrives first. baseUUID has never been seen before. if err := storeReceivedFile(r, targetUUID, baseUUID, deltaBytes); err != nil { t.Fatalf("storeReceivedFile(delta before base) = %v, want nil: a delta "+ "arriving before its base must not be treated as an error", err) } // The target must be a real row, not phantomized — this is the // inversion the fix corrects: canonical Fossil phantomizes the base, // never the target. targetRid, ok := blob.Exists(r.DB(), targetUUID) if !ok { t.Fatal("target blob row missing after storing delta-before-base") } var targetSize int64 if err := r.DB().QueryRow("SELECT size FROM blob WHERE rid=?", targetRid).Scan(&targetSize); err != nil { t.Fatalf("query target size: %v", err) } if targetSize < 0 { t.Fatalf("target size = %d, want >= 0 (target must not be phantomized; "+ "a -1 here means the delta bytes were discarded, the old bug)", targetSize) } // The target is stored but not yet resolvable, because its base isn't. if _, available := content.AvailableByUUID(r.DB(), targetUUID); available { t.Fatal("target reported available before its base ever arrived") } // The base is what got phantomized — a create-phantom-if-missing // lookup, mirroring Fossil's rid_from_uuid(&src, 1, ...) in xfer.c. baseRid, ok := blob.Exists(r.DB(), baseUUID) if !ok { t.Fatal("base blob row missing: create-phantom-if-missing lookup did not run") } var baseSize int64 if err := r.DB().QueryRow("SELECT size FROM blob WHERE rid=?", baseRid).Scan(&baseSize); err != nil { t.Fatalf("query base size: %v", err) } if baseSize != -1 { t.Fatalf("base size = %d, want -1 (phantom, awaiting delivery)", baseSize) } // The delta-to-source link exists immediately. var srcid int64 if err := r.DB().QueryRow("SELECT srcid FROM delta WHERE rid=?", targetRid).Scan(&srcid); err != nil { t.Fatalf("delta row missing for target rid=%d: %v", targetRid, err) } if srcid != int64(baseRid) { t.Fatalf("delta.srcid = %d, want %d", srcid, baseRid) } // Now the base arrives as ordinary full content, in a later round. if err := storeReceivedFile(r, baseUUID, "", base); err != nil { t.Fatalf("storeReceivedFile(base) = %v", err) } // The target resolves lazily now that its base is available — no // further write to the target's own row was needed. rid, available := content.AvailableByUUID(r.DB(), targetUUID) if !available { t.Fatal("target still unavailable after its base arrived") } got, err := content.Expand(r.DB(), rid) if err != nil { t.Fatalf("content.Expand: %v", err) } if string(got) != string(target) { t.Fatalf("expanded content = %q, want %q", got, target) } } // TestStoreReceivedFileDeltaChainBeforeAnyBase constructs a two-hop delta // chain (target -> mid -> base) and delivers only the target's delta in // the first call, then the mid's delta, then finally the root base — the // worst-case ordering that used to require one round per chain hop // (see issue #53's round/file-amplification numbers). Each store call // must succeed immediately; availability should only resolve once the // whole chain is grounded. func TestStoreReceivedFileDeltaChainBeforeAnyBase(t *testing.T) { r := setupSyncTestRepo(t) base := []byte("root content for chain test, long enough to compress") mid := []byte("root content for chain test, long enough to compress, hop one") target := []byte("root content for chain test, long enough to compress, hop one, hop two") baseUUID := hash.SHA1(base) midUUID := hash.SHA1(mid) targetUUID := hash.SHA1(target) midDelta := delta.Create(base, mid) targetDelta := delta.Create(mid, target) // Deliver deepest-first: target's delta references mid, which itself // doesn't exist yet either. if err := storeReceivedFile(r, targetUUID, midUUID, targetDelta); err != nil { t.Fatalf("storeReceivedFile(target delta) = %v, want nil", err) } if err := storeReceivedFile(r, midUUID, baseUUID, midDelta); err != nil { t.Fatalf("storeReceivedFile(mid delta) = %v, want nil", err) } if _, available := content.AvailableByUUID(r.DB(), targetUUID); available { t.Fatal("target reported available before the chain is grounded") } // Finally the root arrives. if err := storeReceivedFile(r, baseUUID, "", base); err != nil { t.Fatalf("storeReceivedFile(base) = %v", err) } rid, available := content.AvailableByUUID(r.DB(), targetUUID) if !available { t.Fatal("target still unavailable after the full chain arrived") } got, err := content.Expand(r.DB(), rid) if err != nil { t.Fatalf("content.Expand: %v", err) } if string(got) != string(target) { t.Fatalf("expanded content = %q, want %q", got, target) } } // TestStoreReceivedFileRejectsContentNotMatchingClaimedUUID is the // integrity regression test for issue #53's raw-delta path: a hostile // peer can claim any UUID it likes for a delta card, since the base // needed to check that claim is exactly the thing the peer controls the // timing of. It ships a delta that expands to "evil" while claiming the // UUID of "real" (the good content), withholds the base until after the // delta is accepted, then delivers the base. Nothing may ever serve // "evil" under real's UUID. // // Four things can make this test pass for the wrong reason; each is // guarded explicitly: // // 1. If delta.Create degenerates to an all-literal encoding instead of a // copy+insert delta, the raw-delta path never runs and this becomes a // no-op test of the full-content path. Guarded by asserting evilDelta // compressed relative to evil. // 2. If the base were already present when the delta arrives, the eager // verify-on-receipt path (storeResolvedContent, reached indirectly) // would catch the mismatch immediately, proving nothing about the // raw-delta path. Guarded by asserting the base is absent first. // 3. The first store must succeed (delta-before-base is not an error). // Fatal on any error there rather than silently falling through, so a // future change moving rejection earlier gets noticed, not masked. // 4. The final check must be on an error from content.Expand, not merely // on the returned bytes differing from "evil" -- a fix that served // different-but-still-wrong bytes would pass a got != evil check. // // delta.Apply's own trailing checksum (see delta.go) is NOT a defense // here: delta.Create computed that checksum over "evil", so Apply's // internal consistency check passes even though the content is wrong. // Only a hash comparison against the claimed UUID (content.Expand) binds // content to name. func TestStoreReceivedFileRejectsContentNotMatchingClaimedUUID(t *testing.T) { r := setupSyncTestRepo(t) base := []byte("legitimate base content, padded out so a delta is worthwhile") real := append(append([]byte{}, base...), []byte(", GOOD")...) evil := append(append([]byte{}, base...), []byte(", EVIL")...) if len(real) != len(evil) { t.Fatalf("fixture bug: real and evil must be the same length (%d vs %d)", len(real), len(evil)) } claimedUUID := hash.SHA1(real) // attacker claims the GOOD uuid baseUUID := hash.SHA1(base) evilDelta := delta.Create(base, evil) // but ships a delta expanding to EVIL // Guard 1: the delta must actually compress relative to the full evil // content, or this test exercises the wrong code path. if len(evilDelta) >= len(evil) { t.Fatalf("fixture bug: evilDelta (%d bytes) did not compress relative to evil (%d bytes) — "+ "delta.Create degenerated to an all-literal encoding, so this test would exercise the "+ "full-content path, not the raw-delta path", len(evilDelta), len(evil)) } // Guard 2: the base must be genuinely absent before the first store. if _, exists := blob.Exists(r.DB(), baseUUID); exists { t.Fatal("fixture bug: base must be absent before the first store, or this test proves " + "nothing about the raw-delta path") } // Guard 3: the delta arrives first, base absent, claiming the GOOD uuid. if err := storeReceivedFile(r, claimedUUID, baseUUID, evilDelta); err != nil { t.Fatalf("storeReceivedFile(evil delta, base absent) = %v, want nil: a delta arriving "+ "before its base must be stored, not rejected up front — rejection has to happen "+ "where the claim can actually be checked", err) } // The base is delivered afterward, as ordinary full content. if err := storeReceivedFile(r, baseUUID, "", base); err != nil { t.Fatalf("storeReceivedFile(base) = %v", err) } rid, available := content.AvailableByUUID(r.DB(), claimedUUID) if !available { t.Fatal("expected the chain to report available once the base arrived — " + "if this changed, the test needs updating, not silently passing") } // Guard 4: the assertion is that Expand refuses to serve anything, not // merely that it didn't return "evil" verbatim. got, err := content.Expand(r.DB(), rid) if err == nil { t.Fatalf("content.Expand served %d bytes under uuid=%s, which hashes to a different "+ "UUID than claimed — a hostile peer planted content under a name it does not hash to", len(got), claimedUUID) } }