Fix transaction safety and reader regressions in Go and C - #2
Conversation
14e383b to
8b788f8
Compare
|
Fixed all three findings from the second review in f25783f:
Each regression test failed before its fix. Validation: full Go suite with -race, go vet, git diff --check, and all ten C test binaries with strict Clang warnings plus AddressSanitizer/UBSan pass. Bazel and Windows execution remain outstanding. |
There was a problem hiding this comment.
🔵 Needs a closer look
The changes span low-level parsing, durability/publication semantics, and concurrency behavior across both Go and C implementations, which warrants final human validation despite strong regression coverage.
Pull request overview
This PR hardens reftable read/write and stack transaction behavior across the Go and C implementations, addressing multiple concurrency, durability/publication, and malformed-input safety regressions with accompanying deterministic regression tests.
Changes:
- Fix stack publication/reload/cleanup correctness (preserve concurrent manifest changes, avoid unlinking others’ locks, keep committed-vs-closed semantics, and avoid reader loss on reload failures).
- Add input validation and bounds checks in block parsing (header sizing, restart tables/offsets, hash-id handling, and log decompression limits) to prevent panics/over-allocation.
- Add/adjust regression tests in Go and C covering the above failure modes and previously silent data loss cases.
File summaries
| File | Description |
|---|---|
| writer.go | Validates writer config pre-allocation; fixes index-level flushing to prevent silent record loss/infinite loops. |
| writer_index_test.go | Regression test ensuring finishSection index covers all records across sections. |
| validation_test.go | New end-to-end validation tests for traversal, hash IDs, block sizing, and hostile log block sizes. |
| storage.go | Makes AtomicWriter semantics explicit; implements idempotent Close and committed-state tracking. |
| storage_test.go | Tests writer lifecycle invariants (idempotent Close, rename failures, committed-file preservation). |
| stack.go | Adds manifest table-name validation; fixes reload reader ownership; preserves concurrent manifest edits during compaction; improves durability-error handling. |
| stack_concurrent_test.go | Race-focused tests for concurrent stacks and uniqueness of generated table names. |
| review_regression_test.go | Deterministic regression suite for compaction/addition interleavings, reload failures, empty-stack behavior, index update-index correctness, and malformed blocks. |
| reftable_test.go | Adjusts expectations for index-level count after index flushing fix. |
| reader.go | Validates hash ID and footer layout; adds block header bounds checks; restores update-index base in indexed iteration. |
| merged.go | Handles empty merged stacks safely for min/max update-index bounds. |
| constants.go | Introduces maxDeflateRatio constant for validating declared log decompression sizes. |
| api.go | Changes HashID.Size to return 0 instead of panicking for unknown IDs; updates doc comment accordingly. |
| block.go | Adds block layout validation, restart-table bounds checks, and log decompression size limiting. |
| block_validation_test.go | Go tests for rejecting malformed blocks and oversized log streams. |
| c/strbuf.h | Adds missing include for sys/types.h for direct compilation. |
| c/stack.c | Fixes reload borrow/free semantics; tracks lock ownership; preserves concurrent manifest changes during compaction; avoids publishing partial compactions on read error. |
| c/stack_test.c | Adds deterministic C regression tests for concurrent addition during compaction, read-error preservation, reload failure reader preservation, and lock ownership behavior. |
| c/readwrite_test.c | Extends RefsFor tests to validate update-index base handling for peeled and non-peeled refs. |
| c/reader.c | Adds header bounds checking and ensures block cleanup on init failures. |
| c/iter.c | Restores update-index base for indexed RefsFor iteration; handles VAL1 vs VAL2 safely. |
| c/block.c | Adds strict block header/restart-table bounds checking and restart-offset validation. |
| c/block_test.c | Adds C test coverage for rejecting invalid block layouts. |
Review details
- Files reviewed: 23/23 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
f25783f to
bbbd5d6
Compare
|
Addressed the initial feedback on history and retry semantics:
New tests exercise Add, Addition.Commit, CompactAll, auto-compaction, and a typical errors.Is-based retry loop. The full Go race-enabled suite and vet pass, and the first consolidated commit independently passes Go tests and vet. The rewritten final source tree is identical to the tested pre-rebase tree. For the two surviving panics in feedback item 1, please share the specific reproducers or locations so they can be addressed without guessing. The PR description explicitly documents the remaining parser-hardening scope. |
Preserve concurrent manifest changes during compaction and track ownership of locks, temporary files, and borrowed readers across failure paths. Validate block sizes and restart layouts, bound Go log decompression, restore indexed ref update indexes, and handle empty Go stacks safely. Distinguish manifest publication from durability confirmation. A published update returns ErrPostCommit on subsequent failure, with its diagnostic cause available explicitly through PostCommitError.Cause. It must never match ErrLockFailure and invite replay of a committed transaction. This also applies to automatic maintenance after a successful addition. Add deterministic fault-injection tests for Go and C, including lock contention, concurrent compaction/addition, failed reloads, malformed blocks, and post-publication errors. Keep aborted-writer names stable and cleanup idempotent. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Stefan Haubold <stefan@haubi.com>
A reftable can arrive in a copied repository, a hostile archive, or on a shared filesystem, so every length and name read out of one is attacker-controlled. Three paths trusted them. HashID.Size panicked on an unrecognised hash id. For a v2 table that id is four raw bytes from the header, and reader.go consumes it before the footer CRC is compared, so corrupting four bytes crashed the process with no valid checksum anywhere in the file. Size now returns 0 for an unknown id, and NewReader and NewWriter both reject it. Log blocks declare their decompressed size, which is deliberately not bounded by the block length. make([]byte, 0, sz) therefore reserved up to 16MiB before a byte was decompressed, so a ~40 byte table could amplify by a factor of 400,000. DEFLATE cannot expand by more than 1032:1, so reject any declared size the compressed bytes present could not possibly produce. Entries in tables.list were joined onto the reftable directory unvalidated. filepath.Join cleans "..", so a crafted manifest could open files anywhere the process could reach, and Stack.Close and reloadOnce would unlink them. Table names are always plain filenames, so refuse separators and dot components. NewWriter also validated BlockSize after allocating it, so an oversized config reserved the memory before returning the error. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Stefan Haubold <stefan@haubi.com>
finishSection built index levels but never flushed the last, partial block of each level. The pending block was either discarded outright by the next iteration, losing every entry in it, or flushed after w.index had already been cleared, leaving a stale record that was then written as the first entry of the *next* section's index. Both outcomes produce structurally invalid tables at default settings: 500 refs with default config is enough. The result is an object index whose first key is a ref name, or a log index whose first key is an object id. Reading such a table either walks into a block of the wrong type or, more quietly, cannot find records that are present: at BlockSize 256, 23 to 35 of 500 reflogs were unreachable through the index with no error reported, since ReadLogAt returns (nil, nil) for a missing entry. Flush each level's final block, and clear w.index after that flush rather than before. Flushing every level means a level whose keys are large enough to hold one entry per block never shrinks, which would loop forever, so stop when a level fails to reduce. A multi-block top level is legitimate: seekLinear walks across blocks, which is what the threshold expresses. TestTableSeekLogLevel1 moves from 25 to 20 records. Its old expectation encoded the dropped block; 25 records genuinely needs two index levels once every level's final block is written, and 7..20 is the single-level band for that shape. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Stefan Haubold <stefan@haubi.com>
formatName drew from a package-level *rand.Rand, which is not safe for concurrent use. One process commonly holds a Stack per repository and writes to them from different goroutines: the Stacks are independent, but the RNG behind their table names was not. This is not only a reported race. The new TestConcurrentTableNamesAreUnique fails reproducibly without -race, because torn reads of the shared source hand two goroutines the same suffix, and a duplicate table name loses a table when the manifest is rewritten. rand/v2's top-level functions are per-P and lock-free, so use those and drop the shared source. The Intn call in reload's backoff becomes IntN. TestConcurrentStacksInSeparateRepos covers the surrounding contract: separate Stacks writing concurrently, each reading back its own refs. Stack itself remains single-goroutine. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Stefan Haubold <stefan@haubi.com>
Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Stefan Haubold <stefan@haubi.com>
Every offset a seek follows out of an index record is file-supplied and can point anywhere. seekIndexed trusted all of them. tabIterAt documents that it returns (nil, nil) when an offset is past EOF or does not address a usable block, and three call sites dereferenced the result unchecked: the descent in seekIndexed, its entry through start, and the no-index path in seek. A corrupted offset in the log index region of a 500-ref/500-log table at BlockSize 256 is enough to crash the process. When the descent landed on a block that was neither the wanted type nor an index block, log.Panicf reported "got type %c following indexes". That is malformed input, so return REFTABLE_FORMAT_ERROR's equivalent, as c/reader.c does. Two more defects in the same loop: the error from idxIter.Next was discarded because !ok was tested first, so a corrupt index block read as "no such ref" rather than an error; and nothing bounded the descent, so an index record pointing back at its own block looped forever. Bound it at maxIndexDepth, which no valid tree approaches because each level holds strictly fewer blocks than the one below. Also guard the neighbouring reachable panics: RefsFor wrapped a possibly nil iterator, refsForIndexed sliced the caller's oid to a footer-supplied objectIDLen that can exceed it, and seekLinear panicked on a block that yielded no records. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Stefan Haubold <stefan@haubi.com>
getVarInt yields a uint64, and int(v) is negative for v >= 2^63, so every `int(v) > len(x)` guard silently passed for a crafted 9-byte varint. Compare as uint64 instead. decodeKey's prefix length is the one a corruption sweep reaches in practice: make([]byte, suffixLen+prefixLen) then takes a length derived from a negative bound. The symref target size and the reflog name/email/message lengths have the same shape. objRecord's offset count was used as a slice capacity straight from the file. Each offset costs at least one byte of varint, so it cannot exceed what remains in the block; without that bound a 10-byte varint reserves gigabytes. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Stefan Haubold <stefan@haubi.com>
The reader had no test that fed it malformed input, which is why the index-offset and varint-length defects fixed in the previous two commits survived. An earlier hand-rolled sweep missed them because it used a 30-ref table, too small to build the multi-level index whose offsets are the interesting attack surface. TestReaderSurvivesCorruption walks byte mutations and truncations across three table shapes, including unaligned and SHA-256, driving every seek path that follows an offset, length, or block type read out of the file. It runs in about 1.5s so it belongs in CI. FuzzReader explores the same surface without a fixed stride. Both share driveReader, so a path added to one is covered by the other. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Stefan Haubold <stefan@haubi.com>
554502f to
ff5175b
Compare
Summary
Address transaction safety and reader/writer correctness findings with deterministic regression tests in Go and C:
tables.listafter reacquiring the lock and replace only the still-contiguous compacted range.ErrPostCommitmust never matchErrLockFailure; useerrors.Asto obtain*PostCommitErrorand inspect its explicitCause. This also covers maintenance failures afterStack.Addcommits. Reload timeouts have a separate sentinel.RefsForiteration in Go and C, including safe C handling of one-OID refs.Also fixes the Go compaction nil-lock defer/retry path, routes compaction cleanup through
Storage, and adds missing C header includes needed for direct compilation.Publication error contract
Causeis intentionally not in the unwrap chain: a lock error during post-commit reload or maintenance must not accidentally trigger a write retry. Publication does not imply durability was confirmed.Tests
The original regression tests failed before their fixes. Coverage includes concurrent neighboring compaction, lock contention, errors before/after publication, failed reloads, C late-read failures, malformed restart layouts, indexed lookups, and atomic-writer lifecycle states.
go test ./...go test -race ./... -count=1go vet ./...Add,Addition.Commit,CompactAll, and automatic maintenance; verify a client retry loop does not replay a published updategit diff --checkInterleaving uses storage/block-source hooks, not timing-sensitive goroutines or sleeps. Directory-sync failure is modeled by a wrapper returning an error after a real successful publication; no real filesystem sync failure is induced. Reload timeout/error classification is injected after publication without waiting for the retry deadline.
Review history
The transaction-safety corrections and post-publication error contract are folded into the first commit. All commits have
Signed-off-bytrailers, and the validation commit title is narrowed to the inputs it validates rather than claiming complete panic prevention.Remaining scope
This is not complete parser hardening or a resolution of the full repository review. Integer overflows in record decoders, cyclic indexes, remaining nil-iterator paths, reflog tombstones, C writer index construction, the C durability protocol, and shared-instance Go concurrency need separate work. The broader local
findings.mddocument is deliberately not included in this PR.