Skip to main content

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}