Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions crates/engine/src/log/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u64>> {
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::*;
Expand Down Expand Up @@ -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]);
}
}
8 changes: 5 additions & 3 deletions crates/engine/src/log/recover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down