use super::*;
use crate::{Slice, fs::StdFs, vlog::blob_file::writer::Writer as BlobFileWriter};
use tempfile::tempdir;
use test_log::test;
const OD_LEN_OFF: usize = 4 + 16 + 8 + 2 + 4;
const HDR_CRC_OFF: usize = OD_LEN_OFF + 4;
const RV_LEN_OFF: usize = OD_LEN_OFF - core::mem::size_of::<u32>();
const _: () = assert!(
HDR_CRC_OFF + 4 == crate::vlog::blob_file::writer::BLOB_HEADER_LEN
&& RV_LEN_OFF + core::mem::size_of::<u32>() == OD_LEN_OFF,
"the derived header field offsets must tile the blob header",
);
#[test]
fn blob_scanner() -> crate::Result<()> {
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
let keys = [b"a", b"b", b"c", b"d", b"e"];
{
let mut writer = BlobFileWriter::new(&blob_file_path, 0, 0, &StdFs)?;
for key in keys {
writer.write(key, 0, &key.repeat(100))?;
}
writer.finish()?;
}
{
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
for key in keys {
let entry = scanner.next().unwrap()?;
assert_eq!(entry.key, Slice::from(key));
assert_eq!(entry.value, Slice::from(key.repeat(100)));
assert!(
!entry.resynced,
"a cleanly writer-chained frame is never tainted; an \
always-resynced implementation would fail here",
);
}
assert!(scanner.next().is_none());
}
Ok(())
}
#[test]
fn blob_scanner_resume_reads_suffix_and_rejects_bad_offset() -> crate::Result<()> {
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
let keys = [b"a", b"b", b"c", b"d", b"e"];
{
let mut writer = BlobFileWriter::new(&blob_file_path, 0, 0, &StdFs)?;
for key in keys {
writer.write(key, 0, &key.repeat(100))?;
}
writer.finish()?;
}
let resume_at = {
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let _a = scanner.next().unwrap()?;
let b = scanner.next().unwrap()?;
b.frame_end
};
{
let mut scanner = Scanner::resume(&blob_file_path, &StdFs, 0, resume_at)?;
for key in [b"c", b"d", b"e"] {
assert_eq!(
Slice::from(&key[..]),
scanner.next().map(|r| r.map(|e| e.key)).unwrap()?,
);
}
assert!(scanner.next().is_none());
}
assert!(
matches!(
Scanner::resume(&blob_file_path, &StdFs, 0, u64::MAX),
Err(crate::Error::InvalidHeader("BlobFile")),
),
"resume offset past the data section must error",
);
Ok(())
}
#[test]
fn blob_scanner_corrupted_seqno_detected_by_header_crc() -> crate::Result<()> {
use crate::vlog::blob_file::writer::BLOB_HEADER_MAGIC;
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
{
let mut writer = BlobFileWriter::new(&blob_file_path, 0, 0, &StdFs)?;
writer.write(b"key", 42, &b"v".repeat(100))?;
writer.finish()?;
}
let mut raw = std::fs::read(&blob_file_path)?;
let frame_start = 0usize;
let seqno_offset = frame_start + BLOB_HEADER_MAGIC.len() + std::mem::size_of::<u128>();
let seqno_len = std::mem::size_of::<u64>();
raw[seqno_offset..seqno_offset + seqno_len].copy_from_slice(&99u64.to_le_bytes()[..seqno_len]);
std::fs::write(&blob_file_path, &raw)?;
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let result = scanner.next().unwrap();
assert!(
matches!(result, Err(crate::Error::HeaderCrcMismatch { .. })),
"expected HeaderCrcMismatch for corrupted seqno, got: {result:?}",
);
Ok(())
}
#[test]
fn blob_scanner_corrupted_value_detected_by_data_checksum() -> crate::Result<()> {
use crate::vlog::blob_file::writer::BLOB_HEADER_LEN;
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
{
let mut writer = BlobFileWriter::new(&blob_file_path, 0, 0, &StdFs)?;
writer.write(b"key", 0, &b"v".repeat(100))?;
writer.finish()?;
}
let mut raw = std::fs::read(&blob_file_path)?;
let frame_start = 0usize;
let key = b"key";
let value_offset = frame_start + BLOB_HEADER_LEN + key.len();
raw[value_offset] ^= 0xFF;
std::fs::write(&blob_file_path, &raw)?;
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let result = scanner.next().unwrap();
assert!(
matches!(result, Err(crate::Error::ChecksumMismatch { .. })),
"expected ChecksumMismatch for corrupted value, got: {result:?}",
);
Ok(())
}
#[test]
fn blob_scanner_rejects_retired_blob_magic_frame() -> crate::Result<()> {
use crate::io::{LittleEndian, WriteBytesExt};
use std::io::Write;
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
let key = b"abc";
let value = b"hello_v3";
let checksum = {
let mut hasher = xxhash_rust::xxh3::Xxh3::default();
hasher.update(key);
hasher.update(value);
hasher.digest128()
};
{
let file = std::fs::File::create(&blob_file_path)?;
let mut sfa_writer = crate::sfa::Writer::from_writer(file);
sfa_writer.start("data")?;
sfa_writer.write_all(b"BLOB")?;
sfa_writer.write_u128::<LittleEndian>(checksum)?;
sfa_writer.write_u64::<LittleEndian>(42)?; #[expect(
clippy::cast_possible_truncation,
reason = "test key length fits in u16"
)]
sfa_writer.write_u16::<LittleEndian>(key.len() as u16)?;
#[expect(
clippy::cast_possible_truncation,
reason = "test value length fits in u32"
)]
sfa_writer.write_u32::<LittleEndian>(value.len() as u32)?; #[expect(
clippy::cast_possible_truncation,
reason = "test value length fits in u32"
)]
sfa_writer.write_u32::<LittleEndian>(value.len() as u32)?; sfa_writer.write_all(key)?;
sfa_writer.write_all(value)?;
sfa_writer.start("meta")?;
let metadata = crate::vlog::blob_file::meta::Metadata {
id: 0,
version: crate::vlog::blob_file::meta::META_VERSION,
created_at: 0,
item_count: 1,
total_compressed_bytes: value.len() as u64,
total_uncompressed_bytes: value.len() as u64,
key_range: crate::KeyRange::new((key[..].into(), key[..].into())),
compression: crate::CompressionType::None,
};
metadata.encode_into(&mut sfa_writer)?;
let inner = sfa_writer.into_inner()?;
inner.sync_all()?;
}
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let result = scanner.next().unwrap();
assert!(
matches!(result, Err(crate::Error::InvalidHeader("Blob"))),
"a retired-format frame must be rejected, got: {result:?}",
);
assert!(
scanner.next().is_none(),
"no readable frame exists past the rejected one"
);
Ok(())
}
#[test]
fn blob_scanner_rejects_oversized_on_disk_len() -> crate::Result<()> {
use crate::io::{LittleEndian, WriteBytesExt};
use std::io::Write;
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
let key = b"abc";
let value = b"hi";
{
let file = std::fs::File::create(&blob_file_path)?;
let mut sfa_writer = crate::sfa::Writer::from_writer(file);
sfa_writer.start("data")?;
sfa_writer.write_all(crate::vlog::blob_file::writer::BLOB_HEADER_MAGIC)?;
sfa_writer.write_u128::<LittleEndian>(0)?; sfa_writer.write_u64::<LittleEndian>(1)?; #[expect(clippy::cast_possible_truncation, reason = "test key fits u16")]
sfa_writer.write_u16::<LittleEndian>(key.len() as u16)?;
sfa_writer.write_u32::<LittleEndian>(2)?; sfa_writer.write_u32::<LittleEndian>(u32::MAX)?;
#[expect(clippy::cast_possible_truncation, reason = "test key fits u16")]
let crc =
crate::vlog::blob_file::writer::compute_header_crc(1, key.len() as u16, 2, u32::MAX);
sfa_writer.write_u32::<LittleEndian>(crc)?;
sfa_writer.write_all(key)?;
sfa_writer.write_all(value)?;
sfa_writer.start("meta")?;
let metadata = crate::vlog::blob_file::meta::Metadata {
id: 0,
version: crate::vlog::blob_file::meta::META_VERSION,
created_at: 0,
item_count: 1,
total_compressed_bytes: 2,
total_uncompressed_bytes: 2,
key_range: crate::KeyRange::new((key[..].into(), key[..].into())),
compression: crate::CompressionType::None,
};
metadata.encode_into(&mut sfa_writer)?;
sfa_writer.into_inner()?.sync_all()?;
}
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let result = scanner.next().unwrap();
assert!(
matches!(result, Err(crate::Error::InvalidHeader("Blob"))),
"an oversized on_disk_val_len must be rejected, got: {result:?}",
);
assert!(
scanner.next().is_none(),
"a CRC-vouched oversized declaration means truncation: the scan terminates"
);
Ok(())
}
#[test]
fn blob_scanner_rejects_a_partial_header_at_the_section_boundary() -> crate::Result<()> {
use crate::io::{LittleEndian, WriteBytesExt};
use std::io::Write;
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
{
let file = std::fs::File::create(&blob_file_path)?;
let mut sfa_writer = crate::sfa::Writer::from_writer(file);
sfa_writer.start("data")?;
sfa_writer.write_all(crate::vlog::blob_file::writer::BLOB_HEADER_MAGIC)?;
sfa_writer.write_u64::<LittleEndian>(0)?;
sfa_writer.start("meta")?;
let metadata = crate::vlog::blob_file::meta::Metadata {
id: 0,
version: crate::vlog::blob_file::meta::META_VERSION,
created_at: 0,
item_count: 0,
total_compressed_bytes: 0,
total_uncompressed_bytes: 0,
key_range: crate::KeyRange::new((b"a"[..].into(), b"a"[..].into())),
compression: crate::CompressionType::None,
};
metadata.encode_into(&mut sfa_writer)?;
sfa_writer.into_inner()?.sync_all()?;
}
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let result = scanner.next().unwrap();
assert!(
matches!(result, Err(crate::Error::InvalidHeader("Blob"))),
"the partial tail is rejected before the header read, not parsed into \
the adjacent section, got: {result:?}",
);
assert!(
scanner.next().is_none(),
"no whole frame fits in the truncated data section: the scan terminates",
);
Ok(())
}
#[test]
fn blob_scanner_rejects_over_cap_real_val_len() -> crate::Result<()> {
use crate::vlog::blob_file::writer::compute_header_crc;
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
let key = b"aaa";
let value = b"hi";
let seqno = 7u64;
{
let mut writer = BlobFileWriter::new(&blob_file_path, 0, 0, &StdFs)?;
writer.write(key, seqno, value)?;
writer.finish()?;
}
let over_cap: u32 = u32::try_from(MAX_DECOMPRESSION_SIZE).unwrap() + 1;
{
let mut bytes = std::fs::read(&blob_file_path)?;
bytes[RV_LEN_OFF..RV_LEN_OFF + 4].copy_from_slice(&over_cap.to_le_bytes());
let key_len = u16::try_from(key.len()).unwrap();
let on_disk_val_len = u32::try_from(value.len()).unwrap();
let crc = compute_header_crc(seqno, key_len, over_cap, on_disk_val_len);
bytes[HDR_CRC_OFF..HDR_CRC_OFF + 4].copy_from_slice(&crc.to_le_bytes());
std::fs::write(&blob_file_path, bytes)?;
}
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let result = scanner.next().unwrap();
assert!(
matches!(result, Err(crate::Error::InvalidHeader("Blob"))),
"an over-cap real_val_len must be rejected before allocation, got: {result:?}",
);
Ok(())
}
fn write_two_frames(dir: &std::path::Path) -> crate::Result<std::path::PathBuf> {
let blob_file_path = dir.join("0");
let mut writer = BlobFileWriter::new(&blob_file_path, 0, 0, &StdFs)?;
writer.write(b"aaa", 7, b"first_value")?;
writer.write(b"bbb", 7, b"second_value")?;
writer.finish()?;
Ok(blob_file_path)
}
#[test]
fn blob_scanner_header_crc_rot_resyncs_to_next_frame() -> crate::Result<()> {
let dir = tempdir()?;
let blob_file_path = write_two_frames(dir.path())?;
{
let mut bytes = std::fs::read(&blob_file_path)?;
bytes[OD_LEN_OFF..OD_LEN_OFF + 4].copy_from_slice(&u32::MAX.to_le_bytes());
std::fs::write(&blob_file_path, bytes)?;
}
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let first = scanner.next().unwrap();
assert!(
matches!(first, Err(crate::Error::HeaderCrcMismatch { .. })),
"the rotted length field fails the header CRC: {first:?}",
);
let Some(second) = scanner.next() else {
panic!("the intact second frame must survive the rotted first");
};
let second = second?;
assert_eq!(second.key, Slice::from(&b"bbb"[..]));
assert_eq!(second.value, Slice::from(&b"second_value"[..]));
assert!(scanner.next().is_none());
Ok(())
}
#[test]
fn blob_scanner_magic_rot_resyncs_to_next_frame() -> crate::Result<()> {
let dir = tempdir()?;
let blob_file_path = write_two_frames(dir.path())?;
{
let mut bytes = std::fs::read(&blob_file_path)?;
bytes[0] ^= 0xFF;
std::fs::write(&blob_file_path, bytes)?;
}
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let first = scanner.next().unwrap();
assert!(
matches!(first, Err(crate::Error::InvalidHeader("Blob"))),
"the rotted magic is rejected: {first:?}",
);
let Some(second) = scanner.next() else {
panic!("the intact second frame must survive the rotted first");
};
let second = second?;
assert_eq!(second.key, Slice::from(&b"bbb"[..]));
assert_eq!(second.value, Slice::from(&b"second_value"[..]));
assert!(scanner.next().is_none());
Ok(())
}
fn write_frames_with_embedded_fake_header(
dir: &std::path::Path,
fake_extra_len: u32,
) -> crate::Result<std::path::PathBuf> {
use crate::vlog::blob_file::writer::{BLOB_HEADER_LEN, compute_header_crc};
let blob_file_path = dir.join("0");
let header = BLOB_HEADER_LEN as u64;
let f2_off = header + 3 + 52;
let f3_off = f2_off + header + 3 + 12;
let fake_pos = header + 3 + 10;
#[expect(
clippy::cast_possible_truncation,
reason = "test layout offsets are tiny"
)]
let odl = (f3_off - fake_pos - header - 3) as u32 + fake_extra_len;
let fake_seqno = 1u64;
let crc = compute_header_crc(fake_seqno, 3, odl, odl);
let mut fake = Vec::with_capacity(BLOB_HEADER_LEN);
fake.extend_from_slice(BLOB_HEADER_MAGIC);
fake.extend_from_slice(&0u128.to_le_bytes()); fake.extend_from_slice(&fake_seqno.to_le_bytes());
fake.extend_from_slice(&3u16.to_le_bytes());
fake.extend_from_slice(&odl.to_le_bytes());
fake.extend_from_slice(&odl.to_le_bytes());
fake.extend_from_slice(&crc.to_le_bytes());
assert_eq!(fake.len(), BLOB_HEADER_LEN, "fake header fills the layout");
let mut value1 = alloc::vec![0xAAu8; 10];
value1.extend_from_slice(&fake);
assert_eq!(value1.len(), 52, "f1 value matches the planned layout");
let mut writer = BlobFileWriter::new(&blob_file_path, 0, 0, &StdFs)?;
writer.write(b"aaa", 7, &value1)?;
writer.write(b"bbb", 7, b"second_value")?;
writer.write(b"ccc", 7, b"third_value")?;
writer.finish()?;
let mut bytes = std::fs::read(&blob_file_path)?;
bytes[0] ^= 0xFF;
std::fs::write(&blob_file_path, bytes)?;
Ok(blob_file_path)
}
#[test]
fn blob_scanner_resyncs_again_when_a_candidate_frame_fails_its_checksum() -> crate::Result<()> {
let dir = tempdir()?;
let blob_file_path = write_frames_with_embedded_fake_header(dir.path(), 0)?;
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let first = scanner.next().unwrap();
assert!(
matches!(first, Err(crate::Error::InvalidHeader("Blob"))),
"the rotted real magic is rejected: {first:?}",
);
let second = scanner.next().unwrap();
assert!(
matches!(second, Err(crate::Error::ChecksumMismatch { .. })),
"the fake candidate fails its payload checksum: {second:?}",
);
let Some(third) = scanner.next() else {
panic!("the intact second frame must survive the fake candidate");
};
let third = third?;
assert_eq!(
third.key,
Slice::from(&b"bbb"[..]),
"frame 2 is recovered, not skipped by the fake declared end",
);
assert_eq!(third.value, Slice::from(&b"second_value"[..]));
assert!(
third.resynced,
"the first frame recovered after a resync is tainted",
);
let Some(fourth) = scanner.next() else {
panic!("frame 3 follows");
};
let fourth = fourth?;
assert_eq!(fourth.key, Slice::from(&b"ccc"[..]));
assert!(
fourth.resynced,
"the taint is STICKY: every frame chained after a resync stays untrusted \
through EOF, since its boundary was re-established by search",
);
assert!(scanner.next().is_none());
Ok(())
}
#[test]
fn blob_scanner_resyncs_when_a_candidate_frame_declares_past_the_section() -> crate::Result<()> {
let dir = tempdir()?;
let blob_file_path = write_frames_with_embedded_fake_header(dir.path(), 1_000_000)?;
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let first = scanner.next().unwrap();
assert!(
matches!(first, Err(crate::Error::InvalidHeader("Blob"))),
"the rotted real magic is rejected: {first:?}",
);
let second = scanner.next().unwrap();
assert!(
matches!(second, Err(crate::Error::InvalidHeader("Blob"))),
"the fake candidate is bounds-rejected: {second:?}",
);
let Some(third) = scanner.next() else {
panic!("the intact second frame must survive the fake candidate");
};
let third = third?;
assert_eq!(
third.key,
Slice::from(&b"bbb"[..]),
"frame 2 is recovered, not lost to a terminated scan",
);
let Some(fourth) = scanner.next() else {
panic!("frame 3 follows");
};
let fourth = fourth?;
assert_eq!(fourth.key, Slice::from(&b"ccc"[..]));
assert!(scanner.next().is_none());
Ok(())
}
#[test]
fn blob_scanner_resyncs_when_a_chained_frame_declares_past_the_section() -> crate::Result<()> {
use crate::vlog::blob_file::writer::compute_header_crc;
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
{
let mut writer = BlobFileWriter::new(&blob_file_path, 0, 0, &StdFs)?;
writer.write(b"aaa", 7, b"first_value")?;
writer.write(b"bbb", 7, b"second_value")?;
writer.write(b"ccc", 7, b"third_value")?;
writer.finish()?;
}
{
let mut bytes = std::fs::read(&blob_file_path)?;
let huge = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
bytes[OD_LEN_OFF..OD_LEN_OFF + 4].copy_from_slice(&huge.to_le_bytes());
let crc = compute_header_crc(7, 3, 11, huge);
bytes[HDR_CRC_OFF..HDR_CRC_OFF + 4].copy_from_slice(&crc.to_le_bytes());
std::fs::write(&blob_file_path, bytes)?;
}
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let first = scanner.next().unwrap();
assert!(
matches!(first, Err(crate::Error::InvalidHeader("Blob"))),
"the over-section frame is bounds-rejected: {first:?}",
);
let Some(second) = scanner.next() else {
panic!("the intact frame 2 must survive the over-section frame");
};
let second = second?;
assert_eq!(
second.key,
Slice::from(&b"bbb"[..]),
"frame 2 is recovered, not lost to a terminated scan",
);
let Some(third) = scanner.next() else {
panic!("frame 3 follows");
};
assert_eq!(third?.key, Slice::from(&b"ccc"[..]));
assert!(scanner.next().is_none());
Ok(())
}
#[test]
fn blob_scanner_resyncs_when_a_chained_frame_swallows_the_next() -> crate::Result<()> {
use crate::vlog::blob_file::writer::{BLOB_HEADER_LEN, compute_header_crc};
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
{
let mut writer = BlobFileWriter::new(&blob_file_path, 0, 0, &StdFs)?;
writer.write(b"aaa", 7, b"first_value")?; writer.write(b"bbb", 7, b"second_value")?;
writer.write(b"ccc", 7, b"third_value")?;
writer.finish()?;
}
#[expect(
clippy::cast_possible_truncation,
reason = "BLOB_HEADER_LEN is the 42-byte header constant, well within u32"
)]
let header = BLOB_HEADER_LEN as u32;
let key_len = 3u32;
let f3_off = (header + key_len + 11) + (header + key_len + 12);
let swallow_odl = f3_off - header - key_len; {
let mut bytes = std::fs::read(&blob_file_path)?;
bytes[OD_LEN_OFF..OD_LEN_OFF + 4].copy_from_slice(&swallow_odl.to_le_bytes());
let crc = compute_header_crc(7, 3, 11, swallow_odl);
bytes[HDR_CRC_OFF..HDR_CRC_OFF + 4].copy_from_slice(&crc.to_le_bytes());
std::fs::write(&blob_file_path, bytes)?;
}
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let first = scanner.next().unwrap();
assert!(
matches!(first, Err(crate::Error::ChecksumMismatch { .. })),
"the swallowing frame fails its payload checksum: {first:?}",
);
let Some(second) = scanner.next() else {
panic!("the intact frame 2 must survive the swallowing frame");
};
let second = second?;
assert_eq!(
second.key,
Slice::from(&b"bbb"[..]),
"frame 2 is recovered, not skipped by the re-stamped declared end",
);
assert_eq!(second.value, Slice::from(&b"second_value"[..]));
let Some(third) = scanner.next() else {
panic!("frame 3 follows");
};
assert_eq!(third?.key, Slice::from(&b"ccc"[..]));
assert!(scanner.next().is_none());
Ok(())
}
#[test]
fn blob_scanner_rejects_invalid_magic() -> crate::Result<()> {
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
{
let mut writer = BlobFileWriter::new(&blob_file_path, 0, 0, &StdFs)?;
writer.write(b"key", 0, b"value")?;
writer.finish()?;
}
let mut raw = std::fs::read(&blob_file_path)?;
raw[0..4].copy_from_slice(b"XXXX");
std::fs::write(&blob_file_path, &raw)?;
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let result = scanner.next().unwrap();
assert!(
matches!(result, Err(crate::Error::InvalidHeader("Blob"))),
"expected InvalidHeader for bad magic, got: {result:?}",
);
assert!(scanner.next().is_none());
Ok(())
}
#[test]
fn blob_scanner_meta_corruption_is_not_silent_eof() -> crate::Result<()> {
use crate::vlog::blob_file::writer::BLOB_HEADER_LEN;
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
{
let mut writer = BlobFileWriter::new(&blob_file_path, 0, 0, &StdFs)?;
writer.write(b"a", 0, &b"v".repeat(50))?;
writer.write(b"b", 1, &b"w".repeat(50))?;
writer.finish()?;
}
let data_start = {
let sfa_reader = crate::sfa::Reader::new(&blob_file_path)?;
let section = sfa_reader.toc().section(b"data").unwrap();
#[expect(
clippy::cast_possible_truncation,
reason = "test blob file is tiny, pos fits in usize"
)]
{
section.pos() as usize
}
};
let mut raw = std::fs::read(&blob_file_path)?;
let second_frame_offset = data_start + BLOB_HEADER_LEN + 1 + 50;
raw.get_mut(second_frame_offset..second_frame_offset + 4)
.unwrap()
.copy_from_slice(b"META");
std::fs::write(&blob_file_path, &raw)?;
let mut scanner = Scanner::new(&blob_file_path, &StdFs, 0)?;
let first = scanner.next().unwrap();
assert!(first.is_ok(), "first frame should be OK: {first:?}");
let second = scanner.next().unwrap();
assert!(
matches!(second, Err(crate::Error::InvalidHeader("Blob"))),
"expected InvalidHeader for META-corrupted magic, got: {second:?}",
);
Ok(())
}
#[test]
fn blob_scanner_rejects_missing_data_section() -> crate::Result<()> {
use std::io::Write;
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
{
let file = std::fs::File::create(&blob_file_path)?;
let mut sfa_writer = crate::sfa::Writer::from_writer(file);
sfa_writer.start("meta")?;
sfa_writer.write_all(b"dummy")?;
sfa_writer.finish()?;
}
let result = Scanner::new(&blob_file_path, &StdFs, 0);
assert!(result.is_err(), "expected error for missing data section");
let err = result.err().unwrap();
assert!(
matches!(err, crate::Error::InvalidHeader("BlobFile")),
"expected InvalidHeader for missing data section, got: {err:?}",
);
Ok(())
}
#[test]
fn blob_scanner_rejects_data_section_offset_overflow() -> crate::Result<()> {
use crate::io::{LittleEndian, WriteBytesExt};
use std::io::Write;
let dir = tempdir()?;
let blob_file_path = dir.path().join("0");
{
let mut file = std::fs::File::create(&blob_file_path)?;
file.write_all(b"\x00")?;
let toc_pos: u64 = 1;
let mut toc_buf = Vec::new();
toc_buf.write_all(b"TOC!")?;
toc_buf.write_u32::<LittleEndian>(1)?; toc_buf.write_u64::<LittleEndian>(1)?; toc_buf.write_u64::<LittleEndian>(u64::MAX)?; toc_buf.write_u16::<LittleEndian>(4)?; toc_buf.write_all(b"data")?;
let toc_checksum = xxhash_rust::xxh3::xxh3_128(&toc_buf);
let toc_len = toc_buf.len() as u64;
file.write_all(&toc_buf)?;
file.write_all(b"SFA!")?;
file.write_u8(0x1)?; file.write_u8(0x0)?; file.write_u128::<LittleEndian>(toc_checksum)?;
file.write_u64::<LittleEndian>(toc_pos)?;
file.write_u64::<LittleEndian>(toc_len)?;
file.sync_all()?;
}
let result = Scanner::new(&blob_file_path, &StdFs, 0);
assert!(
result.is_err(),
"expected error for overflowing data section"
);
let err = result.err().unwrap();
assert!(
matches!(err, crate::Error::InvalidHeader("BlobFile")),
"expected InvalidHeader(\"BlobFile\") for overflow, got: {err:?}",
);
Ok(())
}