use super::{crc_valid_frame_exists_after, frame_decode, sidx, try_decode_frame_at, StoreError};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::io::{Read, Seek, SeekFrom};
#[derive(Deserialize)]
struct CorroborationFramePayload {
event: CorroborationEvent,
}
#[derive(Deserialize)]
struct CorroborationEvent {
#[serde(rename = "header")]
_header: serde::de::IgnoredAny,
#[serde(rename = "payload")]
_payload: serde::de::IgnoredAny,
hash_chain: Option<crate::event::HashChain>,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct RecoveredFrame {
pub(crate) frame_length: u32,
pub(crate) event_hash: Option<[u8; 32]>,
}
pub(crate) type RecoveredFrameMap = BTreeMap<u64, RecoveredFrame>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum UntrustedRecovery {
RecoverPrefix(u64),
FailClosedCorroboratedLoss,
FailClosedUnprovableTail,
RecoverPrefixWithTruncationEvidence { end: u64, footer_claimed_end: u64 },
FailClosedEvidenceOfTruncation { footer_claimed_end: u64 },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct TruncationEvidence {
pub(crate) recovered_prefix_end: u64,
pub(crate) footer_claimed_frames_end: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct ResolvedFramesEnd {
pub(crate) frames_end: u64,
pub(crate) truncation_evidence: Option<TruncationEvidence>,
}
pub(super) fn crc_valid_frames_end_with_map<R: Read + Seek>(
source: &mut R,
frames_start: u64,
file_len: u64,
segment_id: u64,
) -> Result<(u64, RecoveredFrameMap), StoreError> {
let mut cursor = frames_start;
let mut recovered: RecoveredFrameMap = BTreeMap::new();
loop {
if cursor >= file_len {
return Ok((file_len, recovered));
}
match try_decode_frame_at(source, cursor, file_len)? {
Some(frame_size) => {
let event_hash = read_frame_event_hash(source, cursor, frame_size);
let frame_length = u32::try_from(frame_size).ok();
if let Some(frame_length) = frame_length {
recovered.insert(
cursor,
RecoveredFrame {
frame_length,
event_hash,
},
);
}
cursor = match cursor.checked_add(frame_size) {
Some(next) => next,
None => return Ok((cursor, recovered)),
};
}
None => {
let resync_from = match cursor.checked_add(1) {
Some(next) => next,
None => return Ok((cursor, recovered)),
};
if crc_valid_frame_exists_after(source, resync_from, file_len)? {
return Err(StoreError::corrupt_segment_with_detail(
segment_id,
format!(
"mid-stream corruption: frame at offset {cursor} is non-decodable but a \
CRC-valid frame follows before EOF (file_len {file_len}); refusing to \
silently truncate to the prefix during untrusted-footer recovery"
),
));
}
return Ok((cursor, recovered));
}
}
}
}
fn read_frame_event_hash<R: Read + Seek>(
source: &mut R,
at: u64,
frame_size: u64,
) -> Option<[u8; 32]> {
let total = usize::try_from(frame_size).ok()?;
if total < 8 {
return None;
}
if source.seek(SeekFrom::Start(at)).is_err() {
return None;
}
let mut frame = vec![0u8; total];
if source.read_exact(&mut frame).is_err() {
return None;
}
let (msgpack, _consumed) = frame_decode(&frame).ok()?;
let payload: CorroborationFramePayload = crate::encoding::from_bytes(msgpack).ok()?;
payload.event.hash_chain.map(|chain| chain.event_hash)
}
pub(crate) fn corroborate_untrusted_entries(
entries: &[sidx::SidxEntry],
recovered: &RecoveredFrameMap,
recovery_stop: u64,
footer_claimed_frames_end: Option<u64>,
fallback_fail_closed: bool,
) -> UntrustedRecovery {
let is_corroborated = |entry: &sidx::SidxEntry| -> bool {
match recovered.get(&entry.frame_offset) {
Some(frame) => {
frame.frame_length == entry.frame_length
&& frame.event_hash == Some(entry.event_hash)
}
None => false,
}
};
let any_corroborated = entries.iter().any(is_corroborated);
if !any_corroborated {
let torn_gap = footer_claimed_frames_end.filter(|&claimed| recovery_stop < claimed);
if recovered.is_empty() {
return UntrustedRecovery::RecoverPrefix(recovery_stop);
}
if fallback_fail_closed {
return match torn_gap {
Some(claimed) => UntrustedRecovery::FailClosedEvidenceOfTruncation {
footer_claimed_end: claimed,
},
None => UntrustedRecovery::FailClosedUnprovableTail,
};
}
return match torn_gap {
Some(claimed) => UntrustedRecovery::RecoverPrefixWithTruncationEvidence {
end: recovery_stop,
footer_claimed_end: claimed,
},
None => UntrustedRecovery::RecoverPrefix(recovery_stop),
};
}
for entry in entries {
if entry.frame_offset >= recovery_stop {
let present = recovered.get(&entry.frame_offset).is_some_and(|frame| {
frame.frame_length == entry.frame_length
&& frame.event_hash == Some(entry.event_hash)
});
if !present {
return UntrustedRecovery::FailClosedCorroboratedLoss;
}
}
}
UntrustedRecovery::RecoverPrefix(recovery_stop)
}
pub(crate) fn resolve_untrusted_frames_end<R: Read + Seek>(
source: &mut R,
frames_start: u64,
file_len: u64,
segment_id: u64,
footer_claimed_frames_end: Option<u64>,
fallback_fail_closed: bool,
) -> Result<ResolvedFramesEnd, StoreError> {
let entries = sidx::read_entries_unauthenticated(source, segment_id)?;
let (recovery_stop, recovered) =
crc_valid_frames_end_with_map(source, frames_start, file_len, segment_id)?;
const TRAILER_LEN: u64 = 16;
let bounded_footer_claim = footer_claimed_frames_end
.filter(|&claimed| claimed <= file_len.saturating_sub(TRAILER_LEN));
match corroborate_untrusted_entries(
&entries,
&recovered,
recovery_stop,
bounded_footer_claim,
fallback_fail_closed,
) {
UntrustedRecovery::RecoverPrefix(end) => Ok(ResolvedFramesEnd {
frames_end: end,
truncation_evidence: None,
}),
UntrustedRecovery::RecoverPrefixWithTruncationEvidence { end, footer_claimed_end } => {
Ok(ResolvedFramesEnd {
frames_end: end,
truncation_evidence: Some(TruncationEvidence {
recovered_prefix_end: end,
footer_claimed_frames_end: footer_claimed_end,
}),
})
}
UntrustedRecovery::FailClosedCorroboratedLoss => {
Err(StoreError::corrupt_segment_with_detail(
segment_id,
format!(
"untrusted-footer recovery: a corroborated SIDX manifest entry attests to a \
committed frame at/after the recovered prefix end {recovery_stop} that is \
missing from the CRC-valid frame stream (file_len {file_len}); a torn/corrupt \
last committed frame under a corrupt footer would silently drop a committed \
event — refusing to recover"
),
))
}
UntrustedRecovery::FailClosedUnprovableTail => {
Err(StoreError::corrupt_segment_with_detail(
segment_id,
format!(
"untrusted-footer recovery: strict FailClosed posture refuses an unprovable \
tail — a non-empty CRC-valid prefix ending at {recovery_stop} was recovered \
beneath an untrusted footer (file_len {file_len}) with NO corroborating SIDX \
manifest entry, so a torn/truncated further committed frame cannot be ruled \
out; the default RecoverTornTail posture would instead recover this prefix"
),
))
}
UntrustedRecovery::FailClosedEvidenceOfTruncation { footer_claimed_end } => {
Err(StoreError::corrupt_segment_with_detail(
segment_id,
format!(
"untrusted-footer recovery: strict FailClosed posture refuses a tail with POSITIVE \
truncation evidence — CRC-valid frames end at {recovery_stop} but the untrusted \
footer claims frames extend to {footer_claimed_end} (file_len {file_len}); the \
{gap}-byte region between is neither CRC-valid frames nor footer, so a committed \
frame was torn/dropped; the default RecoverTornTail posture would recover the \
prefix and record the evidence",
gap = footer_claimed_end - recovery_stop
),
))
}
}
}
#[cfg(test)]
mod tests {
use super::read_frame_event_hash;
use std::io::{Cursor, Seek, SeekFrom};
#[test]
fn undersized_frame_rejected_without_consuming_the_source() {
let mut source = Cursor::new(vec![0u8; 64]);
source
.seek(SeekFrom::Start(41))
.expect("park the cursor before the call");
let hash = read_frame_event_hash(&mut source, 0, 4);
assert_eq!(hash, None, "an undersized frame yields no event hash");
assert_eq!(
source.position(),
41,
"the size guard must reject an undersized frame without consuming the source"
);
}
#[test]
fn exactly_header_sized_frame_is_read_not_pre_rejected() {
let mut source = Cursor::new(vec![0u8; 64]);
source
.seek(SeekFrom::Start(41))
.expect("park the cursor before the call");
let hash = read_frame_event_hash(&mut source, 0, 8);
assert_eq!(hash, None, "a bare 8-byte header carries no event hash");
assert_eq!(
source.position(),
8,
"an exactly-header-sized frame must be seeked-to and read, not pre-rejected"
);
}
}