appledouble: initial commit, extracted from ad-decoder

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 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5
2026-07-19 11:38:23 -05:00
commit c2a6c95f15
5 changed files with 910 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target/

125
Cargo.lock generated Normal file
View File

@@ -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"

10
Cargo.toml Normal file
View File

@@ -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"

17
README.md Normal file
View File

@@ -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<u32, Vec<u8>>` 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

757
src/lib.rs Normal file
View File

@@ -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<u32, Vec<u8>>` 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<SystemTime>,
pub modify: Option<SystemTime>,
pub backup: Option<SystemTime>,
pub access: Option<SystemTime>,
}
/// 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<SystemTime> {
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<Vec<u8>>,
/// Exactly 32 bytes of Finder Info (entry 9), or `None` if absent.
pub finder_info: Option<Vec<u8>>,
/// Extended attributes packed in the ATTR section (name → value).
pub xattrs: BTreeMap<String, Vec<u8>>,
/// Real Name (entry 3): original filename on the home filesystem.
pub real_name: Option<String>,
/// Comment (entry 4): the classic Finder comment.
pub comment: Option<String>,
/// File Dates Info (entry 8).
pub dates: Option<FileDates>,
/// Macintosh File Info flags (entry 10).
pub mac_flags: Option<MacFileFlags>,
/// Every entry's raw bytes, keyed by entry id — the complete decode.
pub entries: BTreeMap<u32, Vec<u8>>,
}
impl AppleDouble {
/// Decode `raw` AppleDouble bytes.
pub fn parse(raw: &[u8]) -> Result<Self, AppleDoubleError> {
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<Vec<u8>> {
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<BTreeMap<String, Vec<u8>>, 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<T: TryFrom<usize>>(value: usize, field: &str) -> binrw::BinResult<T> {
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<String, Vec<u8>>,
) -> binrw::BinResult<Vec<u8>> {
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<u8> = 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(&section);
}
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<String, Vec<u8>>,
) -> binrw::BinResult<Vec<u8>> {
// Each entry is the 11-byte header + NUL-terminated name, padded to 4 bytes.
let built: Vec<(Vec<u8>, usize, &Vec<u8>)> = 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<u8> = 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<String, Vec<u8>> {
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<u8> {
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<u32> = 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::<Vec<_>>(),
),
]);
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}");
}
}