Artifact 460fbf6983e9f57a3bd0552e4771e39cdbfbbd92eec36258d9fca4beae42bb79:
- File
internal/blob/blob.go
— part of check-in
[89535a6c42]
at
2026-07-19 18:28:45
on branch trunk
— fix: store delta content against a phantomized base instead of discarding it (#83)
* fix: store delta content against a phantomized base instead of discarding it
A delta artifact arriving before its base is normal steady-state behavior during a Fossil transfer, not a fault. The receive path used to discard such deltas outright and phantomize the target instead, so a phantom only ever cleared when its base happened to already be present -- resolution advanced one delta-chain hop per round, making clones of deep-history repos provably unable to converge within MaxRounds.
Match canonical Fossil's content_put_ex/rid_from_uuid model: resolve the delta's source via a create-phantom-if-missing lookup, persist the delta bytes unconditionally (reading the target size straight out of the delta header, so the base never needs to be readable to store it), and record the delta-to-source link immediately. Availability resolves lazily via the existing content.IsAvailable chain walk and manifest.Crosslink's deferred retry -- both already correct and untouched here.
storeReceivedFile is the single call site shared by clone, sync-pull, and server-push, so fixing it here also closes out the delta-before-base sentinel handling on the pull and push paths (issue #57, folded into this fix): neither path needs to special-case the condition anymore because it stops being an error.
Verified against the pikchr corpus via a loopback fossil server: rounds dropped from 100 (exhausted) to 3, files received from 63,155 (22.2x) to 2,980 (1.05x), and the phantom table empties out completely.
* fix: converge delta storage on canonical, close integrity gap, harden wire parsing
Three defects from review, all in the raw-delta path added for the delta-before-base fix, all reachable from an untrusted peer:
1. delta.getInt accumulated v = v*64 + digit with no overflow check. A twelve-byte header of maximum-value digits wraps to exactly math.MaxUint64, which casts to int64(-1) -- the same sentinel blob.size uses for "phantom". That silently corrupted the row: real content reporting as a phantom, with no phantom-table entry to make it re-requestable. getInt now rejects overflow instead of wrapping, and parseTargetLen (shared by Apply and OutputSize) bounds the parsed target length to a sane maximum before it ever reaches an allocation or a size column.
2. StoreDeltaRaw panicked on a zero-length delta payload. storeReceivedFile guarded payload == nil but not len(payload) == 0, and nothing between wire decode and storage checked for it -- a peer sending an empty delta file card crashed the process. Wire data gets an error return, not an assertion; OutputSize and StoreDeltaRaw are now consistent with delta.Apply's existing "empty delta is an error" contract.
3. The receive-time split (store raw only when the base happens to be unavailable, otherwise eagerly expand and verify) left a gap an attacker chooses the timing of: withhold the base, and a delta claiming any UUID goes down the unverified raw path. Nothing downstream checked it either -- content.Expand applied deltas with no hash check, and content.Verify was reachable only from repo rebuild and one integration test.
Closed by converging fully on canonical's model: every delta is now stored raw and linked via a create-phantom-if-missing lookup, regardless of whether its base is currently available -- receive time no longer branches on availability at all. Verification moves to the one choke point every expansion path already goes through: content.Expand now hashes its result against the row's claimed UUID before returning it, catching a mismatch regardless of which caller or how the chain assembled. content.Verify becomes a thin wrapper over the same check.
Storage-layer and end-to-end regression tests added for all three, including the exact claim-one-UUID/ship-a-delta-for-another/withhold-the- base reproduction, guarded against degenerating into a no-op (delta must actually compress, base must be genuinely absent, rejection must be a real error rather than merely different bytes).
Re-verified against pikchr (2,842 artifacts) and wapp (1,039): pikchr converges in 3 rounds, 2,980 files, 0 phantoms; wapp converges in 3 rounds, 1,115 files, 1 phantom outstanding (pre-existing, unrelated -- clone.go has no DB-phantom rescan the way the pull path's loadDBPhantoms does; separately tracked). On-disk repository size is close to but slightly smaller than storing everything as raw delta bytes should produce (pikchr 6.81MB -> 6.73MB, wapp 5.64MB -> 5.46MB): zlib on full text already captures most of the redundancy an explicit delta would, so the win here is mainly integrity and convergence, not size.
* fix: bound delta allocation to actual work, validate delta source hex
Two defects from round-two review, plus a stray comment.
1. delta.Apply preallocated its output buffer directly from the delta header's claimed target length (make([]byte, 0, targetLen)). That claim only had to clear maxDeltaTargetSize (4 GiB) to be trusted as an allocation size, so a handful of wire bytes declaring a near-4 GiB target triggered an allocation sized to the claim, not to any work actually done. Harmless as a one-off parse spike; durable once a delta can be stored unverified (see blob.StoreDeltaRaw, this branch) -- a hostile peer plants one such delta and every later Expand of that row repeats the attempt. The initial capacity is now a small fixed bound; append grows it normally for legitimately large content instead of trusting the header's own claim about itself.
2. storeReceivedFile validated the target UUID's hex-ness (hash.IsValidHash) but not the delta source's. The blob table's CHECK constraint only bounds uuid length, not hex-ness, so a non-hex string of valid length passed straight through to blob.StorePhantom and became a permanent, unfillable row -- one the pull path's loadDBPhantoms would then re-request every sync round, forever. deltaSrc now gets the same check as the target uuid, at the same place.
Also deleted a stray doc-comment line left over from an earlier refactor.
Regression tests for both, RED-verified against reverted code before being fixed: the allocation test had to be corrected mid-way through -- delta headers encode their integer in Fossil's own base-64 alphabet, not decimal, so an initial fmt.Sprintf("%d", ...) fixture parsed as a different (still large but differently-bounded) value and passed vacuously; appendInt is the real encoder and is used directly now, with a round-trip sanity check against OutputSize guarding the fixture itself. (user: [email protected], size: 11783) [annotate] [blame] [check-ins using]
package blob import ( "fmt" libfossil "github.com/danmestas/libfossil/internal/fsltype" "github.com/danmestas/libfossil/db" "github.com/danmestas/libfossil/internal/delta" "github.com/danmestas/libfossil/internal/hash" ) func Store(q db.Querier, content []byte) (rid libfossil.FslID, uuid string, err error) { if q == nil { panic("blob.Store: q must not be nil") } if len(content) == 0 { panic("blob.Store: content length must be > 0") } defer func() { if err == nil && rid <= 0 { panic("blob.Store: postcondition violated: rid <= 0 with no error") } }() uuid = hash.SHA1(content) if rid, ok := Exists(q, uuid); ok { return rid, uuid, nil } compressed, err := Compress(content) if err != nil { return 0, "", fmt.Errorf("blob.Store compress: %w", err) } result, err := q.Exec( "INSERT INTO blob(uuid, size, content, rcvid) VALUES(?, ?, ?, 1)", uuid, len(content), compressed, ) if err != nil { return 0, "", fmt.Errorf("blob.Store insert: %w", err) } ridInt, err := result.LastInsertId() if err != nil { return 0, "", fmt.Errorf("blob.Store lastid: %w", err) } rid = libfossil.FslID(ridInt) // Verify round-trip: re-read, decompress, re-hash. // Matches Fossil's content_put_pk() post-write verification. readBack, err := Load(q, rid) if err != nil { return 0, "", fmt.Errorf("blob.Store verify read-back: %w", err) } if got := hash.SHA1(readBack); got != uuid { return 0, "", fmt.Errorf("blob.Store verify: hash mismatch after round-trip: stored %s, got %s", uuid, got) } // Mark as unclustered — matches Fossil's content_put_ex (content.c:633). // Only new blobs reach here; Exists early-return skips this. if _, err := q.Exec("INSERT OR IGNORE INTO unclustered(rid) VALUES(?)", rid); err != nil { return 0, "", fmt.Errorf("blob.Store unclustered: %w", err) } return rid, uuid, nil } func StoreDelta(q db.Querier, content []byte, srcRid libfossil.FslID) (rid libfossil.FslID, uuid string, err error) { if q == nil { panic("blob.StoreDelta: q must not be nil") } if len(content) == 0 { panic("blob.StoreDelta: content length must be > 0") } if srcRid <= 0 { panic("blob.StoreDelta: srcRid must be > 0") } defer func() { if err == nil && rid <= 0 { panic("blob.StoreDelta: postcondition violated: rid <= 0 with no error") } }() uuid = hash.SHA1(content) if rid, ok := Exists(q, uuid); ok { return rid, uuid, nil } srcContent, err := Load(q, srcRid) if err != nil { return 0, "", fmt.Errorf("blob.StoreDelta load source: %w", err) } deltaBytes := delta.Create(srcContent, content) compressed, err := Compress(deltaBytes) if err != nil { return 0, "", fmt.Errorf("blob.StoreDelta compress: %w", err) } result, err := q.Exec( "INSERT INTO blob(uuid, size, content, rcvid) VALUES(?, ?, ?, 1)", uuid, len(content), compressed, ) if err != nil { return 0, "", fmt.Errorf("blob.StoreDelta insert blob: %w", err) } ridInt, err := result.LastInsertId() if err != nil { return 0, "", fmt.Errorf("blob.StoreDelta lastid: %w", err) } rid = libfossil.FslID(ridInt) _, err = q.Exec("INSERT INTO delta(rid, srcid) VALUES(?, ?)", rid, srcRid) if err != nil { return 0, "", fmt.Errorf("blob.StoreDelta insert delta: %w", err) } // Verify round-trip: re-read delta, apply to source, re-hash. // Matches Fossil's content_put_pk() post-write verification. storedDelta, err := Load(q, rid) if err != nil { return 0, "", fmt.Errorf("blob.StoreDelta verify read-back: %w", err) } rebuilt, err := delta.Apply(srcContent, storedDelta) if err != nil { return 0, "", fmt.Errorf("blob.StoreDelta verify apply: %w", err) } if got := hash.SHA1(rebuilt); got != uuid { return 0, "", fmt.Errorf("blob.StoreDelta verify: hash mismatch after round-trip: stored %s, got %s", uuid, got) } // Mark as unclustered — matches Fossil's content_put_ex (content.c:633). if _, err := q.Exec("INSERT OR IGNORE INTO unclustered(rid) VALUES(?)", rid); err != nil { return 0, "", fmt.Errorf("blob.StoreDelta unclustered: %w", err) } return rid, uuid, nil } // StoreDeltaRaw stores delta-encoded content exactly as given — without // expanding it against its source — and records the delta-to-source link. // Unlike StoreDelta, which computes a delta from full target content and // verifies the round-trip against an already-readable source, StoreDeltaRaw // accepts content that arrived already delta-encoded (e.g. over the wire // during a transfer) whose source may itself still be a phantom. The // target's size is read from the delta's own header (delta.OutputSize), so // no source content is needed to store it. // // If a real (non-phantom) blob already exists for uuid, this is a no-op. // If a phantom row exists, it is filled in place. Otherwise a new row is // created. In every case the row's declared size is real, never -1: the // target is not phantomized just because its source might be — mirrors // Fossil's content_put_ex (src/content.c:557-620), which stores delta // content unconditionally and records REPLACE INTO delta(rid,srcid) // whether or not the source is currently available. Availability is a // live, transitively-recomputed property (content.IsAvailable), not // something this function or any caller needs to update or be notified // about; content.Expand verifies the expanded result against its claimed // UUID on every read, since nothing here can verify a delta whose source // isn't readable yet. // // deltaBytes is wire data, not a programmer-controlled argument: malformed // or empty input (including from a hostile peer) is reported as an error, // never a panic. Argument shapes StoreDeltaRaw's own caller controls (q, // uuid, srcRid) are still asserted, matching this package's usual // nil/invalid-argument convention. func StoreDeltaRaw(q db.Querier, uuid string, deltaBytes []byte, srcRid libfossil.FslID) (rid libfossil.FslID, err error) { if q == nil { panic("blob.StoreDeltaRaw: q must not be nil") } if uuid == "" { panic("blob.StoreDeltaRaw: uuid must not be empty") } if srcRid <= 0 { panic("blob.StoreDeltaRaw: srcRid must be > 0") } defer func() { if err == nil && rid <= 0 { panic("blob.StoreDeltaRaw: postcondition violated: rid <= 0 with no error") } }() existingRid, exists := Exists(q, uuid) if exists { var size int64 if err := q.QueryRow("SELECT size FROM blob WHERE rid=?", existingRid).Scan(&size); err != nil { return 0, fmt.Errorf("blob.StoreDeltaRaw: check existing: %w", err) } if size != -1 { return existingRid, nil // real blob already exists; nothing to do } } // delta.OutputSize rejects empty, malformed, and overflowing/oversized // headers with an error — the boundary for wire-data validation lives // there so this function doesn't duplicate it. targetSize, err := delta.OutputSize(deltaBytes) if err != nil { return 0, fmt.Errorf("blob.StoreDeltaRaw: output size: %w", err) } compressed, err := Compress(deltaBytes) if err != nil { return 0, fmt.Errorf("blob.StoreDeltaRaw: compress: %w", err) } if exists { // Filling a phantom. if _, err := q.Exec("UPDATE blob SET rcvid=1, size=?, content=? WHERE rid=?", int64(targetSize), compressed, existingRid); err != nil { return 0, fmt.Errorf("blob.StoreDeltaRaw: fill phantom: %w", err) } if _, err := q.Exec("DELETE FROM phantom WHERE rid=?", existingRid); err != nil { return 0, fmt.Errorf("blob.StoreDeltaRaw: clear phantom: %w", err) } rid = existingRid } else { result, err := q.Exec( "INSERT INTO blob(uuid, size, content, rcvid) VALUES(?, ?, ?, 1)", uuid, int64(targetSize), compressed, ) if err != nil { return 0, fmt.Errorf("blob.StoreDeltaRaw: insert: %w", err) } ridInt, err := result.LastInsertId() if err != nil { return 0, fmt.Errorf("blob.StoreDeltaRaw: lastid: %w", err) } rid = libfossil.FslID(ridInt) } // Record the delta-to-source link unconditionally — matches // content_put_ex's REPLACE INTO delta(rid,srcid), which fires // regardless of whether srcRid is itself currently available. if _, err := q.Exec("REPLACE INTO delta(rid, srcid) VALUES(?, ?)", rid, srcRid); err != nil { return 0, fmt.Errorf("blob.StoreDeltaRaw: delta link: %w", err) } if _, err := q.Exec("INSERT OR IGNORE INTO unclustered(rid) VALUES(?)", rid); err != nil { return 0, fmt.Errorf("blob.StoreDeltaRaw: unclustered: %w", err) } return rid, nil } func StorePhantom(q db.Querier, uuid string) (rid libfossil.FslID, err error) { if q == nil { panic("blob.StorePhantom: q must not be nil") } if uuid == "" { panic("blob.StorePhantom: uuid must not be empty") } defer func() { if err == nil && rid <= 0 { panic("blob.StorePhantom: postcondition violated: rid <= 0 with no error") } }() if rid, ok := Exists(q, uuid); ok { return rid, nil } result, err := q.Exec( "INSERT INTO blob(uuid, size, content, rcvid) VALUES(?, -1, NULL, 0)", uuid, ) if err != nil { return 0, fmt.Errorf("blob.StorePhantom: %w", err) } ridInt, err := result.LastInsertId() if err != nil { return 0, fmt.Errorf("blob.StorePhantom lastid: %w", err) } rid = libfossil.FslID(ridInt) _, err = q.Exec("INSERT INTO phantom(rid) VALUES(?)", rid) if err != nil { return 0, fmt.Errorf("blob.StorePhantom phantom table: %w", err) } return rid, nil } func Load(q db.Querier, rid libfossil.FslID) (result []byte, err error) { if q == nil { panic("blob.Load: q must not be nil") } if rid <= 0 { panic("blob.Load: rid must be > 0") } defer func() { if err == nil && result == nil { panic("blob.Load: postcondition violated: result is nil with no error") } }() var content []byte var size int64 err = q.QueryRow("SELECT content, size FROM blob WHERE rid=?", rid).Scan(&content, &size) if err != nil { return nil, fmt.Errorf("blob.Load query: %w", err) } if size == -1 { return nil, fmt.Errorf("blob.Load: rid %d is a phantom", rid) } if content == nil || len(content) == 0 { return nil, fmt.Errorf("blob.Load: rid %d has NULL or empty content", rid) } // Fossil stores blobs as [4-byte BE uncompressed-size][zlib data]. // When the compressed form happens to be the same length as the // uncompressed content (rare but real — ~2 in 66K in the Fossil SCM // repo), we must still decompress. Detect compressed content by // checking for the 4-byte BE prefix matching the declared size // followed by a zlib header (0x78). if len(content) >= 6 { prefixSize := int64(content[0])<<24 | int64(content[1])<<16 | int64(content[2])<<8 | int64(content[3]) if prefixSize == size && content[4] == 0x78 { return Decompress(content) } } // No compression prefix — content is stored uncompressed. if int64(len(content)) == size { return content, nil } // Stored bytes < declared size — compressed. return Decompress(content) } // Exists reports whether a blob row with the given uuid is present, and // returns its rid. It answers existence only. // // It returns true for a phantom — a real blob row with size = -1 and NULL // content, standing in for an artifact we know of but have not received. // A phantom's content cannot be read, and neither can that of a delta whose // chain bottoms out in one. Callers that are about to read content must use // content.AvailableByUUID instead, which is transitive over the delta chain. // // Mirrors Fossil's rid_from_uuid (src/xfer.c:70). func Exists(q db.Querier, uuid string) (libfossil.FslID, bool) { if q == nil { panic("blob.Exists: q must not be nil") } if uuid == "" { panic("blob.Exists: uuid must not be empty") } var rid int64 err := q.QueryRow("SELECT rid FROM blob WHERE uuid=?", uuid).Scan(&rid) if err != nil { return 0, false } return libfossil.FslID(rid), true }