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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
125
Cargo.lock
generated
Normal file
125
Cargo.lock
generated
Normal 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
10
Cargo.toml
Normal 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
17
README.md
Normal 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
757
src/lib.rs
Normal 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(§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<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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user