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:
176
src/lib.rs
176
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<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)]
|
||||
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 =
|
||||
|
||||
Reference in New Issue
Block a user