Skip to content

Fix transaction safety and reader regressions in Go and C - #2

Open
Soph wants to merge 8 commits into
go-git:mainfrom
Soph:test/review-regressions
Open

Fix transaction safety and reader regressions in Go and C#2
Soph wants to merge 8 commits into
go-git:mainfrom
Soph:test/review-regressions

Conversation

@Soph

@Soph Soph commented Sep 5, 2026

Copy link
Copy Markdown

Summary

Address transaction safety and reader/writer correctness findings with deterministic regression tests in Go and C:

  • Preserve concurrent additions and unrelated compactions when publishing a compacted stack: reread tables.list after reacquiring the lock and replace only the still-contiguous compacted range.
  • Track C lock ownership so failed addition/subtable lock acquisition cannot unlink another writer's lock; make Go atomic-writer cleanup idempotent and keep closed and committed states distinct.
  • Validate block-header sizes, restart-table bounds, and restart offsets before indexing memory; bound Go log decompression by the declared size.
  • Preserve referenced tables when manifest rename succeeds but subsequent directory sync reports an error, for both Go additions and compactions.
  • Distinguish retryable pre-publication lock failures from failures after publication. ErrPostCommit must never match ErrLockFailure; use errors.As to obtain *PostCommitError and inspect its explicit Cause. This also covers maintenance failures after Stack.Add commits. Reload timeouts have a separate sentinel.
  • Preserve borrowed readers after failed reloads, validate the Go merged replacement before swapping, and do not unlink retained tables when reopening without reuse.
  • Restore the update-index base in indexed RefsFor iteration in Go and C, including safe C handling of one-OID refs.
  • Handle empty Go stack bounds, cleanup, and expiration compaction without panics.
  • Validate Go hash IDs, manifest path components, and writer allocation/minimum block-size bounds.
  • Flush every Go writer index level's last block, clear cross-section index state, and stop non-shrinking levels.
  • Use a concurrency-safe RNG for independent Go stacks.
  • Preserve C compaction read/write errors instead of publishing partial output, and remove Go table files whose sync failed before manifest publication.

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

if errors.Is(err, reftable.ErrLockFailure) {
    // No manifest was published by this write; the transaction may be retried.
}
var published *reftable.PostCommitError
if errors.As(err, &published) {
    // The manifest was published. Do not replay the update.
    // Reopen the stack to inspect the current state.
    // Inspect published.Cause explicitly for diagnostics.
}

Cause is 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=1
  • go vet ./...
  • Post-publication fault injection for Add, Addition.Commit, CompactAll, and automatic maintenance; verify a client retry loop does not replay a published update
  • First consolidated commit independently passes Go tests and vet
  • All ten C test binaries compiled and passed with Clang, strict warnings, AddressSanitizer and UndefinedBehaviorSanitizer, using system zlib
  • git diff --check
  • Bazel test execution (Bazel unavailable in the development environment)
  • Windows execution

Interleaving 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-by trailers, 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.md document is deliberately not included in this PR.

@Soph
Soph force-pushed the test/review-regressions branch from 14e383b to 8b788f8 Compare September 5, 2026 21:07
@Soph

Soph commented Sep 5, 2026

Copy link
Copy Markdown
Author

Fixed all three findings from the second review in f25783f:

  • C compaction now propagates ref read/write errors before seeking logs. A late-read fault-injection test verifies the original manifest and all nine refs survive, and that temporary files and locks are cleaned up.
  • Failed table commits that published a file before a directory-sync error explicitly remove that unreferenced file. Tests cover empty and existing stacks, preservation of existing refs, and successful retry.
  • NewWriter rejects blocks smaller than the version-dependent file header + block header + restart count. Tests cover every undersized value for default/SHA-1/SHA-256 and the minimum/default-size boundaries.

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.

@pjbgf
pjbgf marked this pull request as ready for review September 7, 2026 19:19
Copilot AI lite review requested due to automatic review settings September 7, 2026 19:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.

@Soph
Soph force-pushed the test/review-regressions branch from f25783f to bbbd5d6 Compare September 7, 2026 20:31
@Soph

Soph commented Sep 7, 2026

Copy link
Copy Markdown
Author

Addressed the initial feedback on history and retry semantics:

  • Folded the corrective commit and the post-publication error contract into the first safety commit. Removed the dangling 14e383b reference.
  • Added Signed-off-by trailers to every commit, preserving existing co-author attribution. The rewrite was pushed with an explicit force-with-lease; a local backup branch is retained.
  • Narrowed the validation commit title to “Reject unknown hash IDs and unsafe manifest names”; it no longer claims all malformed inputs are panic-free.
  • Introduced ErrPostCommit and PostCommitError.Cause. After publication, neither reload errors nor automatic-maintenance errors can match ErrLockFailure and cause a client to replay a committed update. Reload timeout now has a separate sentinel. Pre-publication contention remains retryable; no-op additions do not falsely report publication.

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.

Soph added 8 commits September 8, 2026 18:49
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>
@Soph
Soph force-pushed the test/review-regressions branch from 554502f to ff5175b Compare September 8, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants