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