From 5313b1935d499172a7db817bde9e638306a3cd9a Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 11:51:33 -0500 Subject: [PATCH] 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 --- src/lib.rs | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 175 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 6a305e9..9b3acc2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,7 +64,8 @@ const MAC_EPOCH_OFFSET: u64 = 946_684_800; /// RFC 1740 sentinel meaning "date unknown". 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). const MAX_XATTR_NAME: usize = 128; @@ -106,6 +107,8 @@ pub enum AppleDoubleError { AttrValueOutOfRange { name: String }, #[error("malformed AppleDouble structure")] Malformed(#[from] binrw::Error), + #[error("resource fork ({0} bytes) does not fit AppleDouble's u32 length field")] + ResourceForkTooLarge(u64), } #[derive(BinRead, BinWrite)] @@ -503,6 +506,112 @@ fn encode_attr_section( 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), + /// Read this byte range of the resource fork itself. + ResourceFork(std::ops::Range), +} + +/// 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 { + 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 { + 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)] mod tests { use super::*; @@ -748,6 +857,71 @@ mod tests { 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] fn attr_non_utf8_name_rejected() { let mut raw =