diff --git a/crates/engine/src/log/record.rs b/crates/engine/src/log/record.rs index 17f34a4..594b27b 100644 --- a/crates/engine/src/log/record.rs +++ b/crates/engine/src/log/record.rs @@ -181,6 +181,40 @@ 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]; + verify_crc(&hdr, header_buf, body, offset as u64)?; + good.push(offset as u64); + offset += rec_len; + } + Ok(good) +} + #[cfg(test)] mod tests { use super::*; @@ -232,4 +266,41 @@ 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]); + } } 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; }