From b17af2ead4cce490adb188a5e1c283fbc7a9a32e Mon Sep 17 00:00:00 2001 From: Paulo Cabral Sanz Date: Fri, 14 Aug 2026 17:37:50 -0300 Subject: [PATCH 1/3] test: sealed mid-file CRC must fail-stop A fully-framed record with a bad CRC in a sealed log is bitrot, not a torn tail. scan_sealed currently copies rebuild_from_records and returns the prefix; the test requires CrcMismatch. --- crates/engine/src/log/record.rs | 64 +++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/engine/src/log/record.rs b/crates/engine/src/log/record.rs index 17f34a4..ad0a1e2 100644 --- a/crates/engine/src/log/record.rs +++ b/crates/engine/src/log/record.rs @@ -181,6 +181,42 @@ pub fn encode( Ok(buf) } +/// Walk a sealed log image. +/// +/// A short last record at EOF is a torn tail and is dropped (`Ok` with the +/// prefix). A CRC mismatch on a *fully framed* mid-file record is bitrot: +/// the rest of the sealed file is still there and must not be silently +/// discarded. Callers treat that as `CrcMismatch` (fail-stop) rather than +/// "this is the torn active tail". +/// +/// [`crate::log::recover`] uses the same rule when a sealed footer is +/// missing and the file is rebuilt from records. +pub fn scan_sealed(bytes: &[u8]) -> Result> { + let mut offset = 0usize; + let mut good = Vec::new(); + while offset < bytes.len() { + if bytes.len() - offset < HEADER_LEN { + break; + } + let header_buf = &bytes[offset..offset + HEADER_LEN]; + let hdr = match parse_header(header_buf, offset as u64) { + Ok(h) => h, + Err(_) => break, + }; + let rec_len = hdr.record_len(); + if offset + rec_len > bytes.len() { + break; + } + let body = &bytes[offset + HEADER_LEN..offset + rec_len]; + if verify_crc(&hdr, header_buf, body, offset as u64).is_err() { + break; + } + good.push(offset as u64); + offset += rec_len; + } + Ok(good) +} + #[cfg(test)] mod tests { use super::*; @@ -232,4 +268,32 @@ mod tests { assert_eq!(hdr.expires_at_ms, 5000); assert_eq!(hdr.val_size, 0); } + + #[test] + fn sealed_mid_file_crc_is_corruption_not_a_silent_tail() { + let mut buf = Vec::new(); + encode_into(&mut buf, 1, flags::NO_EXPIRY, 0, b"keep", b"one", b"").unwrap(); + let second_at = buf.len(); + encode_into(&mut buf, 2, flags::NO_EXPIRY, 0, b"drop-me", b"two", b"").unwrap(); + encode_into(&mut buf, 3, flags::NO_EXPIRY, 0, b"also-keep", b"three", b"").unwrap(); + + // Flip one value byte in the middle record. Framing stays intact. + let val = second_at + HEADER_LEN + b"drop-me".len(); + buf[val] ^= 0x01; + + let err = scan_sealed(&buf).expect_err("mid-file CRC in a sealed image is corruption"); + assert!( + matches!(err, EngineError::CrcMismatch { offset } if offset == second_at as u64), + "got {err:?}" + ); + } + + #[test] + fn sealed_torn_tail_is_ok() { + let mut buf = Vec::new(); + encode_into(&mut buf, 1, flags::NO_EXPIRY, 0, b"keep", b"one", b"").unwrap(); + buf.extend_from_slice(&[0u8; 10]); // short junk at EOF + let offs = scan_sealed(&buf).expect("torn tail is not corruption"); + assert_eq!(offs, vec![0]); + } } From 4798c69166135630c0303620855807d2fed960df Mon Sep 17 00:00:00 2001 From: Paulo Cabral Sanz Date: Fri, 14 Aug 2026 17:37:50 -0300 Subject: [PATCH 2/3] fix: sealed rebuild fail-stops on mid-file CRC rebuild_from_records used to break and return Ok, dropping every key after the flipped byte. Active-file replay still truncates (that is a torn write). --- crates/engine/src/log/record.rs | 4 +--- crates/engine/src/log/recover.rs | 8 +++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/log/record.rs b/crates/engine/src/log/record.rs index ad0a1e2..c2b5744 100644 --- a/crates/engine/src/log/record.rs +++ b/crates/engine/src/log/record.rs @@ -208,9 +208,7 @@ pub fn scan_sealed(bytes: &[u8]) -> Result> { break; } let body = &bytes[offset + HEADER_LEN..offset + rec_len]; - if verify_crc(&hdr, header_buf, body, offset as u64).is_err() { - break; - } + verify_crc(&hdr, header_buf, body, offset as u64)?; good.push(offset as u64); offset += rec_len; } diff --git a/crates/engine/src/log/recover.rs b/crates/engine/src/log/recover.rs index d19797c..11a4e75 100644 --- a/crates/engine/src/log/recover.rs +++ b/crates/engine/src/log/recover.rs @@ -151,9 +151,11 @@ async fn rebuild_from_records(file: &LogFile, file_id: u32, index: &mut NsIndex) break; } }; - if verify_crc(&hdr, &header_buf, &body, offset).is_err() { - break; - } + // Sealed files are immutable. A CRC miss mid-file is bitrot, not a + // torn tail — later records are still there. Fail-stop so we don't + // rebuild an index that silently dropped the suffix. (The active + // file still truncates: that *is* a torn write.) + verify_crc(&hdr, &header_buf, &body, offset)?; apply_record(index, file_id, offset, &hdr, &body); offset += hdr.record_len() as u64; } From 745eed89e60fd2ffe1492db85d271be2c0735fee Mon Sep 17 00:00:00 2001 From: Paulo Cabral Sanz Date: Fri, 14 Aug 2026 18:07:39 -0300 Subject: [PATCH 3/3] style: rustfmt sealed CRC scan --- crates/engine/src/log/record.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/log/record.rs b/crates/engine/src/log/record.rs index c2b5744..594b27b 100644 --- a/crates/engine/src/log/record.rs +++ b/crates/engine/src/log/record.rs @@ -273,7 +273,16 @@ mod tests { encode_into(&mut buf, 1, flags::NO_EXPIRY, 0, b"keep", b"one", b"").unwrap(); let second_at = buf.len(); encode_into(&mut buf, 2, flags::NO_EXPIRY, 0, b"drop-me", b"two", b"").unwrap(); - encode_into(&mut buf, 3, flags::NO_EXPIRY, 0, b"also-keep", b"three", b"").unwrap(); + encode_into( + &mut buf, + 3, + flags::NO_EXPIRY, + 0, + b"also-keep", + b"three", + b"", + ) + .unwrap(); // Flip one value byte in the middle record. Framing stays intact. let val = second_at + HEADER_LEN + b"drop-me".len();