From c2a6c95f1581cfa9fcdbadfa1b71ec82325f1020 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 11:38:23 -0500 Subject: [PATCH] appledouble: initial commit, extracted from ad-decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codec module (src/appledouble.rs) lifted verbatim from ad-decoder's working tree at extraction time — including its uncommitted entry-ID constant additions — with only the crate-level docs rewritten. 17 in-module tests carried over and green. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + Cargo.lock | 125 +++++++++ Cargo.toml | 10 + README.md | 17 ++ src/lib.rs | 757 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 910 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..337880e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,125 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "appledouble" +version = "0.1.0" +dependencies = [ + "binrw", + "thiserror", +] + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "binrw" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53195f985e88ab94d1cc87e80049dd2929fd39e4a772c5ae96a7e5c4aad3642" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5910da05ee556b789032c8ff5a61fb99239580aa3fd0bfaa8f4d094b2aee00ad" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8e77324 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "appledouble" +version = "0.1.0" +edition = "2021" +description = "AppleSingle/AppleDouble container codec: exhaustive parse, lossless build" +license = "MIT" + +[dependencies] +binrw = "0.15" +thiserror = "2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..670a973 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# appledouble + +AppleSingle/AppleDouble container codec: exhaustive parse, lossless build. + +Extracted from [ad-decoder] as a shared crate. Entries decode into a +`BTreeMap>` with typed views over Real Name (3), Comment (4), +File Dates (8), Finder Info (9), and Macintosh File Info (10) — everything +else is preserved as raw bytes, so parse → build round trips are lossless +even for entry types this crate has no opinion about. The macOS `ATTR` +extension (arbitrary xattrs packed after Finder Info) is parsed and rebuilt. + +Consumers: +- **ad-decoder** — assertive (non-obliterative) sidecar folding onto APFS +- **adx** — AS/AD inspection and editing +- **fsinspect** — FUSE-side AppleDouble synthesis for Linux EA recovery + +[ad-decoder]: https://code.movq.us/movq/ad-decoder diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..54247c0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,757 @@ +//! AppleSingle/AppleDouble container codec. +//! +//! Extracted from `ad-decoder` (2026-07-19) as a shared crate: consumed by +//! `ad-decoder` (assertive sidecar folding), `adx` (AS/AD inspection), and +//! `fsinspect` (FUSE AppleDouble synthesis for Linux EA recovery). Entries +//! are decoded exhaustively into a `BTreeMap>` with typed views +//! over the common ones, so a parse -> build round trip is lossless even for +//! entry types this crate has no opinion about. +//! +//! AppleDouble v2 layout: +//! +//! ```text +//! magic u32 0x00051607 +//! version u32 0x00020000 +//! filler 16 bytes +//! count u16 +//! entries count * (id:u32, offset:u32, length:u32) +//! ...entry payloads... +//! ``` +//! +//! The Finder Info entry (id 9) is nominally 32 bytes, but macOS appends an +//! `ATTR` section after it that packs arbitrary extended attributes — this is +//! how `com.apple.*` xattrs survive on filesystems without native EA support. +//! We parse that section so nothing is silently dropped. +//! +//! Both directions go through [`binrw`]: the fixed structural records are +//! `BinRead`/`BinWrite` derives, while the absolute-offset arithmetic and +//! bounds validation are hand-written around them — binrw owns the byte layout, +//! this module owns the meaning. + +use std::collections::BTreeMap; +use std::io::{Cursor, Write}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use binrw::{BinRead, BinReaderExt, BinWrite, BinWriterExt}; + +pub const AS_MAGIC: u32 = 0x0005_1600; +pub const AD_MAGIC: u32 = 0x0005_1607; +pub const AD_VERSION: u32 = 0x0002_0000; +const ATTR_MAGIC: u32 = 0x4154_5452; // "ATTR" + +const ENTRY_DATA_FORK: u32 = 1; +const ENTRY_RESOURCE_FORK: u32 = 2; +const ENTRY_REAL_NAME: u32 = 3; +const ENTRY_COMMENT: u32 = 4; +const ENTRY_ICON_BW: u32 = 5; +const ENTRY_ICON_COLOR: u32 = 6; +const ENTRY_FILE_INFO: u32 = 7; // deprecated in favor of ENTRY_FILE_DATES + ENTRY_MAC_FILE_INFO +const ENTRY_FILE_DATES: u32 = 8; +const ENTRY_FINDER_INFO: u32 = 9; +const ENTRY_MAC_FILE_INFO: u32 = 10; +const ENTRY_PRODOS_FILE_INFO: u32 = 11; // deprecated +const ENTRY_MSDOS_FILE_INFO: u32 = 12; // deprecated +const ENTRY_AFP_SHORT_NAME: u32 = 13; // deprecated in favor of ENTRY_REAL_NAME +const ENTRY_AFP_FILE_INFO: u32 = 14; // deprecated +const ENTRY_AFP_DIRECTORY_ID: u32 = 15; // deprecated + +/// Seconds between the Unix epoch and the Mac (2000-01-01 00:00 UTC) epoch. +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; +/// XNU caps extended-attribute names at 128 bytes (incl. NUL). +const MAX_XATTR_NAME: usize = 128; + +const RAW_HEADER_SIZE: usize = 26; +const RAW_ENTRY_SIZE: usize = 12; +const ATTR_HEADER_SIZE: usize = 36; +const ATTR_ENTRY_SIZE: usize = 11; + +/// The byte stream is not a well-formed AppleDouble file. +/// +/// Every malformation is a contained error rather than a panic or a silent +/// truncation: at 250k-file scale some sidecars are damaged, and a bad one must +/// be skippable — never able to corrupt a restored value. +#[derive(Debug, thiserror::Error)] +pub enum AppleDoubleError { + #[error("too short to be AppleDouble")] + TooShort, + #[error("bad magic {found:#010x}, expected {expected:#010x}")] + BadMagic { found: u32, expected: u32 }, + #[error("unsupported version {found:#010x}, expected {expected:#010x}")] + BadVersion { found: u32, expected: u32 }, + #[error("entry table truncated")] + EntryTableTruncated, + #[error("entry {0} payload out of range")] + PayloadOutOfRange(u32), + #[error("ATTR header truncated")] + AttrHeaderTruncated, + #[error("ATTR entry table truncated")] + AttrEntryTableTruncated, + #[error("implausible xattr name length {0}")] + ImplausibleNameLength(u8), + #[error("ATTR name runs past section")] + AttrNameRunsPast, + #[error("ATTR data area out of range")] + AttrDataOutOfRange, + #[error("non-UTF-8 xattr name")] + NonUtf8Name(#[source] std::str::Utf8Error), + #[error("ATTR value for {name:?} out of range")] + AttrValueOutOfRange { name: String }, + #[error("malformed AppleDouble structure")] + Malformed(#[from] binrw::Error), +} + +#[derive(BinRead, BinWrite)] +struct RawHeader { + magic: u32, + version: u32, + filler: [u8; 16], + count: u16, +} + +#[derive(BinRead, BinWrite)] +struct RawEntry { + id: u32, + offset: u32, + length: u32, +} + +#[derive(BinRead, BinWrite)] +struct AttrHeader { + magic: u32, + debug_tag: u32, + total_size: u32, + data_start: u32, + data_length: u32, + reserved: [u32; 3], + flags: u16, + num_attrs: u16, +} + +#[derive(BinRead, BinWrite)] +struct AttrEntryHeader { + value_offset: u32, + value_length: u32, + flags: u16, + namelen: u8, +} + +/// File Dates Info (entry id 8): four dates as wall-clock instants. +/// +/// Each is `None` when the sidecar stored the [`DATE_UNKNOWN`] sentinel (or a +/// zero that the format conventionally uses for "no info"). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct FileDates { + pub create: Option, + pub modify: Option, + pub backup: Option, + pub access: Option, +} + +/// Macintosh File Info (entry id 10) attribute flags. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct MacFileFlags { + pub locked: bool, + pub protected: bool, +} + +/// Convert RFC 1740 Mac seconds (signed, relative to 2000-01-01 UTC) to a +/// wall-clock time, or `None` for the unknown sentinel / zero. +fn date_from_mac_secs(secs: i32) -> Option { + if secs == DATE_UNKNOWN || secs == 0 { + return None; + } + // Always fits i64; offset either side of the Unix epoch without underflow. + let unix = MAC_EPOCH_OFFSET as i64 + secs as i64; + Some(if unix >= 0 { + UNIX_EPOCH + Duration::from_secs(unix as u64) + } else { + UNIX_EPOCH - Duration::from_secs(unix.unsigned_abs()) + }) +} + +/// Decoded contents of an AppleDouble sidecar. +/// +/// Parsing is exhaustive: every entry's raw bytes are kept in [`Self::entries`] +/// so nothing in a sidecar is invisible. The other fields are typed views over +/// the well-specified entries, derived during [`Self::parse`]. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct AppleDouble { + /// Raw resource-fork bytes (entry 2), or `None` if absent/empty. + pub resource_fork: Option>, + /// Exactly 32 bytes of Finder Info (entry 9), or `None` if absent. + pub finder_info: Option>, + /// Extended attributes packed in the ATTR section (name → value). + pub xattrs: BTreeMap>, + /// Real Name (entry 3): original filename on the home filesystem. + pub real_name: Option, + /// Comment (entry 4): the classic Finder comment. + pub comment: Option, + /// File Dates Info (entry 8). + pub dates: Option, + /// Macintosh File Info flags (entry 10). + pub mac_flags: Option, + /// Every entry's raw bytes, keyed by entry id — the complete decode. + pub entries: BTreeMap>, +} + +impl AppleDouble { + /// Decode `raw` AppleDouble bytes. + pub fn parse(raw: &[u8]) -> Result { + if raw.len() < RAW_HEADER_SIZE { + return Err(AppleDoubleError::TooShort); + } + let mut cur = Cursor::new(raw); + let header: RawHeader = cur.read_be()?; + if header.magic != AD_MAGIC { + return Err(AppleDoubleError::BadMagic { + found: header.magic, + expected: AD_MAGIC, + }); + } + // v1 shares the magic but lays entries out differently; parsing it as v2 + // would silently misread offsets. netatalk and macOS both emit v2. + if header.version != AD_VERSION { + return Err(AppleDoubleError::BadVersion { + found: header.version, + expected: AD_VERSION, + }); + } + + let mut ad = Self::default(); + for _ in 0..header.count { + if cur.position() as usize + RAW_ENTRY_SIZE > raw.len() { + return Err(AppleDoubleError::EntryTableTruncated); + } + let entry: RawEntry = cur.read_be()?; + let start = entry.offset as usize; + let len = entry.length as usize; + let end = start + .checked_add(len) + .filter(|&end| end <= raw.len()) + .ok_or(AppleDoubleError::PayloadOutOfRange(entry.id))?; + + let payload = &raw[start..end]; + match entry.id { + ENTRY_RESOURCE_FORK if len > 0 => { + ad.resource_fork = Some(payload.to_vec()); + } + ENTRY_FINDER_INFO if len >= FINDER_INFO_LEN => { + ad.finder_info = Some(payload[..FINDER_INFO_LEN].to_vec()); + if len > FINDER_INFO_LEN { + ad.xattrs = parse_attr_section(raw, start, len)?; + } + } + // Classic name/comment are Mac Roman; lossy UTF-8 decoding may + // garble non-ASCII bytes. These are report-only, never applied. + ENTRY_REAL_NAME if len > 0 => { + ad.real_name = Some(String::from_utf8_lossy(payload).into_owned()); + } + ENTRY_COMMENT if len > 0 => { + ad.comment = Some(String::from_utf8_lossy(payload).into_owned()); + } + ENTRY_FILE_DATES if len >= 16 => { + let date = |i: usize| { + let mut bytes = [0u8; 4]; + bytes.copy_from_slice(&payload[i..i + 4]); + date_from_mac_secs(i32::from_be_bytes(bytes)) + }; + ad.dates = Some(FileDates { + create: date(0), + modify: date(4), + backup: date(8), + access: date(12), + }); + } + ENTRY_MAC_FILE_INFO if len >= 4 => { + let attr = payload[3]; // 3 filler bytes precede the attribute byte + ad.mac_flags = Some(MacFileFlags { + locked: attr & 0x01 != 0, + protected: attr & 0x02 != 0, + }); + } + _ => {} + } + // Keep every entry's raw bytes — the complete, future-proof decode. + ad.entries.insert(entry.id, payload.to_vec()); + } + Ok(ad) + } + + /// Re-encode to AppleDouble bytes (round-trips with [`Self::parse`]). + pub fn build(&self) -> binrw::BinResult> { + build_appledouble( + self.resource_fork.as_deref(), + self.finder_info.as_deref(), + &self.xattrs, + ) + } +} + +/// Parse the ATTR section that follows the 32-byte Finder Info. +fn parse_attr_section( + raw: &[u8], + finfo_off: usize, + finfo_len: usize, +) -> Result>, AppleDoubleError> { + // The ATTR header normally follows 2 pad bytes; tolerate writers that omit them. + let pos = [2usize, 0] + .into_iter() + .map(|pad| finfo_off + FINDER_INFO_LEN + pad) + .find(|&p| { + raw.get(p..p + 4) + .and_then(|b| b.try_into().ok()) + .is_some_and(|b| u32::from_be_bytes(b) == ATTR_MAGIC) + }); + let Some(pos) = pos else { + return Ok(BTreeMap::new()); + }; + + let section_end = finfo_off + finfo_len; + if pos + ATTR_HEADER_SIZE > section_end { + return Err(AppleDoubleError::AttrHeaderTruncated); + } + let mut cur = Cursor::new(raw); + cur.set_position(pos as u64); + let attr_header: AttrHeader = cur.read_be()?; + if attr_header.num_attrs == 0 { + return Ok(BTreeMap::new()); + } + + // Values live in the data area the header declares (`data_start` + + // `data_length`), which XNU places *after* the entry/name table. Bounding + // values to this region — rather than to the whole ATTR section — keeps a + // malformed offset from aliasing the header or an entry/name and restoring + // those bytes as a value. `data_start` is an absolute file offset, like the + // per-entry value offsets. + let table_start = pos + ATTR_HEADER_SIZE; + let data_start = attr_header.data_start as usize; + let data_end = data_start + .checked_add(attr_header.data_length as usize) + .filter(|&end| data_start >= table_start && end <= section_end) + .ok_or(AppleDoubleError::AttrDataOutOfRange)?; + + let mut xattrs = BTreeMap::new(); + let mut entry_pos = table_start; + for _ in 0..attr_header.num_attrs { + if entry_pos + ATTR_ENTRY_SIZE > section_end { + return Err(AppleDoubleError::AttrEntryTableTruncated); + } + cur.set_position(entry_pos as u64); + let entry: AttrEntryHeader = cur.read_be()?; + entry_pos += ATTR_ENTRY_SIZE; + + let namelen = entry.namelen as usize; + if namelen == 0 || namelen > MAX_XATTR_NAME { + return Err(AppleDoubleError::ImplausibleNameLength(entry.namelen)); + } + if entry_pos + namelen > section_end { + return Err(AppleDoubleError::AttrNameRunsPast); + } + let raw_name = &raw[entry_pos..entry_pos + namelen]; + let raw_name = raw_name.split(|&b| b == 0).next().unwrap_or(raw_name); + let name = std::str::from_utf8(raw_name) + .map_err(AppleDoubleError::NonUtf8Name)? + .to_owned(); + entry_pos = (entry_pos + namelen + 3) & !3; // entries are 4-byte aligned + + let value_off = entry.value_offset as usize; + let value_end = value_off + .checked_add(entry.value_length as usize) + .filter(|&end| value_off >= data_start && end <= data_end) + .ok_or_else(|| AppleDoubleError::AttrValueOutOfRange { name: name.clone() })?; + xattrs.insert(name, raw[value_off..value_end].to_vec()); + } + // The data area must begin at or after the end of the entry/name table; an + // overlap would let a value (>= data_start) alias an entry header or name. + if data_start < entry_pos { + return Err(AppleDoubleError::AttrDataOutOfRange); + } + Ok(xattrs) +} + +/// Narrow a computed size into one of AppleDouble's fixed-width offset/length +/// fields, surfacing an error instead of silently truncating on overflow. +fn fit>(value: usize, field: &str) -> binrw::BinResult { + T::try_from(value).map_err(|_| binrw::Error::Custom { + pos: 0, + err: Box::new(format!( + "{field} ({value} bytes) does not fit its AppleDouble field" + )), + }) +} + +/// Encode an AppleDouble sidecar from its parts. +/// +/// Always emits a Finder Info entry (id 9) followed by a Resource Fork entry +/// (id 2), matching how macOS lays out `._*` files. +pub fn build_appledouble( + resource_fork: Option<&[u8]>, + finder_info: Option<&[u8]>, + xattrs: &BTreeMap>, +) -> binrw::BinResult> { + let resource = resource_fork.unwrap_or_default(); + + let mut finfo = [0u8; FINDER_INFO_LEN]; + if let Some(src) = finder_info { + let n = src.len().min(FINDER_INFO_LEN); + finfo[..n].copy_from_slice(&src[..n]); + } + + let finfo_off = RAW_HEADER_SIZE + 2 * RAW_ENTRY_SIZE; + let mut finfo_region: Vec = finfo.to_vec(); + if !xattrs.is_empty() { + finfo_region.extend_from_slice(&[0, 0]); // pad before ATTR header + let section = encode_attr_section(finfo_off + finfo_region.len(), xattrs)?; + finfo_region.extend_from_slice(§ion); + } + let resource_off = finfo_off + finfo_region.len(); + + let mut out = Cursor::new(Vec::new()); + out.write_be(&RawHeader { + magic: AD_MAGIC, + version: AD_VERSION, + filler: [0u8; 16], + count: 2, + })?; + out.write_be(&RawEntry { + id: ENTRY_FINDER_INFO, + offset: fit(finfo_off, "finder info offset")?, + length: fit(finfo_region.len(), "finder info length")?, + })?; + out.write_be(&RawEntry { + id: ENTRY_RESOURCE_FORK, + offset: fit(resource_off, "resource fork offset")?, + length: fit(resource.len(), "resource fork length")?, + })?; + out.write_all(&finfo_region)?; + out.write_all(resource)?; + Ok(out.into_inner()) +} + +/// Encode an ATTR section whose value offsets are absolute file positions. +fn encode_attr_section( + base_off: usize, + xattrs: &BTreeMap>, +) -> binrw::BinResult> { + // Each entry is the 11-byte header + NUL-terminated name, padded to 4 bytes. + let built: Vec<(Vec, usize, &Vec)> = xattrs + .iter() + .map(|(name, value)| { + let mut name_bytes = name.as_bytes().to_vec(); + name_bytes.push(0); + let padded_entry = (ATTR_ENTRY_SIZE + name_bytes.len() + 3) & !3; + (name_bytes, padded_entry, value) + }) + .collect(); + + let entries_len: usize = built.iter().map(|(_, padded, _)| padded).sum(); + let values_start = base_off + ATTR_HEADER_SIZE + entries_len; + let values_len: usize = built.iter().map(|(_, _, value)| value.len()).sum(); + + let mut entries = Cursor::new(Vec::with_capacity(entries_len)); + let mut values: Vec = Vec::with_capacity(values_len); + let mut value_cursor = values_start; + for (name_bytes, padded_entry, value) in &built { + // `namelen` is a u8 and XNU caps names (incl. NUL) at MAX_XATTR_NAME, so a + // longer name is a caller bug; reject it rather than truncate the field. + if name_bytes.len() > MAX_XATTR_NAME { + return Err(binrw::Error::Custom { + pos: 0, + err: Box::new(format!( + "xattr name is {} bytes; XNU caps names (incl. NUL) at {MAX_XATTR_NAME}", + name_bytes.len() + )), + }); + } + entries.write_be(&AttrEntryHeader { + value_offset: fit(value_cursor, "xattr value offset")?, + value_length: fit(value.len(), "xattr value length")?, + flags: 0, + namelen: name_bytes.len() as u8, // guarded above: ≤ MAX_XATTR_NAME ≤ u8::MAX + })?; + entries.write_all(name_bytes)?; + let written = ATTR_ENTRY_SIZE + name_bytes.len(); + entries.write_all(&vec![0u8; padded_entry - written])?; + values.extend_from_slice(value); + value_cursor += value.len(); + } + + let mut out = Cursor::new(Vec::new()); + out.write_be(&AttrHeader { + magic: ATTR_MAGIC, + debug_tag: 0, + total_size: fit( + ATTR_HEADER_SIZE + entries_len + values_len, + "ATTR total size", + )?, + data_start: fit(values_start, "ATTR data start")?, + data_length: fit(values_len, "ATTR data length")?, + reserved: [0; 3], + flags: 0, + num_attrs: fit(xattrs.len(), "ATTR attribute count")?, + })?; + out.write_all(&entries.into_inner())?; + out.write_all(&values)?; + Ok(out.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FINDER_INFO_PNG: &[u8] = b"PNGf8BIM\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; + + fn xattrs(pairs: &[(&str, &[u8])]) -> BTreeMap> { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_vec())) + .collect() + } + + /// Hand-assemble an AppleDouble blob from `(id, payload)` entries so tests can + /// exercise entry types that `build` (deliberately 9+2 only) never emits. + fn assemble(entries: &[(u32, &[u8])]) -> Vec { + let header_size = RAW_HEADER_SIZE + entries.len() * RAW_ENTRY_SIZE; + let mut table = Vec::new(); + let mut payloads = Vec::new(); + let mut off = header_size; + for (id, payload) in entries { + table.extend_from_slice(&id.to_be_bytes()); + table.extend_from_slice(&(off as u32).to_be_bytes()); + table.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + payloads.extend_from_slice(payload); + off += payload.len(); + } + let mut out = Vec::new(); + out.extend_from_slice(&AD_MAGIC.to_be_bytes()); + out.extend_from_slice(&AD_VERSION.to_be_bytes()); + out.extend_from_slice(&[0u8; 16]); // filler + out.extend_from_slice(&(entries.len() as u16).to_be_bytes()); + out.extend_from_slice(&table); + out.extend_from_slice(&payloads); + out + } + + #[test] + fn decodes_all_entry_types() { + // create = 1 day after the Mac epoch (2000-01-02 00:00 UTC). + let mut dates = Vec::new(); + dates.extend_from_slice(&86_400i32.to_be_bytes()); // create + dates.extend_from_slice(&172_800i32.to_be_bytes()); // modify + dates.extend_from_slice(&DATE_UNKNOWN.to_be_bytes()); // backup → None + dates.extend_from_slice(&0i32.to_be_bytes()); // access → None + let mac_info = [0u8, 0, 0, 0x03]; // locked + protected + + let raw = assemble(&[ + (ENTRY_REAL_NAME, b"Gorgeous Photo.png"), + (ENTRY_COMMENT, b"shot on a Mac"), + (ENTRY_FILE_DATES, &dates), + (ENTRY_FINDER_INFO, FINDER_INFO_PNG), + (ENTRY_MAC_FILE_INFO, &mac_info), + (ENTRY_RESOURCE_FORK, b"RES"), + (7, b"unknown-entry"), // unrecognized id is still captured raw + ]); + let ad = AppleDouble::parse(&raw).unwrap(); + + assert_eq!(ad.real_name.as_deref(), Some("Gorgeous Photo.png")); + assert_eq!(ad.comment.as_deref(), Some("shot on a Mac")); + assert_eq!(ad.resource_fork.as_deref(), Some(&b"RES"[..])); + let d = ad.dates.unwrap(); + assert_eq!( + d.create, + Some(UNIX_EPOCH + Duration::from_secs(MAC_EPOCH_OFFSET + 86_400)) + ); + assert!(d.backup.is_none() && d.access.is_none()); + assert_eq!( + ad.mac_flags, + Some(MacFileFlags { + locked: true, + protected: true + }) + ); + // The complete decode: every id present, including the unrecognized one. + let ids: Vec = ad.entries.keys().copied().collect(); + assert_eq!(ids, [2, 3, 4, 7, 8, 9, 10]); + } + + #[test] + fn date_from_mac_secs_handles_epoch_sentinel_and_negative() { + assert_eq!(date_from_mac_secs(DATE_UNKNOWN), None); + assert_eq!(date_from_mac_secs(0), None); + assert_eq!( + date_from_mac_secs(1), + Some(UNIX_EPOCH + Duration::from_secs(MAC_EPOCH_OFFSET + 1)) + ); + // One year before the Mac epoch is still well after 1970. + assert_eq!( + date_from_mac_secs(-31_536_000), + Some(UNIX_EPOCH + Duration::from_secs(MAC_EPOCH_OFFSET - 31_536_000)) + ); + } + + #[test] + fn round_trip_resource_only() { + let raw = build_appledouble(Some(b"RESOURCEFORKDATA"), None, &BTreeMap::new()).unwrap(); + let ad = AppleDouble::parse(&raw).unwrap(); + assert_eq!(ad.resource_fork.as_deref(), Some(&b"RESOURCEFORKDATA"[..])); + assert!(ad + .finder_info + .as_deref() + .is_none_or(|fi| fi.iter().all(|&b| b == 0))); + assert!(ad.xattrs.is_empty()); + } + + #[test] + fn round_trip_finder_info() { + let raw = build_appledouble(None, Some(FINDER_INFO_PNG), &BTreeMap::new()).unwrap(); + let ad = AppleDouble::parse(&raw).unwrap(); + assert_eq!(ad.finder_info.as_deref(), Some(FINDER_INFO_PNG)); + } + + #[test] + fn round_trip_packed_xattrs() { + let attrs = xattrs(&[ + ("com.apple.serverdocs.markup", b"bplist00\xde\xad\xbe\xef"), + ( + "com.apple.metadata:kMDItemWhereFroms", + &(0u8..40).collect::>(), + ), + ]); + let raw = build_appledouble(Some(b"RF"), Some(FINDER_INFO_PNG), &attrs).unwrap(); + let ad = AppleDouble::parse(&raw).unwrap(); + assert_eq!(ad.resource_fork.as_deref(), Some(&b"RF"[..])); + assert_eq!(ad.finder_info.as_deref(), Some(FINDER_INFO_PNG)); + assert_eq!(ad.xattrs, attrs); + } + + #[test] + fn round_trip_via_struct_build() { + // `build` only emits entries 9 + 2, so compare the fields it round-trips + // (parse additionally populates `entries`, which build does not consume). + let original = AppleDouble { + resource_fork: Some(b"abc".to_vec()), + finder_info: Some(FINDER_INFO_PNG.to_vec()), + xattrs: xattrs(&[("a", b"1"), ("b", b"22")]), + ..Default::default() + }; + let parsed = AppleDouble::parse(&original.build().unwrap()).unwrap(); + assert_eq!(parsed.resource_fork, original.resource_fork); + assert_eq!(parsed.finder_info, original.finder_info); + assert_eq!(parsed.xattrs, original.xattrs); + } + + #[test] + fn bad_magic_rejected() { + let junk = build_appledouble(Some(b"x"), None, &BTreeMap::new()).unwrap(); + let mut junk = junk; + junk[0..4].copy_from_slice(&0xDEADBEEFu32.to_be_bytes()); + let err = AppleDouble::parse(&junk).unwrap_err(); + assert!(err.to_string().contains("bad magic"), "{err}"); + } + + #[test] + fn too_short_rejected() { + let err = AppleDouble::parse(b"\x00\x05\x16").unwrap_err(); + assert!(err.to_string().contains("too short"), "{err}"); + } + + #[test] + fn truncated_payload_rejected() { + let raw = + build_appledouble(Some(b"RESOURCE"), Some(FINDER_INFO_PNG), &BTreeMap::new()).unwrap(); + let truncated = &raw[..raw.len() - 4]; + assert!(AppleDouble::parse(truncated).is_err()); + } + + #[test] + fn real_magic_constant() { + let raw = build_appledouble(Some(b"x"), None, &BTreeMap::new()).unwrap(); + assert_eq!(u32::from_be_bytes(raw[0..4].try_into().unwrap()), AD_MAGIC); + } + + fn attr_entry0_offset(raw: &[u8]) -> usize { + raw.windows(4).position(|w| w == b"ATTR").unwrap() + ATTR_HEADER_SIZE + } + + #[test] + fn attr_implausible_name_length_rejected() { + let mut raw = + build_appledouble(Some(b"RF"), Some(FINDER_INFO_PNG), &xattrs(&[("ab", b"V")])) + .unwrap(); + let off = attr_entry0_offset(&raw) + 10; + raw[off] = 200; // namelen byte > 128 + let err = AppleDouble::parse(&raw).unwrap_err(); + assert!(err.to_string().contains("name length"), "{err}"); + } + + #[test] + fn attr_value_offset_out_of_range_rejected() { + let mut raw = + build_appledouble(Some(b"RF"), Some(FINDER_INFO_PNG), &xattrs(&[("ab", b"V")])) + .unwrap(); + let off = attr_entry0_offset(&raw); + raw[off..off + 4].copy_from_slice(&0xFFFFFFF0u32.to_be_bytes()); + let err = AppleDouble::parse(&raw).unwrap_err(); + assert!(err.to_string().contains("out of range"), "{err}"); + } + + #[test] + fn bad_version_rejected() { + let mut raw = build_appledouble(Some(b"x"), None, &BTreeMap::new()).unwrap(); + raw[4..8].copy_from_slice(&0x0001_0000u32.to_be_bytes()); // v1 magic-mate + let err = AppleDouble::parse(&raw).unwrap_err(); + assert!(err.to_string().contains("unsupported version"), "{err}"); + } + + #[test] + fn attr_value_inside_blob_outside_section_rejected() { + // Point the value at offset 0 (the AppleDouble header): inside the blob, + // but before the ATTR section — must be rejected, not restored as bytes. + let mut raw = + build_appledouble(Some(b"RF"), Some(FINDER_INFO_PNG), &xattrs(&[("ab", b"V")])) + .unwrap(); + let off = attr_entry0_offset(&raw); + raw[off..off + 4].copy_from_slice(&0u32.to_be_bytes()); // value_offset → header + raw[off + 4..off + 8].copy_from_slice(&4u32.to_be_bytes()); // value_length + let err = AppleDouble::parse(&raw).unwrap_err(); + assert!(err.to_string().contains("out of range"), "{err}"); + } + + #[test] + fn attr_value_into_entry_table_rejected() { + // A value pointing inside the entry/name table (between the header and the + // data area) is bounded out: data-area validation, not just section bounds. + let mut raw = + build_appledouble(Some(b"RF"), Some(FINDER_INFO_PNG), &xattrs(&[("ab", b"V")])) + .unwrap(); + let off = attr_entry0_offset(&raw); // == table_start: the first entry header + let into_table = (off as u32).to_be_bytes(); + raw[off..off + 4].copy_from_slice(&into_table); // value_offset → entry table + raw[off + 4..off + 8].copy_from_slice(&4u32.to_be_bytes()); // value_length + let err = AppleDouble::parse(&raw).unwrap_err(); + assert!(err.to_string().contains("out of range"), "{err}"); + } + + #[test] + fn build_rejects_oversized_xattr_name() { + let long = "a".repeat(MAX_XATTR_NAME); // + NUL pushes it over the cap + let err = + build_appledouble(None, Some(FINDER_INFO_PNG), &xattrs(&[(&long, b"V")])).unwrap_err(); + assert!(err.to_string().contains("XNU caps names"), "{err}"); + } + + #[test] + fn attr_non_utf8_name_rejected() { + let mut raw = + build_appledouble(Some(b"RF"), Some(FINDER_INFO_PNG), &xattrs(&[("ab", b"V")])) + .unwrap(); + let off = attr_entry0_offset(&raw) + 11; + raw[off] = 0xFF; // first byte of the name + let err = AppleDouble::parse(&raw).unwrap_err(); + assert!(err.to_string().contains("non-UTF-8"), "{err}"); + } +}