apfs_core/xattr.rs
1//! Extended attributes (`APFS_TYPE_XATTR 4`, value `j_xattr_val_t`) and symlink
2//! targets.
3//!
4//! An xattr key is `j_xattr_key_t { j_key; u16 name_len; u8 name[] }` (the
5//! `name_len` includes the trailing NUL). The value is `j_xattr_val_t { u16
6//! flags; u16 xdata_len; u8 xdata[] }` (Apple *APFS Reference*). The `flags`
7//! word selects where the data lives:
8//!
9//! - **`XATTR_DATA_EMBEDDED` (0x0002)** — `xdata` holds the attribute value
10//! inline.
11//! - **`XATTR_DATA_STREAM` (0x0001)** — `xdata` holds a `j_xattr_dstream_t`
12//! (`u64 xattr_obj_id` = the dstream id, then a `j_dstream_t { u64 size; … }`);
13//! the value bytes are the extents keyed by that dstream id.
14//!
15//! Forensically important named xattrs:
16//! - `com.apple.decmpfs` — transparent-compression header ([`crate::compression`]).
17//! - `com.apple.ResourceFork` — resource fork (non-embedded compressed payload),
18//! stored as a stream xattr.
19//! - `com.apple.fs.symlink` — a symlink's target path, stored **embedded** in the
20//! xattr value (verified against the real fixture: flags `0x6` =
21//! `EMBEDDED | 0x4`, value `"Dir1/Beth.txt\0"`).
22
23use std::io::{Read, Seek};
24
25use crate::dir::for_each_fs_record_for_oid;
26use crate::fsrecord::{decode_jkey, RecordType};
27use crate::volume::ApfsVolume;
28
29/// `XATTR_DATA_STREAM` flag (the value is in a referenced data stream).
30pub const XATTR_DATA_STREAM: u16 = 0x0001;
31/// `XATTR_DATA_EMBEDDED` flag (the value is inline in the record).
32pub const XATTR_DATA_EMBEDDED: u16 = 0x0002;
33
34/// The `com.apple.decmpfs` xattr name (transparent compression header).
35pub const XATTR_NAME_DECMPFS: &str = "com.apple.decmpfs";
36/// The `com.apple.ResourceFork` xattr name (resource fork / compressed payload).
37pub const XATTR_NAME_RESOURCE_FORK: &str = "com.apple.ResourceFork";
38/// The `com.apple.fs.symlink` xattr name (symlink target path).
39pub const XATTR_NAME_SYMLINK: &str = "com.apple.fs.symlink";
40
41// j_xattr_key_t: name_len u16 follows the 8-byte j_key; name follows at @10.
42const OFF_XATTR_KEY_NAME_LEN: usize = 8;
43const OFF_XATTR_KEY_NAME: usize = 10;
44// j_xattr_val_t: flags u16 @0, xdata_len u16 @2, xdata @4.
45const OFF_XATTR_VAL_FLAGS: usize = 0;
46const OFF_XATTR_VAL_XDATA_LEN: usize = 2;
47const OFF_XATTR_VAL_XDATA: usize = 4;
48// j_xattr_dstream_t (the embedded value of a XATTR_DATA_STREAM xattr):
49// u64 xattr_obj_id; then j_dstream_t { u64 size; … }.
50const OFF_XDSTREAM_OBJ_ID: usize = 0;
51const OFF_XDSTREAM_SIZE: usize = 8;
52
53/// Where an xattr's value lives.
54#[derive(Debug, Clone)]
55#[non_exhaustive]
56pub enum XattrValue {
57 /// The value is stored inline in the record.
58 Embedded(Vec<u8>),
59 /// The value is stored in a data stream: `(dstream_oid, logical_size)`. The
60 /// bytes are the `FILE_EXTENT` records keyed by `dstream_oid`
61 /// ([`crate::extent::read_stream`]).
62 Stream { dstream_oid: u64, size: u64 },
63}
64
65/// A parsed extended attribute.
66#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub struct Xattr {
69 /// The attribute name (e.g. `com.apple.decmpfs`).
70 pub name: String,
71 /// The raw `j_xattr_val_t.flags` word.
72 pub flags: u16,
73 /// The attribute value (embedded bytes or a stream reference).
74 pub value: XattrValue,
75}
76
77/// Decode a single `XATTR` (key, value) record, or `None` if the key has no
78/// decodable name (a malformed/hostile record is skipped, never panics).
79fn parse_xattr(key: &[u8], value: &[u8]) -> Option<Xattr> {
80 let name_len = crate::bytes::le_u16(key, OFF_XATTR_KEY_NAME_LEN) as usize;
81 if name_len == 0 {
82 return None;
83 }
84 let name_bytes = key.get(OFF_XATTR_KEY_NAME..OFF_XATTR_KEY_NAME + name_len)?;
85 let name = decode_cstr(name_bytes);
86
87 let flags = crate::bytes::le_u16(value, OFF_XATTR_VAL_FLAGS);
88 let xdata_len = crate::bytes::le_u16(value, OFF_XATTR_VAL_XDATA_LEN) as usize;
89 let xdata = value
90 .get(OFF_XATTR_VAL_XDATA..OFF_XATTR_VAL_XDATA + xdata_len)
91 .unwrap_or(&[]);
92
93 let xvalue = if flags & XATTR_DATA_STREAM != 0 {
94 XattrValue::Stream {
95 dstream_oid: crate::bytes::le_u64(xdata, OFF_XDSTREAM_OBJ_ID),
96 size: crate::bytes::le_u64(xdata, OFF_XDSTREAM_SIZE),
97 }
98 } else {
99 XattrValue::Embedded(xdata.to_vec())
100 };
101 Some(Xattr {
102 name,
103 flags,
104 value: xvalue,
105 })
106}
107
108/// List all extended attributes of the inode `inode_oid`, scanning the volume
109/// fs-tree for `XATTR` records whose key oid is the inode.
110///
111/// # Errors
112/// [`crate::ApfsError::OmapUnresolved`] / [`crate::ApfsError::ChecksumMismatch`]
113/// / [`crate::ApfsError::CycleGuard`] / [`crate::ApfsError::Io`] on a
114/// structurally invalid omap/fs-tree or a read failure.
115pub fn list_xattrs<R: Read + Seek>(
116 reader: &mut R,
117 volume: &ApfsVolume,
118 inode_oid: u64,
119 block_size: usize,
120) -> crate::Result<Vec<Xattr>> {
121 let mut out = Vec::new();
122 for_each_fs_record_for_oid(reader, volume, block_size, inode_oid, &mut |key, value| {
123 let (oid, ty) = decode_jkey(crate::bytes::le_u64(key, 0));
124 if ty != Some(RecordType::Xattr) || oid != inode_oid {
125 return;
126 }
127 if let Some(x) = parse_xattr(key, value) {
128 out.push(x);
129 }
130 })?;
131 Ok(out)
132}
133
134/// Fetch a named xattr's value for an inode, or `None` if absent.
135///
136/// # Errors
137/// As [`list_xattrs`].
138pub fn get_xattr<R: Read + Seek>(
139 reader: &mut R,
140 volume: &ApfsVolume,
141 inode_oid: u64,
142 name: &str,
143 block_size: usize,
144) -> crate::Result<Option<XattrValue>> {
145 let xattrs = list_xattrs(reader, volume, inode_oid, block_size)?;
146 Ok(xattrs.into_iter().find(|x| x.name == name).map(|x| x.value))
147}
148
149/// Return the raw `com.apple.decmpfs` header bytes for an inode, if it is a
150/// transparently-compressed file. The decmpfs header is always embedded in the
151/// xattr (the bulk payload, when large, lives in the resource fork — see
152/// [`resource_fork`]).
153///
154/// # Errors
155/// As [`list_xattrs`].
156pub fn decmpfs_header<R: Read + Seek>(
157 reader: &mut R,
158 volume: &ApfsVolume,
159 inode_oid: u64,
160 block_size: usize,
161) -> crate::Result<Option<Vec<u8>>> {
162 match get_xattr(reader, volume, inode_oid, XATTR_NAME_DECMPFS, block_size)? {
163 // The decmpfs xattr is embedded; an unexpected stream form yields its
164 // (empty) embedded bytes, which the decoder then rejects loudly.
165 Some(XattrValue::Embedded(bytes)) => Ok(Some(bytes)),
166 Some(XattrValue::Stream { .. }) | None => Ok(None),
167 }
168}
169
170/// Read the inode's `com.apple.ResourceFork` value as bytes, if present.
171///
172/// The resource fork holds a non-embedded decmpfs payload. It is normally a
173/// stream xattr (`XATTR_DATA_STREAM`) whose dstream is read through the extent
174/// machinery; a small fork may be embedded.
175///
176/// # Errors
177/// As [`list_xattrs`], plus the structural errors of
178/// [`crate::extent::read_stream`] when the fork is stream-backed.
179pub fn resource_fork<R: Read + Seek>(
180 reader: &mut R,
181 volume: &ApfsVolume,
182 inode_oid: u64,
183 block_size: usize,
184) -> crate::Result<Option<Vec<u8>>> {
185 match get_xattr(
186 reader,
187 volume,
188 inode_oid,
189 XATTR_NAME_RESOURCE_FORK,
190 block_size,
191 )? {
192 Some(XattrValue::Embedded(bytes)) => Ok(Some(bytes)),
193 Some(XattrValue::Stream { dstream_oid, size }) => {
194 let bytes = crate::extent::read_stream(reader, volume, dstream_oid, size, block_size)?;
195 Ok(Some(bytes))
196 }
197 None => Ok(None),
198 }
199}
200
201/// Resolve a symlink's target path. APFS stores it in the `com.apple.fs.symlink`
202/// xattr value (embedded), as a NUL-terminated UTF-8 path. `None` if the inode is
203/// not a symlink (no such xattr).
204///
205/// # Errors
206/// As [`list_xattrs`].
207pub fn symlink_target<R: Read + Seek>(
208 reader: &mut R,
209 volume: &ApfsVolume,
210 inode_oid: u64,
211 block_size: usize,
212) -> crate::Result<Option<String>> {
213 match get_xattr(reader, volume, inode_oid, XATTR_NAME_SYMLINK, block_size)? {
214 Some(XattrValue::Embedded(bytes)) => Ok(Some(decode_cstr(&bytes))),
215 // A stream-backed symlink target is not a form macOS produces; surface
216 // None rather than guess (the caller can still list the raw xattr).
217 Some(XattrValue::Stream { .. }) | None => Ok(None),
218 }
219}
220
221/// Decode a NUL-terminated UTF-8 byte string. Bytes after the first NUL are
222/// dropped; invalid UTF-8 is replaced (never panics).
223fn decode_cstr(data: &[u8]) -> String {
224 let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
225 String::from_utf8_lossy(&data[..end]).into_owned()
226}