Skip to main content

forensic_mount/
marking.rs

1//! Platform-agnostic recovered-deleted marking schema (ADR-0008 v2).
2//!
3//! The single source of truth for the *values* a recovered deleted/orphan entry
4//! advertises out-of-band: its `status` (`deleted` / `orphan`) and the four
5//! recovered MACB times in ISO-8601 UTC. One logical schema, two physical
6//! channels — both render **from here**, so they can never drift:
7//!
8//! - **Unix** (macFUSE / Linux): extended attributes `user.4n6.status` +
9//!   `user.4n6.macb.{modified,accessed,changed,born}` — see
10//!   [`crate::fusefs`]'s `getxattr` / `listxattr`.
11//! - **Windows** (Dokan): NTFS Alternate Data Streams `<name>:4n6.status` and
12//!   `<name>:4n6.macb`, surfaced by `find_streams` — see
13//!   [`crate::fuse_windows`].
14//!
15//! The Unix channel splits the four MACB times across four xattrs; the Windows
16//! channel carries all four in one `:4n6.macb` stream as a JSON object. The
17//! *values* (the status word and each ISO-8601 time) are identical byte-for-byte
18//! between the channels — that identity is the parity guarantee this module
19//! exists to enforce, and it is asserted in the tests below.
20
21use crate::{FsAllocation, FsDeletedNode};
22
23/// The four recovered MACB times as Unix seconds, in canonical `M A C B` order.
24///
25/// `modified` ← mtime, `accessed` ← atime, `changed` ← ctime, `born` ← crtime —
26/// the same mapping the Unix `deleted/` cache applies.
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub struct Macb {
29    pub modified: i64,
30    pub accessed: i64,
31    pub changed: i64,
32    pub born: i64,
33}
34
35/// The out-of-band marking on one recovered deleted/orphan entry: its allocation
36/// state plus the recovered MACB times. Both physical channels render from this.
37#[derive(Debug, Clone, Copy)]
38pub struct Mark {
39    pub allocation: FsAllocation,
40    pub macb: Macb,
41}
42
43impl Mark {
44    /// Build a [`Mark`] from a recovered [`FsDeletedNode`], applying the
45    /// canonical MACB mapping (mtime→modified, atime→accessed, ctime→changed,
46    /// crtime→born).
47    pub fn from_node(node: &FsDeletedNode) -> Self {
48        Self {
49            allocation: node.allocation,
50            macb: Macb {
51                modified: node.mtime.seconds,
52                accessed: node.atime.seconds,
53                changed: node.ctime.seconds,
54                born: node.crtime.seconds,
55            },
56        }
57    }
58}
59
60/// The five Unix xattr names on a recovered-deleted entry, in listing order.
61pub const UNIX_XATTR_NAMES: [&str; 5] = [
62    "user.4n6.status",
63    "user.4n6.macb.modified",
64    "user.4n6.macb.accessed",
65    "user.4n6.macb.changed",
66    "user.4n6.macb.born",
67];
68
69/// One NTFS Alternate Data Stream carrying part of the marking.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum MarkStream {
72    /// `<name>:4n6.status` — the `deleted` / `orphan` word.
73    Status,
74    /// `<name>:4n6.macb` — the four MACB times as a JSON object.
75    Macb,
76}
77
78/// The two marking ADS streams a recovered-deleted entry exposes, in order.
79pub const ADS_STREAMS: [MarkStream; 2] = [MarkStream::Status, MarkStream::Macb];
80
81impl MarkStream {
82    /// The bare NTFS stream name (no `:` delimiters, no `:$DATA` type suffix).
83    pub fn base(self) -> &'static str {
84        match self {
85            MarkStream::Status => "4n6.status",
86            MarkStream::Macb => "4n6.macb",
87        }
88    }
89
90    /// Parse a bare NTFS stream name into a marking stream, or `None` when it is
91    /// not one of ours (so a foreign ADS is never misread as a 4n6 marker).
92    pub fn from_base(base: &str) -> Option<Self> {
93        match base {
94            "4n6.status" => Some(MarkStream::Status),
95            "4n6.macb" => Some(MarkStream::Macb),
96            _ => None,
97        }
98    }
99
100    /// The full NTFS stream name Dokan reports for this stream, e.g.
101    /// `:4n6.status:$DATA`.
102    pub fn ads_full_name(self) -> String {
103        format!(":{}:$DATA", self.base())
104    }
105}
106
107/// The status word for an allocation state: `deleted` or `orphan`.
108pub fn status_str(allocation: FsAllocation) -> &'static str {
109    match allocation {
110        FsAllocation::Deleted => "deleted",
111        FsAllocation::Orphan => "orphan",
112    }
113}
114
115/// Format Unix seconds as ISO-8601 UTC `YYYY-MM-DDTHH:MM:SSZ` — the marking
116/// *value* form (colons are legal inside a value, unlike a filename).
117#[allow(clippy::many_single_char_names)] // conventional date-field names
118pub fn iso8601_utc(secs: i64) -> String {
119    let days = secs.div_euclid(86_400);
120    let tod = secs.rem_euclid(86_400);
121    let (h, m, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
122    let (y, mon, d) = civil_from_days(days);
123    format!("{y:04}-{mon:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
124}
125
126/// Value of one Unix xattr on a marked entry, or `None` when `name` is outside
127/// the schema (the getxattr shell then replies `ENODATA`).
128pub fn unix_xattr_value(mark: &Mark, name: &str) -> Option<Vec<u8>> {
129    let v = match name {
130        "user.4n6.status" => status_str(mark.allocation).to_string(),
131        "user.4n6.macb.modified" => iso8601_utc(mark.macb.modified),
132        "user.4n6.macb.accessed" => iso8601_utc(mark.macb.accessed),
133        "user.4n6.macb.changed" => iso8601_utc(mark.macb.changed),
134        "user.4n6.macb.born" => iso8601_utc(mark.macb.born),
135        _ => return None,
136    };
137    Some(v.into_bytes())
138}
139
140/// Bytes of one marking ADS stream on a marked entry. The `Status` stream is the
141/// status word; the `Macb` stream is a JSON object of the four ISO-8601 times.
142pub fn ads_stream_value(mark: &Mark, stream: MarkStream) -> Vec<u8> {
143    match stream {
144        MarkStream::Status => status_str(mark.allocation).as_bytes().to_vec(),
145        MarkStream::Macb => {
146            let obj = serde_json::json!({
147                "modified": iso8601_utc(mark.macb.modified),
148                "accessed": iso8601_utc(mark.macb.accessed),
149                "changed": iso8601_utc(mark.macb.changed),
150                "born": iso8601_utc(mark.macb.born),
151            });
152            serde_json::to_vec(&obj).unwrap_or_default()
153        }
154    }
155}
156
157/// Days since the Unix epoch → (year, month, day). Howard Hinnant's
158/// `civil_from_days` (public domain), valid across the whole i64 range.
159#[allow(clippy::many_single_char_names)] // canonical algorithm's variable names
160pub(crate) fn civil_from_days(z: i64) -> (i64, u32, u32) {
161    let z = z + 719_468;
162    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
163    let doe = (z - era * 146_097) as u64; // [0, 146096]
164    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
165    let y = yoe as i64 + era * 400;
166    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
167    let mp = (5 * doy + 2) / 153; // [0, 11]
168    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
169    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
170    (if m <= 2 { y + 1 } else { y }, m, d)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::{FsFileType, FsTimestamp};
177
178    fn ts(secs: i64) -> FsTimestamp {
179        FsTimestamp {
180            seconds: secs,
181            nanoseconds: 0,
182        }
183    }
184
185    fn sample_mark(allocation: FsAllocation) -> Mark {
186        Mark {
187            allocation,
188            macb: Macb {
189                modified: 1_700_000_000,
190                accessed: 1_700_000_100,
191                changed: 1_700_000_200,
192                born: 1_700_000_300,
193            },
194        }
195    }
196
197    #[test]
198    fn status_str_maps_allocation() {
199        assert_eq!(status_str(FsAllocation::Deleted), "deleted");
200        assert_eq!(status_str(FsAllocation::Orphan), "orphan");
201    }
202
203    #[test]
204    fn iso8601_utc_renders_zulu() {
205        assert_eq!(iso8601_utc(1_700_000_000), "2023-11-14T22:13:20Z");
206        assert_eq!(iso8601_utc(200), "1970-01-01T00:03:20Z");
207        assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z");
208    }
209
210    #[test]
211    fn unix_xattr_names_are_the_schema() {
212        assert_eq!(
213            UNIX_XATTR_NAMES,
214            [
215                "user.4n6.status",
216                "user.4n6.macb.modified",
217                "user.4n6.macb.accessed",
218                "user.4n6.macb.changed",
219                "user.4n6.macb.born",
220            ]
221        );
222    }
223
224    #[test]
225    fn unix_xattr_value_status_and_macb() {
226        let d = sample_mark(FsAllocation::Deleted);
227        let o = sample_mark(FsAllocation::Orphan);
228        assert_eq!(
229            unix_xattr_value(&d, "user.4n6.status").as_deref(),
230            Some(&b"deleted"[..])
231        );
232        assert_eq!(
233            unix_xattr_value(&o, "user.4n6.status").as_deref(),
234            Some(&b"orphan"[..])
235        );
236        assert_eq!(
237            unix_xattr_value(&d, "user.4n6.macb.modified").as_deref(),
238            Some(iso8601_utc(1_700_000_000).as_bytes())
239        );
240        assert_eq!(
241            unix_xattr_value(&d, "user.4n6.macb.born").as_deref(),
242            Some(iso8601_utc(1_700_000_300).as_bytes())
243        );
244        // A name outside the schema yields None.
245        assert!(unix_xattr_value(&d, "user.4n6.nope").is_none());
246        assert!(unix_xattr_value(&d, "user.other").is_none());
247    }
248
249    #[test]
250    fn ads_stream_base_names_and_full_names() {
251        assert_eq!(MarkStream::Status.base(), "4n6.status");
252        assert_eq!(MarkStream::Macb.base(), "4n6.macb");
253        assert_eq!(MarkStream::Status.ads_full_name(), ":4n6.status:$DATA");
254        assert_eq!(MarkStream::Macb.ads_full_name(), ":4n6.macb:$DATA");
255        assert_eq!(ADS_STREAMS, [MarkStream::Status, MarkStream::Macb]);
256    }
257
258    #[test]
259    fn ads_from_base_recognizes_only_ours() {
260        assert_eq!(
261            MarkStream::from_base("4n6.status"),
262            Some(MarkStream::Status)
263        );
264        assert_eq!(MarkStream::from_base("4n6.macb"), Some(MarkStream::Macb));
265        assert_eq!(MarkStream::from_base("Zone.Identifier"), None);
266        assert_eq!(MarkStream::from_base(""), None);
267    }
268
269    #[test]
270    fn ads_status_stream_is_the_status_word() {
271        let d = sample_mark(FsAllocation::Deleted);
272        let o = sample_mark(FsAllocation::Orphan);
273        assert_eq!(ads_stream_value(&d, MarkStream::Status), b"deleted");
274        assert_eq!(ads_stream_value(&o, MarkStream::Status), b"orphan");
275    }
276
277    #[test]
278    fn ads_macb_stream_is_json_of_the_four_iso_times() {
279        let d = sample_mark(FsAllocation::Deleted);
280        let bytes = ads_stream_value(&d, MarkStream::Macb);
281        let v: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
282        assert_eq!(v["modified"], iso8601_utc(1_700_000_000));
283        assert_eq!(v["accessed"], iso8601_utc(1_700_000_100));
284        assert_eq!(v["changed"], iso8601_utc(1_700_000_200));
285        assert_eq!(v["born"], iso8601_utc(1_700_000_300));
286    }
287
288    /// The parity contract: the *value* the Unix xattr channel emits for the
289    /// status is byte-identical to the Windows ADS status stream, and each MACB
290    /// time is identical across the two channels. Same logical schema, two
291    /// physical channels.
292    #[test]
293    fn unix_and_windows_channels_agree_on_values() {
294        let d = sample_mark(FsAllocation::Deleted);
295        // Status parity.
296        assert_eq!(
297            unix_xattr_value(&d, "user.4n6.status").unwrap(),
298            ads_stream_value(&d, MarkStream::Status)
299        );
300        // MACB-time parity: the Unix per-field xattr equals the matching field
301        // in the Windows combined JSON stream.
302        let macb_json: serde_json::Value =
303            serde_json::from_slice(&ads_stream_value(&d, MarkStream::Macb)).unwrap();
304        for (xattr, field) in [
305            ("user.4n6.macb.modified", "modified"),
306            ("user.4n6.macb.accessed", "accessed"),
307            ("user.4n6.macb.changed", "changed"),
308            ("user.4n6.macb.born", "born"),
309        ] {
310            let unix = String::from_utf8(unix_xattr_value(&d, xattr).unwrap()).unwrap();
311            assert_eq!(unix, macb_json[field].as_str().unwrap());
312        }
313    }
314
315    #[test]
316    fn from_node_applies_the_macb_mapping() {
317        let node = FsDeletedNode {
318            ino: 42,
319            name: b"notes.txt".to_vec(),
320            parent_ino: Some(5),
321            size: 12,
322            file_type: FsFileType::RegularFile,
323            allocation: FsAllocation::Deleted,
324            record_id: 12345,
325            atime: ts(200),
326            mtime: ts(100),
327            ctime: ts(300),
328            crtime: ts(400),
329        };
330        let mark = Mark::from_node(&node);
331        assert_eq!(mark.allocation, FsAllocation::Deleted);
332        assert_eq!(mark.macb.modified, 100); // mtime
333        assert_eq!(mark.macb.accessed, 200); // atime
334        assert_eq!(mark.macb.changed, 300); // ctime
335        assert_eq!(mark.macb.born, 400); // crtime
336    }
337}