diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a4246..70316b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **per-chunk integrity + durability** -- `ChunkedFileSink::Config` gains `durable_writes` (default **on**: `fsync`/`_commit` each chunk and journal record to physical media via the new `DurableFile` FILE* wrapper; set **off** to only `fflush` to the OS -- survives a process crash but not power loss) and `enable_recovery_journal` (default **on**). Every chunk carries an **xxHash64 `checksum`** of its on-disk payload, added to `ChunkIndexEntry` / `ChunkIndexData` and to both the FlatBuffers (`vtx_schema.fbs`) and Protobuf (`vtx_schema.proto`, field 6) footer schemas, so a torn or corrupt chunk is detectable on recovery. The reader parses and exposes it; FlatBuffers header/footer parsing gained a `flatbuffers::Verifier` guard so a truncated footer is rejected cleanly instead of reading out of bounds - **recovery journal** -- a single write-ahead sidecar **`.recovery`** (`sdk/include/vtx/writer/policies/sinks/recovery_journal.h`) holds a typed, self-validating record stream (each record `[u8 type][u32 len][payload][u64 xxHash64]`, so a torn last record from a mid-write crash is detected and dropped): an `S` record (written once) carries the writer's timing parameters `{fps, is_increasing}`, `C` records commit a durable chunk's index entry, `T` records carry each committed frame's exact `{game_time, created_utc}`, and `F` records carry each in-flight (un-flushed) frame's times + serialized payload. The log is strictly **append-only** in the crash-critical path -- a frame appends an `F` record; a flush appends the chunk's `C` + `T` records -- so at every instant the file is a valid prefix and there is no window in which a fsync'd chunk is described by neither its `F` records nor its `C` record. Ordering is **data-before-journal**: a chunk is fsync'd into the `.vtx` first, then its `C`/`T` records are appended and fsync'd. A committed chunk's now-redundant `F` records (repair dedups them by frame index) are reclaimed by periodic **compaction** -- the log is rewritten (all `C`/`T` + only the still-pending `F`) into a temp file that **atomically replaces** the sidecar, so a crash during compaction leaves either the old or the new complete journal. The writer journals every frame as it is recorded (`ReplayWriter::TryRecordFrame` -> new `SinkPolicy::JournalFrame` hook; timing via `SinkPolicy::JournalTiming`), so a crash between flushes still recovers the pending batch. If the journal cannot be opened cleanly, journaling is disabled and the torn sidecar is removed (a half-written journal would otherwise block repair). On a clean `Close()` the footer is written and the `.recovery` is deleted -- its presence at open time signals an unclean shutdown - **repair** -- **`VTX::RepairReplayFile(path)`** (`sdk/include/vtx/writer/core/vtx_replay_recovery.h`) reconstructs a valid, readable file from a footerless `.vtx` + its journal: it verifies each committed chunk's checksum (dropping a torn/corrupt tail), re-appends the in-flight frames as chunks, rebuilds the seek table and the **exact per-frame times**, and synthesizes the footer -- including the **derived time data** (`duration_seconds`, timeline **gaps**, game **segments**), reconstructed from the journaled timing (`S` record) with the same expressions `VTXGameTimes` uses, so a recovered footer matches a clean `Stop()` **exactly** (manual segment marks are the one thing not journaled). Recovery is deliberately **not automatic on open** -- the user-driven flow is `ReplayNeedsRecovery(path)` (cheap sidecar check) / `RecoveryJournalPath(path)` (locate the sidecar) then `RepairReplayFile(path)`. A file that already ends with a valid footer plus a leftover journal (a crash between the footer fsync and the journal delete) is detected and preserved untouched; a journal whose recorded format magic disagrees with the file, or whose header is unreadable, is refused rather than applied; a **recording still being written is refused** without touching it (on Windows the writer holds a deny-write handle, so repair's truncate fails cleanly). Returns a `RepairResult` (`was_clean` / `repaired` / `recovered_chunks` / `recovered_frames` / `error`). Both FlatBuffers and Protobuf files are supported -- **tests**: **`tests/writer/test_crash_recovery.cpp`** -- 65 cases covering the full crash-window matrix: recovery between chunks (single- and multi-frame), between frames (in-flight batch, and nothing-flushed-yet), a torn tail chunk, a partial chunk written before its journal record, a checksum-detected corrupt chunk, a torn pending-frame record, a **torn chunk-commit record** (the batch falls back to its still-present `F` records -- the append-only guarantee), a crash mid-footer-write, a leftover-journal-over-valid-footer, a **stale compaction temp**, a mismatched journal format, a clean file (no-op), a header-only crash (0-frame result), the lingering-`F`-record dedup guard, compaction reclaim, the recovery helpers, exact per-frame time preservation, a **live-recording repair refusal** (Windows), and the FlatBuffers + Protobuf repair paths. Crash states are fabricated byte-faithfully through the same `RecoveryJournal` API the sink uses, plus **end-to-end tests through the real writer** (a raw `ReplayWriter` dropped without `Stop()`) for both formats that require the recovered footer to match a cleanly-stopped control **exactly**: per-frame `game_time` + `created_utc`, `duration_seconds`, timeline gaps, game segments, and per-entity `content_hash` -- and require the recovered file to pass whole-replay `ValidateReplayFile` and cache-hostile out-of-order seeks across the committed-chunk/recovered-chunk boundary. Also covered: the sink's **full config matrix** (`durable_writes` x `b_use_compression`) with frames large enough that **zstd compression genuinely engages** (committed chunks and journaled `F` payloads take the compressed path), a **repair interrupted mid-run and re-run** (idempotent), a **second repair on an already-repaired file** (clean no-op, byte-identical), a **journal-opted-out crash** (repair reports clean and leaves the footerless file byte-identical; the reader rejects it gracefully), a **session-start crash** (torn `S` record + header-only `.vtx` -> valid 0-frame file), **torn `T` records** (frame times fall back to the still-present `F` records -- exact, not zeroed), a **0-byte journal** (refused, main file untouched), **compaction through the real sink** under crash (new `Config::journal_compact_threshold_bytes`, 0 = default), **map-container frames** recovered intact from `F` records, a **non-ASCII path** through the full sidecar flow, **hostile checksummed journal records** (a `C` offset inside the header region, a `C` size at or below its own length prefix, an `F` with an empty payload -- each dropped safely, degrading to a valid file), a **torn main-file header** alongside a valid journal (refused, bytes untouched), and a **decreasing-time recording** (`is_increasing = false` -- the other branch of the segment reconstruction) matching its clean control. Scale + pipeline coverage: a **stress run** (2030 multi-entity frames, 40 committed chunks + a 30-frame in-flight batch -- every timestamp exact, whole-replay validation clean), **rejected frames interleaved mid-recording** (timer rejections leave no trace and do not desync the journal's frame indexing), and a **post-processor recording** (journaled `F` records capture the frame as it would hit disk -- post-mutation -- proven by a pending frame that only ever existed in the journal). Lifecycle + hygiene: a **double-crash lifecycle** over the same path (crash -> repair -> re-record -> crash -> repair, second recovery reflects only the second session), a **stale sidecar under journaling opt-out** (a session with `enable_recovery_journal = false` removes any leftover `.recovery` at start so it cannot masquerade as recovery state), a **foreign `.recovery` file** (no `VTXR` magic -- never deleted by repair, even on the clean-file path), a **hostile out-of-range `T` record** (ignored by the bounds check), the **map crash-recovery path on Protobuf** as well as FlatBuffers, a **stale journal from a different recording** over a replaced file (per-chunk checksums reject the foreign chunks -- graceful degradation to a valid empty file), a **read-only crashed file** (repair refuses without consuming the journal; succeeds untouched once the file is writable), a **no-time-registry recording** (`EGameTimeType::None`, fully FPS-synthesized timeline recovered exactly), and **byte-budget chunk splitting** (`max_bytes`, the other `ThresholdChunkPolicy` branch) through crash + recovery. The maximal guarantee is pinned by **`BoundaryCrashRecoversByteIdenticalFile`** (FlatBuffers **and** Protobuf, plus a 120-frame large-footer variant that exercises the compressed-footer path): a crash exactly at a chunk boundary recovers to a file that is **bit-for-bit identical** to a clean `Stop()` with the same inputs -- repair passes the footer's time vectors exactly as `Stop()` does (present-but-empty rather than absent) and compresses the synthesized footer exactly as the sink's `Close()` would (the `S` record now also journals `{use_compression, compression_level}`; journal version 4). A journal with an **incompatible version field** is refused outright, main file untouched. On top of the hand-picked windows, a **brute-force sweep suite** (`CrashRecoverySweep`) proves the "any crash point" claim literally: the journal truncated at **every byte length**, the main `.vtx` truncated at **every byte length**, and **every journal byte flipped** one at a time -- thousands of repair runs, none may crash, and every claimed repair must open and agree with its own reported frame count and footer times. `ReadValid` now bounds each record's payload allocation by the journal's **actual file size** (a corrupt length field can no longer trigger a giant transient allocation), pinned by `HostileRecordLengthIsBoundedByFileSize`; the truncation sweep also runs over the **post-compaction journal layout**. Finally, `CrashRecoveryProcess` (Windows) performs a **genuine process kill**: vtx_tests spawns itself as a child in a hidden endless-writer mode and `TerminateProcess`es it mid-recording -- no destructors, no fclose, handles abandoned to the kernel -- then repairs and verifies frame content and exact footer times, in **both** durability modes (this is the honest validation of the flush-only claim that data handed to the OS survives a process death) -- plus a kill under an **aggressive compaction cadence** (a full close/rewrite/atomic-rename cycle per commit), so real process death lands amid compaction traffic and the atomic-rename guarantee holds, and a **varied-progress kill soak** (kills landing just after the first journaled frames, around the first flush, amid compaction cycles, and several chunks deep -- alternating durability modes). The recovered output was also smoke-checked through the end-user toolchain: `vtx_cli` opens a repaired file and serves `info` / `footer` / `frame` normally. `RecoveredFileTranscodesToCleanChunks` pins the **normalization workflow** for salvaged files (open -> drain frames with footer times -> re-record): the one-frame recovery chunks become proper chunks, `created_utc` round-trips exactly, and `game_time` drifts at most 1 tick (100 ns) per frame through the float-seconds register -- the documented transcode caveat. `BM_ReaderRecoveredTail` (benchmarks) measures the salvage cost that motivates it: a 200-frame one-frame-chunk tail reads sequentially ~3x slower than clean chunking +- **tests**: **`tests/writer/test_crash_recovery.cpp`** -- 65 cases covering the full crash-window matrix: recovery between chunks (single- and multi-frame), between frames (in-flight batch, and nothing-flushed-yet), a torn tail chunk, a partial chunk written before its journal record, a checksum-detected corrupt chunk, a torn pending-frame record, a **torn chunk-commit record** (the batch falls back to its still-present `F` records -- the append-only guarantee), a crash mid-footer-write, a leftover-journal-over-valid-footer, a **stale compaction temp**, a mismatched journal format, a clean file (no-op), a header-only crash (0-frame result), the lingering-`F`-record dedup guard, compaction reclaim, the recovery helpers, exact per-frame time preservation, a **live-recording repair refusal** (Windows), and the FlatBuffers + Protobuf repair paths. Crash states are fabricated byte-faithfully through the same `RecoveryJournal` API the sink uses, plus **end-to-end tests through the real writer** (a raw `ReplayWriter` dropped without `Stop()`) for both formats that require the recovered footer to match a cleanly-stopped control **exactly**: per-frame `game_time` + `created_utc`, `duration_seconds`, timeline gaps, game segments, and per-entity `content_hash` -- and require the recovered file to pass whole-replay `ValidateReplayFile` and cache-hostile out-of-order seeks across the committed-chunk/recovered-chunk boundary. Also covered: the sink's **full config matrix** (`durable_writes` x `b_use_compression`) with frames large enough that **zstd compression genuinely engages** (committed chunks and journaled `F` payloads take the compressed path), a **repair interrupted mid-run and re-run** (idempotent), a **second repair on an already-repaired file** (clean no-op, byte-identical), a **journal-opted-out crash** (repair reports clean and leaves the footerless file byte-identical; the reader rejects it gracefully), a **session-start crash** (torn `S` record + header-only `.vtx` -> valid 0-frame file), **torn `T` records** (frame times fall back to the still-present `F` records -- exact, not zeroed), a **0-byte journal** (refused, main file untouched), **compaction through the real sink** under crash (new `Config::journal_compact_threshold_bytes`, 0 = default), **map-container frames** recovered intact from `F` records, a **non-ASCII path** through the full sidecar flow, **hostile checksummed journal records** (a `C` offset inside the header region, a `C` size at or below its own length prefix, an `F` with an empty payload -- each dropped safely, degrading to a valid file), a **torn main-file header** alongside a valid journal (refused, bytes untouched), and a **decreasing-time recording** (`is_increasing = false` -- the other branch of the segment reconstruction) matching its clean control. Scale + pipeline coverage: a **stress run** (2030 multi-entity frames, 40 committed chunks + a 30-frame in-flight batch -- every timestamp exact, whole-replay validation clean), **rejected frames interleaved mid-recording** (timer rejections leave no trace and do not desync the journal's frame indexing), and a **post-processor recording** (journaled `F` records capture the frame as it would hit disk -- post-mutation -- proven by a pending frame that only ever existed in the journal). Lifecycle + hygiene: a **double-crash lifecycle** over the same path (crash -> repair -> re-record -> crash -> repair, second recovery reflects only the second session), a **stale sidecar under journaling opt-out** (a session with `enable_recovery_journal = false` removes any leftover `.recovery` at start so it cannot masquerade as recovery state), a **foreign `.recovery` file** (no `VTXR` magic -- never deleted by repair, even on the clean-file path), a **hostile out-of-range `T` record** (ignored by the bounds check), the **map crash-recovery path on Protobuf** as well as FlatBuffers, a **stale journal from a different recording** over a replaced file (per-chunk checksums reject the foreign chunks -- graceful degradation to a valid empty file), a **read-only crashed file** (repair refuses without consuming the journal; succeeds untouched once the file is writable), a **no-time-registry recording** (`EGameTimeType::None`, fully FPS-synthesized timeline recovered exactly), and **byte-budget chunk splitting** (`max_bytes`, the other `ThresholdChunkPolicy` branch) through crash + recovery. The maximal guarantee is pinned by **`BoundaryCrashRecoversByteIdenticalFile`** (FlatBuffers **and** Protobuf, plus a 120-frame large-footer variant that exercises the compressed-footer path): a crash exactly at a chunk boundary recovers to a file that is **bit-for-bit identical** to a clean `Stop()` with the same inputs -- repair passes the footer's time vectors exactly as `Stop()` does (present-but-empty rather than absent) and compresses the synthesized footer exactly as the sink's `Close()` would (the `S` record now also journals `{use_compression, compression_level}`; journal version 4). A journal with an **incompatible version field** is refused outright, main file untouched. On top of the hand-picked windows, a **brute-force sweep suite** (`CrashRecoverySweep`) proves the "any crash point" claim literally: the journal truncated at **every byte length**, the main `.vtx` truncated at **every byte length**, and **every journal byte flipped** one at a time -- thousands of repair runs, none may crash, and every claimed repair must open and agree with its own reported frame count and footer times. `ReadValid` now bounds each record's payload allocation by the journal's **actual file size** (a corrupt length field can no longer trigger a giant transient allocation), pinned by `HostileRecordLengthIsBoundedByFileSize`; the truncation sweep also runs over the **post-compaction journal layout**. Finally, `CrashRecoveryProcess` (Windows) performs a **genuine process kill**: vtx_tests spawns itself as a child in a hidden endless-writer mode and `TerminateProcess`es it mid-recording -- no destructors, no fclose, handles abandoned to the kernel -- then repairs and verifies frame content and exact footer times, in **both** durability modes (this is the honest validation of the flush-only claim that data handed to the OS survives a process death) -- plus a kill under an **aggressive compaction cadence** (a full close/rewrite/atomic-rename cycle per commit), so real process death lands amid compaction traffic and the atomic-rename guarantee holds, and a **varied-progress kill soak** (kills landing just after the first journaled frames, around the first flush, amid compaction cycles, and several chunks deep -- alternating durability modes). The recovered output was also smoke-checked through the end-user toolchain: `vtx_cli` opens a repaired file and serves `info` / `footer` / `frame` normally. The whole suite (including the brute-force sweeps and real process kills) also ran clean under **AddressSanitizer** (MSVC `/fsanitize=address`) -- zero memory errors in the SDK; the one report is a known protobuf-internal `DynamicMessage` sized-delete artifact, documented in `tests/sanitizer_suppressions/asan.supp` (`ASAN_OPTIONS=new_delete_type_mismatch=0`). `RecoveredFileTranscodesToCleanChunks` pins the **normalization workflow** for salvaged files (open -> drain frames with footer times -> re-record): the one-frame recovery chunks become proper chunks, `created_utc` round-trips exactly, and `game_time` drifts at most 1 tick (100 ns) per frame through the float-seconds register -- the documented transcode caveat. `BM_ReaderRecoveredTail` (benchmarks) measures the salvage cost that motivates it: a 200-frame one-frame-chunk tail reads sequentially ~3x slower than clean chunking - **benchmarks**: new **`benchmarks/bench_crash_recovery.cpp`** (`BM_WriterDurabilityTier`) quantifies the writer hot-path cost of the recovery defaults across the three durability tiers (journal off / journal + flush-only / journal + fsync). Reference numbers on an NVMe dev machine, 200 small frames per iteration: **~19 us/frame** with no journal, **~30 us/frame** flush-only (process-crash safe -- effectively free), **~289 us/frame** with the default per-operation fsync (power-loss safe; ~1.7% of a 60 fps frame budget on NVMe -- on spinning disks prefer `durable_writes = false`). The byte-identity tests now compare from the end of the header on (the header embeds a second-granularity recording timestamp, so two separately recorded sessions may legitimately differ there; repair never rewrites the header) - **docs**: **`docs/SDK_API.md`** "Writing Replays" gains a **"Crash recovery"** section -- the `.recovery` sidecar model, the user-driven `ReplayNeedsRecovery` / `RepairReplayFile` / `RecoveryJournalPath` flow with `RepairResult` semantics, the exact-times guarantee, the refusal cases, and the sink durability knob table (`durable_writes` / `enable_recovery_journal` / `journal_compact_threshold_bytes`). **`README.md`** feature list gains a "Crash-safe recording" bullet. `DurableFile` hardening: `Seek`/`SeekEnd` failures now latch `Good()` false (a write after an unnoticed seek failure would land at the wrong offset), and the dead `Truncate` primitive left over from the pre-append-only journal design was removed diff --git a/tests/reader/test_reader_api.cpp b/tests/reader/test_reader_api.cpp index 11a201e..32c376a 100644 --- a/tests/reader/test_reader_api.cpp +++ b/tests/reader/test_reader_api.cpp @@ -161,8 +161,14 @@ TEST(ReaderApiFlatBuffers, CacheWindowZeroEvictsPreviousChunks) { auto ctx = VTX::OpenReplayFile(path); ASSERT_TRUE(ctx) << ctx.error; + // OpenReplayFile eagerly warms chunk 0 on a background thread; wait for it so + // the cache assertions below are deterministic. (Asserting a cold cache here + // raced that warm-up -- it only passed when the assert beat the loader thread, + // which slow/instrumented runners lose.) + ASSERT_TRUE(ctx.reader->WaitUntilReady()); + EXPECT_EQ(ctx.reader->GetChunkFrameCountSafe(0), 1); // warmed by open + ctx.reader->SetCacheWindow(0, 0); - EXPECT_EQ(ctx.reader->GetChunkFrameCountSafe(0), 0); ASSERT_NE(ctx.reader->GetFrameSync(0), nullptr); EXPECT_EQ(ctx.reader->GetChunkFrameCountSafe(0), 1); diff --git a/tests/sanitizer_suppressions/asan.supp b/tests/sanitizer_suppressions/asan.supp index 9f5163b..b0a2496 100644 --- a/tests/sanitizer_suppressions/asan.supp +++ b/tests/sanitizer_suppressions/asan.supp @@ -8,3 +8,14 @@ # Example (uncomment when needed): # # Known leak in libprotobuf's global init, deliberate design. # # leak:google::protobuf::internal::OnShutdown +# +# Known third-party finding (2026-07, full suite under MSVC /fsanitize=address): +# new-delete-type-mismatch inside google::protobuf::DynamicMessage's scalar +# deleting dtor (allocated 56B, deleted as 32B). DynamicMessage by design +# over-allocates its block and stores field data inline after the object, so +# sized delete disagrees with the allocation. Purely protobuf-internal +# (surfaces via VtxDiff::ProtobufDifferFacade::DiffRawFrames destroying a +# unique_ptr -- correct usage on our side). This check cannot be +# suppressed from this file; disable it for ASan runs with: +# ASAN_OPTIONS=new_delete_type_mismatch=0 +# With that flag, the full 419-test suite runs clean under ASan. diff --git a/tests/writer/test_crash_recovery.cpp b/tests/writer/test_crash_recovery.cpp index 9000660..1b57934 100644 --- a/tests/writer/test_crash_recovery.cpp +++ b/tests/writer/test_crash_recovery.cpp @@ -1677,6 +1677,14 @@ namespace { // nothing in flight) must recover to a file that is BYTE-IDENTICAL to one produced // by a clean Stop() with the same inputs -- same chunks, same seek table, same // footer (times, gaps, segments, duration, compression), same trailer. + // + // The file header embeds a SECOND-granularity recording timestamp and is + // zstd-compressed, so a control/crash pair recorded across a second boundary gets + // different header bytes -- and possibly a different compressed header SIZE, which + // shifts every absolute offset in the seek table (a legitimate difference; repair + // never rewrites the header). Full-file identity is therefore only meaningful for + // a SAME-SECOND pair: re-record the pair (bounded retries, each takes well under a + // second) until both headers are byte-identical, then demand total equality. template void RunBoundaryByteIdentity(const std::string& prefix, int frames, int32_t chunk_max) { auto record_all = [frames](RawWriterFor& w) { @@ -1690,19 +1698,45 @@ namespace { } w.Flush(); // land the trailing batch -> crash sits exactly on a chunk boundary }; + auto header_region = [](const std::vector& bytes) { + if (bytes.size() < 8) + return std::vector(); + uint32_t header_size = 0; + std::memcpy(&header_size, bytes.data() + 4, sizeof(header_size)); + const size_t end = static_cast(8) + header_size; + if (end > bytes.size()) + return std::vector(); + return std::vector(bytes.begin(), bytes.begin() + static_cast(end)); + }; const std::string control_path = VtxTest::OutputPath(prefix + "_ident_control.vtx"); - { - auto w = MakeRawWriter(control_path, chunk_max); - record_all(*w); - w->Stop(); - } const std::string crash_path = VtxTest::OutputPath(prefix + "_ident_crash.vtx"); - { - auto w = MakeRawWriter(crash_path, chunk_max); - record_all(*w); - // dropped without Stop(): every frame is in a committed chunk, none pending + + // Flush-only durability: fsync vs fflush changes NO file bytes (and the flag is + // not journaled), but it makes each recording take milliseconds instead of + // seconds -- essential so a same-second pair is reachable even on slow CI + // runners, where fsync-per-frame recordings span more than a second each. + bool same_second_pair = false; + for (int attempt = 0; attempt < 8 && !same_second_pair; ++attempt) { + { + auto w = MakeRawWriter(control_path, chunk_max, /*durable=*/false); + record_all(*w); + if (::testing::Test::HasFatalFailure()) + return; + w->Stop(); + } + { + auto w = MakeRawWriter(crash_path, chunk_max, /*durable=*/false); + record_all(*w); + if (::testing::Test::HasFatalFailure()) + return; + // dropped without Stop(): every frame is in a committed chunk, none pending + } + const auto control_header = header_region(VtxTest::ReadAllBytes(control_path)); + const auto crash_header = header_region(VtxTest::ReadAllBytes(crash_path)); + same_second_pair = !control_header.empty() && control_header == crash_header; } + ASSERT_TRUE(same_second_pair) << "could not record a same-second control/crash pair in 8 attempts"; const auto rr = VTX::RepairReplayFile(crash_path); ASSERT_TRUE(rr.ok()) << rr.error; @@ -1711,28 +1745,8 @@ namespace { const auto control_bytes = VtxTest::ReadAllBytes(control_path); const auto recovered_bytes = VtxTest::ReadAllBytes(crash_path); ASSERT_FALSE(control_bytes.empty()); - ASSERT_GE(control_bytes.size(), 8u); - ASSERT_GE(recovered_bytes.size(), 8u); - - // The header embeds a second-granularity recording timestamp, so two separately - // recorded sessions may legitimately differ there (repair never touches the - // header -- the recovered file keeps its own, which IS the guarantee). Compare - // everything from the end of the header on: chunks, seek table, footer, trailer. - auto header_end = [](const std::vector& bytes) { - uint32_t header_size = 0; - std::memcpy(&header_size, bytes.data() + 4, sizeof(header_size)); - return static_cast(8) + header_size; - }; - const size_t control_body = header_end(control_bytes); - ASSERT_EQ(header_end(recovered_bytes), control_body); // same header structure/size - ASSERT_LT(control_body, control_bytes.size()); - EXPECT_EQ(recovered_bytes.size(), control_bytes.size()); - const bool body_identical = - recovered_bytes.size() == control_bytes.size() && - std::equal(control_bytes.begin() + static_cast(control_body), control_bytes.end(), - recovered_bytes.begin() + static_cast(control_body)); - EXPECT_TRUE(body_identical); // chunks + footer + trailer bit-for-bit equal + EXPECT_EQ(recovered_bytes, control_bytes); // bit-for-bit equal to the clean file } } // namespace