apfs_core/inode.rs
1//! Inode records (`APFS_TYPE_INODE 3`, value `j_inode_val_t`).
2//!
3//! Apple *APFS Reference*, `j_inode_val_t`: `parent_id`, `private_id` (the
4//! data-stream object id), four timestamps, `internal_flags`, a
5//! `nchildren`/`nlink` union, ownership/mode, then extended fields (xfields).
6//! **Timestamps are 64-bit nanoseconds since 1970-01-01 00:00 UTC** (disregarding
7//! leap seconds); zero is a contextual lead, not a spec-defined "unset" sentinel.
8//! The filename lives in an `INO_EXT_TYPE_NAME 4` xfield; the data-stream
9//! attribute (carrying the logical file size) in an `INO_EXT_TYPE_DSTREAM 8`
10//! xfield.
11//!
12//! On-disk field offsets within the value (verified empirically against the real
13//! self-minted fixture and cross-checked against TSK `istat` — timestamps, mode,
14//! uid/gid, nchildren — see `docs/validation.md`):
15//!
16//! | off | size | field |
17//! |-----|------|--------------------------------|
18//! | 0 | 8 | `parent_id` |
19//! | 8 | 8 | `private_id` (data-stream oid) |
20//! | 16 | 8 | `create_time` |
21//! | 24 | 8 | `mod_time` |
22//! | 32 | 8 | `change_time` |
23//! | 40 | 8 | `access_time` |
24//! | 48 | 8 | `internal_flags` |
25//! | 56 | 4 | `nchildren` / `nlink` (union) |
26//! | 68 | 4 | `bsd_flags` |
27//! | 72 | 4 | `owner` (uid) |
28//! | 76 | 4 | `group` (gid) |
29//! | 80 | 2 | `mode` |
30//! | 92 | … | extended fields (`xf_blob`) |
31//!
32//! The fixed prefix differs from the libfsapfs asciidoc table (which inserts a
33//! phantom 8-byte gap, placing access@48 / flags@56 / mode@86); the real on-disk
34//! layout above reconciles exactly with the TSK oracle.
35
36use chrono::{DateTime, Utc};
37
38use crate::fsrecord::parse_xfields;
39
40// `j_inode_val_t` fixed-field offsets (verified vs the fixture + TSK istat).
41const OFF_PARENT_ID: usize = 0;
42const OFF_PRIVATE_ID: usize = 8;
43const OFF_CREATE_TIME: usize = 16;
44const OFF_MOD_TIME: usize = 24;
45const OFF_CHANGE_TIME: usize = 32;
46const OFF_ACCESS_TIME: usize = 40;
47const OFF_INTERNAL_FLAGS: usize = 48;
48const OFF_NCHILDREN: usize = 56;
49const OFF_BSD_FLAGS: usize = 68;
50const OFF_OWNER: usize = 72;
51const OFF_GROUP: usize = 76;
52const OFF_MODE: usize = 80;
53/// Extended fields begin after the 92-byte fixed inode prefix.
54const OFF_XFIELDS: usize = 92;
55
56/// Inode extended-field type `INO_EXT_TYPE_NAME` (filename, UTF-8 + NUL).
57const INO_EXT_TYPE_NAME: u8 = 4;
58/// Inode extended-field type `INO_EXT_TYPE_DSTREAM` (data-stream attribute).
59const INO_EXT_TYPE_DSTREAM: u8 = 8;
60
61/// Nanoseconds per second — APFS timestamps are ns since the Unix epoch.
62const NS_PER_SEC: i64 = 1_000_000_000;
63
64/// A parsed inode.
65#[derive(Debug, Clone)]
66#[non_exhaustive]
67pub struct Inode {
68 /// File-system object id of this inode (the `j_key` oid).
69 pub oid: u64,
70 /// `parent_id` — the inode of the containing directory.
71 pub parent_id: u64,
72 /// `private_id` — the data-stream object id (file extents are keyed by it).
73 pub private_id: u64,
74 /// `create_time` (ns since 1970-01-01 UTC).
75 pub create_time: u64,
76 /// `mod_time` (ns since 1970-01-01 UTC).
77 pub mod_time: u64,
78 /// `change_time` (ns since 1970-01-01 UTC).
79 pub change_time: u64,
80 /// `access_time` (ns since 1970-01-01 UTC).
81 pub access_time: u64,
82 /// `internal_flags` (e.g. `INODE_WAS_CLONED`, `INODE_NO_RSRC_FORK`).
83 pub internal_flags: u64,
84 /// `nchildren` (directories) or `nlink` (files) — the same union field.
85 pub nlink_or_nchildren: i32,
86 /// `bsd_flags` — BSD file entry flags.
87 pub bsd_flags: u32,
88 /// `owner` — owner user id (uid).
89 pub uid: u32,
90 /// `group` — group id (gid).
91 pub gid: u32,
92 /// `mode` — POSIX file mode (type bits + permissions).
93 pub mode: u16,
94 /// Filename from the `INO_EXT_TYPE_NAME` xfield, if present.
95 pub name: Option<String>,
96 /// Logical file size (the data-stream `used_size`) from the
97 /// `INO_EXT_TYPE_DSTREAM` xfield, if present (directories have none).
98 pub size: Option<u64>,
99}
100
101impl Inode {
102 /// Parse a `j_inode_val_t` value (+ xfields) for `oid`. Bounds-checked: a
103 /// truncated value reads missing fields as 0 rather than panicking.
104 ///
105 /// # Errors
106 /// Never fails for a well-formed slice today; the `Result` is reserved for
107 /// future stricter validation (kept for API stability).
108 pub fn parse(oid: u64, value: &[u8]) -> crate::Result<Self> {
109 let mut name = None;
110 let mut size = None;
111 if value.len() > OFF_XFIELDS {
112 for (x_type, data) in parse_xfields(&value[OFF_XFIELDS..]) {
113 match x_type {
114 INO_EXT_TYPE_NAME => name = Some(decode_cstr(data)),
115 // The data-stream attribute's first u64 is `used_size`, the
116 // logical file size.
117 INO_EXT_TYPE_DSTREAM => size = Some(crate::bytes::le_u64(data, 0)),
118 _ => {}
119 }
120 }
121 }
122
123 Ok(Self {
124 oid,
125 parent_id: crate::bytes::le_u64(value, OFF_PARENT_ID),
126 private_id: crate::bytes::le_u64(value, OFF_PRIVATE_ID),
127 create_time: crate::bytes::le_u64(value, OFF_CREATE_TIME),
128 mod_time: crate::bytes::le_u64(value, OFF_MOD_TIME),
129 change_time: crate::bytes::le_u64(value, OFF_CHANGE_TIME),
130 access_time: crate::bytes::le_u64(value, OFF_ACCESS_TIME),
131 internal_flags: crate::bytes::le_u64(value, OFF_INTERNAL_FLAGS),
132 #[allow(clippy::cast_possible_wrap)]
133 nlink_or_nchildren: crate::bytes::le_u32(value, OFF_NCHILDREN) as i32,
134 bsd_flags: crate::bytes::le_u32(value, OFF_BSD_FLAGS),
135 uid: crate::bytes::le_u32(value, OFF_OWNER),
136 gid: crate::bytes::le_u32(value, OFF_GROUP),
137 mode: crate::bytes::le_u16(value, OFF_MODE),
138 name,
139 size,
140 })
141 }
142
143 /// `create_time` as a UTC datetime.
144 #[must_use]
145 pub fn created(&self) -> Option<DateTime<Utc>> {
146 ns_to_datetime(self.create_time)
147 }
148
149 /// `mod_time` as a UTC datetime.
150 #[must_use]
151 pub fn modified(&self) -> Option<DateTime<Utc>> {
152 ns_to_datetime(self.mod_time)
153 }
154
155 /// `change_time` as a UTC datetime.
156 #[must_use]
157 pub fn changed(&self) -> Option<DateTime<Utc>> {
158 ns_to_datetime(self.change_time)
159 }
160
161 /// `access_time` as a UTC datetime.
162 #[must_use]
163 pub fn accessed(&self) -> Option<DateTime<Utc>> {
164 ns_to_datetime(self.access_time)
165 }
166}
167
168/// Convert APFS nanoseconds-since-epoch to a UTC datetime (range-checked).
169/// `None` for a value beyond the representable range (never panics).
170#[must_use]
171pub fn ns_to_datetime(ns: u64) -> Option<DateTime<Utc>> {
172 // Split into whole seconds + sub-second nanos, both non-negative.
173 #[allow(clippy::cast_possible_wrap)]
174 let secs = (ns / NS_PER_SEC as u64) as i64;
175 #[allow(clippy::cast_possible_truncation)]
176 let nanos = (ns % NS_PER_SEC as u64) as u32;
177 DateTime::from_timestamp(secs, nanos)
178}
179
180/// Decode a NUL-terminated UTF-8 byte string (the xfield filename form). Bytes
181/// after the first NUL are dropped; invalid UTF-8 is replaced (never panics).
182fn decode_cstr(data: &[u8]) -> String {
183 let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
184 String::from_utf8_lossy(&data[..end]).into_owned()
185}