use crate::manifest_blocks::{MANIFEST_LAYOUT_VERSION_V1, MAX_SECTION_NAME_BYTES};
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use std::io::{Read, Write};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TocEntry {
pub name: String,
pub block_offset: u64,
pub block_size: u32,
pub section_checksum: u128,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FooterPayload {
pub layout_version: u8,
pub flags: u8,
pub sections: Vec<TocEntry>,
}
impl FooterPayload {
#[must_use]
pub fn new(flags: u8, sections: Vec<TocEntry>) -> Self {
Self {
layout_version: MANIFEST_LAYOUT_VERSION_V1,
flags,
sections,
}
}
#[must_use]
pub fn section(&self, name: &str) -> Option<&TocEntry> {
self.sections.iter().find(|e| e.name == name)
}
pub fn encode<W: Write>(&self, mut writer: W) -> crate::Result<()> {
if self.sections.len() > usize::from(u16::MAX) {
return Err(crate::Error::ManifestFooterInvalid(
"section count exceeds u16::MAX",
));
}
let mut seen: crate::HashSet<&str> = crate::HashSet::default();
for entry in &self.sections {
if entry.name.is_empty() {
return Err(crate::Error::ManifestFooterInvalid(
"section name must be non-empty",
));
}
if entry.name.len() > MAX_SECTION_NAME_BYTES {
return Err(crate::Error::ManifestFooterInvalid(
"section name exceeds MAX_SECTION_NAME_BYTES",
));
}
if !seen.insert(entry.name.as_str()) {
return Err(crate::Error::ManifestFooterInvalid(
"duplicate section name",
));
}
}
writer.write_u8(self.layout_version)?;
writer.write_u8(self.flags)?;
#[expect(
clippy::cast_possible_truncation,
reason = "section count bounded by u16::MAX check above"
)]
writer.write_u16::<LittleEndian>(self.sections.len() as u16)?;
for entry in &self.sections {
#[expect(
clippy::cast_possible_truncation,
reason = "name length bounded by MAX_SECTION_NAME_BYTES check above"
)]
writer.write_u16::<LittleEndian>(entry.name.len() as u16)?;
writer.write_all(entry.name.as_bytes())?;
writer.write_u64::<LittleEndian>(entry.block_offset)?;
writer.write_u32::<LittleEndian>(entry.block_size)?;
writer.write_u128::<LittleEndian>(entry.section_checksum)?;
}
Ok(())
}
pub fn decode<R: Read>(mut reader: R) -> crate::Result<Self> {
let layout_version = reader.read_u8()?;
if layout_version != MANIFEST_LAYOUT_VERSION_V1 {
return Err(crate::Error::ManifestFooterInvalid(
"unknown manifest_layout_version",
));
}
let flags = reader.read_u8()?;
let section_count = usize::from(reader.read_u16::<LittleEndian>()?);
let mut sections: Vec<TocEntry> = Vec::new();
for _ in 0..section_count {
let name_len = usize::from(reader.read_u16::<LittleEndian>()?);
if name_len == 0 {
return Err(crate::Error::ManifestFooterInvalid(
"section name length must be non-zero",
));
}
if name_len > MAX_SECTION_NAME_BYTES {
return Err(crate::Error::ManifestFooterInvalid(
"section name length exceeds MAX_SECTION_NAME_BYTES",
));
}
let mut name_bytes = vec![0u8; name_len];
reader.read_exact(&mut name_bytes)?;
let name = String::from_utf8(name_bytes)
.map_err(|_| crate::Error::ManifestFooterInvalid("section name not UTF-8"))?;
let block_offset = reader.read_u64::<LittleEndian>()?;
let block_size = reader.read_u32::<LittleEndian>()?;
let section_checksum = reader.read_u128::<LittleEndian>()?;
if sections.iter().any(|e: &TocEntry| e.name == name) {
return Err(crate::Error::ManifestFooterInvalid(
"duplicate section name",
));
}
sections.push(TocEntry {
name,
block_offset,
block_size,
section_checksum,
});
}
Ok(Self {
layout_version,
flags,
sections,
})
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::expect_used,
clippy::cast_possible_truncation,
reason = "tests panic on the unhappy paths to surface failures loudly; \
the hand-rolled bad-byte fixtures need direct write_* calls \
that can't propagate via `?` cleanly; bounded test inputs \
never approach u16 truncation"
)]
mod tests {
use super::*;
use crate::manifest_blocks::FLAG_FOOTER_MIRROR_ENABLED;
fn sample_payload() -> FooterPayload {
FooterPayload::new(
FLAG_FOOTER_MIRROR_ENABLED,
vec![
TocEntry {
name: "format_version".to_string(),
block_offset: 4096,
block_size: 64,
section_checksum: 0xDEAD_BEEF_DEAD_BEEF_DEAD_BEEF_DEAD_BEEF_u128,
},
TocEntry {
name: "tables".to_string(),
block_offset: 4160,
block_size: 512,
section_checksum: 0xCAFE_BABE_CAFE_BABE_CAFE_BABE_CAFE_BABE_u128,
},
],
)
}
#[test]
fn footer_payload_roundtrip_preserves_all_fields() {
let original = sample_payload();
let mut buf = Vec::new();
original.encode(&mut buf).expect("encode succeeds");
let decoded = FooterPayload::decode(&buf[..]).expect("decode succeeds");
assert_eq!(decoded, original);
}
#[test]
fn footer_payload_section_lookup_finds_by_name() {
let payload = sample_payload();
let tables = payload.section("tables").expect("tables section exists");
assert_eq!(tables.block_offset, 4160);
assert_eq!(tables.block_size, 512);
assert!(payload.section("nonexistent").is_none());
}
#[test]
fn footer_decode_rejects_unknown_layout_version() {
let mut buf = Vec::new();
buf.write_u8(2).unwrap(); buf.write_u8(0).unwrap();
buf.write_u16::<LittleEndian>(0).unwrap();
let err = FooterPayload::decode(&buf[..]).expect_err("must reject");
assert!(matches!(err, crate::Error::ManifestFooterInvalid(_)));
}
#[test]
fn footer_decode_rejects_empty_section_name() {
let mut buf = Vec::new();
buf.write_u8(MANIFEST_LAYOUT_VERSION_V1).unwrap();
buf.write_u8(0).unwrap();
buf.write_u16::<LittleEndian>(1).unwrap();
buf.write_u16::<LittleEndian>(0).unwrap(); buf.write_u64::<LittleEndian>(0).unwrap();
buf.write_u32::<LittleEndian>(0).unwrap();
let err = FooterPayload::decode(&buf[..]).expect_err("must reject");
assert!(matches!(err, crate::Error::ManifestFooterInvalid(_)));
}
#[test]
fn footer_decode_rejects_duplicate_section_names() {
let entries = vec![
TocEntry {
name: "tables".to_string(),
block_offset: 4096,
block_size: 64,
section_checksum: 0,
},
TocEntry {
name: "tables".to_string(), block_offset: 4160,
block_size: 64,
section_checksum: 0,
},
];
let payload = FooterPayload::new(0, entries);
let mut buf = Vec::new();
buf.write_u8(payload.layout_version).unwrap();
buf.write_u8(payload.flags).unwrap();
buf.write_u16::<LittleEndian>(payload.sections.len() as u16)
.unwrap();
for e in &payload.sections {
buf.write_u16::<LittleEndian>(e.name.len() as u16).unwrap();
buf.write_all(e.name.as_bytes()).unwrap();
buf.write_u64::<LittleEndian>(e.block_offset).unwrap();
buf.write_u32::<LittleEndian>(e.block_size).unwrap();
buf.write_u128::<LittleEndian>(e.section_checksum).unwrap();
}
let err = FooterPayload::decode(&buf[..]).expect_err("must reject");
assert!(matches!(err, crate::Error::ManifestFooterInvalid(_)));
}
#[test]
fn footer_decode_rejects_oversized_name_length() {
let mut buf = Vec::new();
buf.write_u8(MANIFEST_LAYOUT_VERSION_V1).unwrap();
buf.write_u8(0).unwrap();
buf.write_u16::<LittleEndian>(1).unwrap();
#[expect(
clippy::cast_possible_truncation,
reason = "test crafts bad input deliberately"
)]
let oversized = (MAX_SECTION_NAME_BYTES + 1) as u16;
buf.write_u16::<LittleEndian>(oversized).unwrap();
let err = FooterPayload::decode(&buf[..]).expect_err("must reject");
assert!(matches!(err, crate::Error::ManifestFooterInvalid(_)));
}
#[test]
fn footer_encode_rejects_empty_section_name() {
let payload = FooterPayload::new(
0,
vec![TocEntry {
name: String::new(),
block_offset: 0,
block_size: 0,
section_checksum: 0,
}],
);
let mut buf = Vec::new();
let err = payload.encode(&mut buf).expect_err("must reject");
assert!(matches!(err, crate::Error::ManifestFooterInvalid(_)));
}
#[test]
fn footer_encode_rejects_oversized_section_name() {
let oversized_name = "x".repeat(MAX_SECTION_NAME_BYTES + 1);
let payload = FooterPayload::new(
0,
vec![TocEntry {
name: oversized_name,
block_offset: 0,
block_size: 0,
section_checksum: 0,
}],
);
let mut buf = Vec::new();
let err = payload.encode(&mut buf).expect_err("must reject");
assert!(matches!(err, crate::Error::ManifestFooterInvalid(_)));
}
}