Add SidecarLayout: streaming layout API for virtual sidecars

Prelude bytes + offset math for the fixed two-entry (Finder Info +
resource fork) container, so a consumer serving a virtual sidecar
(fsinspect's FUSE AppleDouble mode) can answer getattr from total_len
and dispatch range reads via plan_read without materializing the
container. Byte-equivalence with build_appledouble is pinned by test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5
2026-07-19 11:51:33 -05:00
parent 55e10eb936
commit 5313b1935d

View File

@@ -64,7 +64,8 @@ const MAC_EPOCH_OFFSET: u64 = 946_684_800;
/// RFC 1740 sentinel meaning "date unknown". /// RFC 1740 sentinel meaning "date unknown".
const DATE_UNKNOWN: i32 = i32::MIN; // 0x80000000 const DATE_UNKNOWN: i32 = i32::MIN; // 0x80000000
const FINDER_INFO_LEN: usize = 32; /// Finder Info (entry id 9) is exactly 32 bytes on disk.
pub const FINDER_INFO_LEN: usize = 32;
/// XNU caps extended-attribute names at 128 bytes (incl. NUL). /// XNU caps extended-attribute names at 128 bytes (incl. NUL).
const MAX_XATTR_NAME: usize = 128; const MAX_XATTR_NAME: usize = 128;
@@ -106,6 +107,8 @@ pub enum AppleDoubleError {
AttrValueOutOfRange { name: String }, AttrValueOutOfRange { name: String },
#[error("malformed AppleDouble structure")] #[error("malformed AppleDouble structure")]
Malformed(#[from] binrw::Error), Malformed(#[from] binrw::Error),
#[error("resource fork ({0} bytes) does not fit AppleDouble's u32 length field")]
ResourceForkTooLarge(u64),
} }
#[derive(BinRead, BinWrite)] #[derive(BinRead, BinWrite)]
@@ -503,6 +506,112 @@ fn encode_attr_section(
Ok(out.into_inner()) Ok(out.into_inner())
} }
/// Bytes before the resource fork in the fixed two-entry container: header,
/// two-entry table, and Finder Info. Numerically 82.
pub const PRELUDE_LEN: usize = RAW_HEADER_SIZE + 2 * RAW_ENTRY_SIZE + FINDER_INFO_LEN;
/// One piece of a range read planned by [`SidecarLayout::plan_read`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadSegment {
/// Copy this range of the [`SidecarLayout::prelude`] bytes.
Prelude(std::ops::Range<usize>),
/// Read this byte range of the resource fork itself.
ResourceFork(std::ops::Range<u64>),
}
/// Byte layout of the fixed two-entry sidecar that [`build_appledouble`]
/// emits when there are no packed xattrs: the [`PRELUDE_LEN`]-byte prelude
/// (header + entry table + Finder Info) followed by the resource fork.
///
/// This is the streaming counterpart to [`build_appledouble`]: a consumer
/// serving a virtual sidecar (fsinspect's FUSE AppleDouble mode) can answer
/// `getattr` from [`Self::total_len`] and dispatch any `read(offset, len)`
/// via [`Self::plan_read`] without ever materializing the container. The
/// bytes are guaranteed identical to a `build_appledouble` container of the
/// same parts — the round-trip test pins that equivalence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SidecarLayout {
resource_fork_len: u64,
}
impl SidecarLayout {
/// Layout for a sidecar carrying a `resource_fork_len`-byte resource fork
/// (and always a Finder Info entry, zeroed or not — matching `build`).
///
/// Errors if the fork cannot fit AppleDouble's u32 length field.
pub fn new(resource_fork_len: u64) -> Result<Self, AppleDoubleError> {
u32::try_from(resource_fork_len)
.map_err(|_| AppleDoubleError::ResourceForkTooLarge(resource_fork_len))?;
Ok(Self { resource_fork_len })
}
/// Total container size: prelude + resource fork.
pub fn total_len(&self) -> u64 {
PRELUDE_LEN as u64 + self.resource_fork_len
}
/// Absolute offset where the resource fork's bytes begin.
pub fn resource_fork_offset(&self) -> u64 {
PRELUDE_LEN as u64
}
/// The container's fixed leading bytes: header, entry table, Finder Info.
/// Byte-identical to the first [`PRELUDE_LEN`] bytes of the equivalent
/// [`build_appledouble`] output.
pub fn prelude(&self, finder_info: &[u8; FINDER_INFO_LEN]) -> [u8; PRELUDE_LEN] {
let finfo_off = RAW_HEADER_SIZE + 2 * RAW_ENTRY_SIZE;
let mut out = Cursor::new(Vec::with_capacity(PRELUDE_LEN));
// In-memory writes of validated values cannot fail: the only fallible
// conversion (fork length into u32) was checked in `new`.
out.write_be(&RawHeader {
magic: AD_MAGIC,
version: AD_VERSION,
filler: [0u8; 16],
count: 2,
})
.expect("in-memory write");
out.write_be(&RawEntry {
id: ENTRY_FINDER_INFO,
offset: finfo_off as u32,
length: FINDER_INFO_LEN as u32,
})
.expect("in-memory write");
out.write_be(&RawEntry {
id: ENTRY_RESOURCE_FORK,
offset: PRELUDE_LEN as u32,
length: self.resource_fork_len as u32, // checked in `new`
})
.expect("in-memory write");
out.write_all(finder_info).expect("in-memory write");
out.into_inner()
.try_into()
.expect("prelude is exactly PRELUDE_LEN bytes")
}
/// Split a `read(offset, len)` request into the segments that satisfy it,
/// clamped to the container: zero, one, or two segments (prelude first),
/// in on-disk order. An empty result means EOF (or a zero-length read).
pub fn plan_read(&self, offset: u64, len: u64) -> Vec<ReadSegment> {
let end = offset.saturating_add(len).min(self.total_len());
let mut segments = Vec::new();
if offset >= end {
return segments;
}
let fork_start = PRELUDE_LEN as u64;
if offset < fork_start {
segments.push(ReadSegment::Prelude(
offset as usize..end.min(fork_start) as usize,
));
}
if end > fork_start {
segments.push(ReadSegment::ResourceFork(
offset.max(fork_start) - fork_start..end - fork_start,
));
}
segments
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -748,6 +857,71 @@ mod tests {
assert!(err.to_string().contains("XNU caps names"), "{err}"); assert!(err.to_string().contains("XNU caps names"), "{err}");
} }
/// The layout must describe exactly the container `build_appledouble`
/// emits (no xattrs): same total size, same prelude bytes, fork at the
/// declared offset. This equivalence is what lets the FUSE layer serve
/// range reads without materializing — if it drifts, sidecars synthesized
/// piecewise stop matching ones built whole.
#[test]
fn layout_matches_build() {
let finfo: &[u8; FINDER_INFO_LEN] = FINDER_INFO_PNG.try_into().unwrap();
for fork in [&b""[..], b"R", b"RESOURCEFORKDATA"] {
let built = build_appledouble(Some(fork), Some(finfo), &BTreeMap::new()).unwrap();
let layout = SidecarLayout::new(fork.len() as u64).unwrap();
assert_eq!(layout.total_len(), built.len() as u64);
assert_eq!(layout.prelude(finfo), built[..PRELUDE_LEN]);
assert_eq!(&built[layout.resource_fork_offset() as usize..], fork);
}
}
/// Prelude + fork bytes parse back to the same Finder Info and fork.
#[test]
fn layout_output_parses() {
let finfo: &[u8; FINDER_INFO_LEN] = FINDER_INFO_PNG.try_into().unwrap();
let layout = SidecarLayout::new(3).unwrap();
let mut raw = layout.prelude(finfo).to_vec();
raw.extend_from_slice(b"RES");
let ad = AppleDouble::parse(&raw).unwrap();
assert_eq!(ad.finder_info.as_deref(), Some(FINDER_INFO_PNG));
assert_eq!(ad.resource_fork.as_deref(), Some(&b"RES"[..]));
}
/// Every (offset, len) window reassembled from planned segments must equal
/// the same slice of the built container — including windows straddling
/// the prelude/fork boundary, past EOF, and zero-length.
#[test]
fn plan_read_reassembles_any_window() {
let finfo: &[u8; FINDER_INFO_LEN] = FINDER_INFO_PNG.try_into().unwrap();
let fork = b"RESOURCEFORKDATA";
let built = build_appledouble(Some(fork), Some(finfo), &BTreeMap::new()).unwrap();
let layout = SidecarLayout::new(fork.len() as u64).unwrap();
let prelude = layout.prelude(finfo);
let total = built.len() as u64;
for offset in [0, 1, 50, 81, 82, 83, total - 1, total, total + 10] {
for len in [0u64, 1, 32, 82, total, total + 100] {
let mut got = Vec::new();
for seg in layout.plan_read(offset, len) {
match seg {
ReadSegment::Prelude(r) => got.extend_from_slice(&prelude[r]),
ReadSegment::ResourceFork(r) => {
got.extend_from_slice(&fork[r.start as usize..r.end as usize])
}
}
}
let start = (offset as usize).min(built.len());
let end = (offset.saturating_add(len) as usize).min(built.len());
assert_eq!(got, built[start..end], "offset={offset} len={len}");
}
}
}
#[test]
fn layout_rejects_oversized_fork() {
let err = SidecarLayout::new(u64::from(u32::MAX) + 1).unwrap_err();
assert!(err.to_string().contains("does not fit"), "{err}");
}
#[test] #[test]
fn attr_non_utf8_name_rejected() { fn attr_non_utf8_name_rejected() {
let mut raw = let mut raw =