fix(gitengine): bound REF_DELTA recursion and harden object-store lifetime - #21
Merged
Merged
Conversation
14 tasks
…etime A packfile containing an OBJ_REF_DELTA cycle crashed the process with `fatal error: stack overflow`: every REF_DELTA hop re-entered the pack through ObjectReader.ReadObject, which restarted readObjectAt at depth 0, so the maxDeltaDepth ceiling only ever counted intra-pack OFS_DELTA hops. grg reads arbitrary .git directories, so this was attacker-reachable, and a fatal error cannot be recovered by the pipeline worker that hits it. PackReader and RepositoryReader now share an unexported depth-carrying entry point, readObjectDepth, reached through the unexported deltaBaseResolver interface. The delta depth survives the resolver hop, so maxDeltaDepth bounds mixed OFS/REF chains and a cycle terminates as ErrCorruptObject. The exported ObjectReader interface is unchanged. Also in the object store: - RepositoryReader.Close no longer closes packfiles under in-flight readers. Reads register on a WaitGroup under RLock and bail on a closed atomic.Bool; Close flips the flag, drops the lock, drains the readers, and only then closes the files, outside the lock. A shutdown race now reports the new ErrReaderClosed instead of being silently misreported as ErrObjectNotFound, and a source that claims an object but fails to produce it propagates its real error rather than being downgraded to "not found". - SetResolver is gone: an exported unsynchronized mutator on a type documented thread-safe, whose field is read by every pipeline worker. The resolver is now immutable after construction, assigned by NewRepositoryReader before the reader is published. - ApplyDeltaWithBuffer takes *[]byte, so a target that outgrows the pooled 64 KiB buffer writes the grown array back and is actually recycled instead of allocating twice on exactly the objects the pool exists for. - A failed pooled-zlib Reset no longer falls back to zlib.NewReader. Reset has already consumed the RFC-1950 header and installed a bufio read-ahead, so the fallback parsed from a mutated offset and reported a misleading error; the corrupt-object error is returned directly. Both call sites now share newPooledZlibReader, which reaches zlib.NewReader only on an empty pool. - Documented the real concurrency contract on RepositoryReader, LooseReader and PackReader. Tests: buildTestPackAndIdx grows a baseSHA field so it can emit REF_DELTA objects, which previously had no coverage at all. refdelta_cycle_test.go promotes the parked reproduction to a regression test asserting ErrCorruptObject, and TestPackReaderRefDelta covers both the resolver and no-resolver paths. TestConcurrency_RepositoryReader_Deadlock raced a zero-value struct and therefore exercised nothing; it now races 20 readers against 5 concurrent Close calls on a real packfile-backed reader and asserts every read either returns the payload or fails with ErrReaderClosed, never ErrObjectNotFound. Removing the drain from Close makes it fail with "file already closed", so the refcount is load-bearing. Two call sites outside the object store move to the new pointer signature, one line each: internal/gitengine/delta_test.go and test/benchmark/search_bench_test.go. Closes #12
hammadmajid
force-pushed
the
fix/12-object-store
branch
from
September 5, 2026 14:14
7340d9d to
4813c9f
Compare
hammadmajid
marked this pull request as ready for review
September 5, 2026 14:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
grgreads arbitrary.gitdirectories, and a packfile containing anOBJ_REF_DELTAcycle killed the process outright. Four smaller object-store defects found by the same audit ride along, because they live in the same files.Closes #12
Type of Change
Key Changes
1. CRITICAL —
OBJ_REF_DELTAcycle causedfatal error: stack overflow.maxDeltaDepth = 50only counted intra-packOFS_DELTAhops. EveryREF_DELTAhop re-entered throughObjectReader.ReadObject→PackReader.ReadObject→readObjectAt(offset, **0**), resetting the counter, so anA→B→Acycle recursed until the stack died. This was a fatal error, not a panic: unrecoverable, skippingdefer reader.Close()and the wholeexitCodeForErrorcontract, triggered from a pipeline worker goroutine.Added an unexported
deltaBaseResolverinterface withreadObjectDepth(oid, depth).PackReader.ReadObjectdelegates toreadObjectDepth(oid, 0); theREF_DELTAhop passesdepth+1through both the resolver and the self-lookup branch.RepositoryReaderimplements it and forwards. The exportedObjectReadersurface is unchanged, and theresolverfield is typed as the unexported interface so depth can never be silently dropped again.2.
Close()closed packfiles under in-flight readers. The snapshot-then-unlock inReadObjectcorrectly avoided the recursive-RLockdeadlock through the resolver back-edge, but it mademua field lock, not a lifetime lock — soClosecould close an*os.Filea reader still held.ErrFileClosedwas then swallowed intoErrObjectNotFound: a silent wrong answer during shutdown, invisible to-racebecause it is a lifetime bug, not a data race. Now refcounted withinflight sync.WaitGroup+closed atomic.Bool;Closemarks closed, unlocks, waits, and closes files outside the lock. New exportedErrReaderClosed.readObjectDepthalso stops collapsing real source errors intoErrObjectNotFound.3.
SetResolverdeleted. An exported unsynchronized write to a field read by every worker, on a type whose doc claimed thread-safety — safe only by accident of call-site placement.NewRepositoryReadernow assigns the field before the reader is published; it is immutable afterwards.4.
ApplyDeltaWithBuffertakes*[]byte. Taking the slice by value meant a >64 KiB target reallocated and the caller recycled the original small array, defeating the pool on exactly the objects it was added for.5. Failed pooled-zlib
Resetno longer retries on a mutated stream.Reseteats the RFC-1950 header and installs a read-aheadbufio.Readerbefore it can fail, so the oldzlib.NewReader(f)fallback parsed from a garbage offset. SharednewPooledZlibReadernow returns the error directly on the pooled path.Verification & Testing
go test -v -count=1 ./...go test -race -shuffle=on -count=1 ./...go vet ./...Reproduction, before the fix —
debug.SetMaxStack(16<<20)to fail fast:The depth argument is
0x0in every frame.New tests.
TestRefDeltaCycle_ReturnsCorruptObjectbuilds anA→B→Acycle and drives it throughNewRepositoryReader(production resolver wiring), asserting the error wrapsErrCorruptObjectand is notErrObjectNotFound.TestPackReaderRefDeltaadds the missing positive coverage for validREF_DELTAon both the resolver and nil-resolver paths — there was none before.TestConcurrency_RepositoryReader_Deadlockpreviously used a zero-value reader with no files, so it exercised nothing; it now races 20 readers × 100 reads against 5 concurrentClosecalls on a real packfile-backed reader. An instrumented probe over 50 iterations recordedok=1854 closed=98146 notfound=0 other=0, confirming both asserted outcomes occur and neither forbidden outcome does.TestPerformance_ApplyDeltaWithBuffer_GrowsCallerBufferfails on the old by-value signature.buildTestPackAndIdxgained a base-SHA field so the suite can buildREF_DELTApacks at all.Note
Bottom of a 7-PR stack. Nothing below it.
Checklist
gofmtclean