Skip to main content

fmf_core\usn/
records.rs

1//! Pure `USN_RECORD_V2` buffer parsing — no OS calls, so the whole layer is
2//! testable from raw byte fixtures (docs/ARCHITECTURE.md, CLAUDE.md elevation rules).
3//!
4//! Buffer layout returned by `FSCTL_READ_USN_JOURNAL` / `FSCTL_ENUM_USN_DATA`:
5//! a leading u64 (the next USN / next FRN to resume from), then a sequence of
6//! `USN_RECORD_V2` structures, each `RecordLength` bytes, 8-byte aligned.
7
8/// Reason flags we act on (winioctl.h).
9pub mod reason {
10    /// File data was overwritten (`USN_REASON_DATA_OVERWRITE`).
11    pub const DATA_OVERWRITE: u32 = 0x0000_0001;
12    /// File data was extended (`USN_REASON_DATA_EXTEND`).
13    pub const DATA_EXTEND: u32 = 0x0000_0002;
14    /// File data was truncated (`USN_REASON_DATA_TRUNCATION`).
15    pub const DATA_TRUNCATION: u32 = 0x0000_0004;
16    /// Basic file info (attributes/timestamps) changed (`USN_REASON_BASIC_INFO_CHANGE`).
17    pub const BASIC_INFO_CHANGE: u32 = 0x0000_8000;
18    /// File or directory was created (`USN_REASON_FILE_CREATE`).
19    pub const FILE_CREATE: u32 = 0x0000_0100;
20    /// File or directory was deleted (`USN_REASON_FILE_DELETE`).
21    pub const FILE_DELETE: u32 = 0x0000_0200;
22    /// Record carries the name the file had before a rename (`USN_REASON_RENAME_OLD_NAME`).
23    pub const RENAME_OLD_NAME: u32 = 0x0000_1000;
24    /// Record carries the name the file has after a rename (`USN_REASON_RENAME_NEW_NAME`).
25    pub const RENAME_NEW_NAME: u32 = 0x0000_2000;
26    /// A hard link was added or removed (`USN_REASON_HARD_LINK_CHANGE`).
27    pub const HARD_LINK_CHANGE: u32 = 0x0001_0000;
28    /// Final record after a handle to the file was closed (`USN_REASON_CLOSE`).
29    pub const CLOSE: u32 = 0x8000_0000;
30}
31
32/// Hidden-file attribute bit (`FILE_ATTRIBUTE_HIDDEN`).
33pub const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
34/// System-file attribute bit (`FILE_ATTRIBUTE_SYSTEM`).
35pub const FILE_ATTRIBUTE_SYSTEM: u32 = 0x4;
36/// Directory attribute bit (`FILE_ATTRIBUTE_DIRECTORY`).
37pub const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
38/// Reparse-point attribute bit (`FILE_ATTRIBUTE_REPARSE_POINT`), e.g. symlinks/junctions.
39pub const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
40
41/// One decoded journal record.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct UsnRecord {
44    /// Update Sequence Number — this record's monotonic position in the journal.
45    pub usn: i64,
46    /// Full 64-bit FRN (with sequence).
47    pub frn: u64,
48    /// Full 64-bit FRN of the containing directory (with sequence).
49    pub parent_frn: u64,
50    /// Bitfield of `reason::*` flags describing what changed.
51    pub reason: u32,
52    /// Bitfield of `FILE_ATTRIBUTE_*` flags for the file at record time.
53    pub attributes: u32,
54    /// File name in UTF-16 units (single link name, see RESEARCH.md on
55    /// hard links).
56    pub name: Vec<u16>,
57}
58
59impl UsnRecord {
60    /// True if this record is for a directory.
61    #[must_use]
62    pub const fn is_dir(&self) -> bool {
63        self.attributes & FILE_ATTRIBUTE_DIRECTORY != 0
64    }
65    /// True if this record is for a reparse point (symlink/junction).
66    #[must_use]
67    pub const fn is_reparse(&self) -> bool {
68        self.attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
69    }
70    /// True if the hidden attribute is set.
71    #[must_use]
72    pub const fn is_hidden(&self) -> bool {
73        self.attributes & FILE_ATTRIBUTE_HIDDEN != 0
74    }
75    /// True if the system attribute is set.
76    #[must_use]
77    pub const fn is_system(&self) -> bool {
78        self.attributes & FILE_ATTRIBUTE_SYSTEM != 0
79    }
80}
81
82#[inline]
83fn u16_at(b: &[u8], off: usize) -> u16 {
84    u16::from_le_bytes([b[off], b[off + 1]])
85}
86#[inline]
87fn u32_at(b: &[u8], off: usize) -> u32 {
88    u32::from_le_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
89}
90#[inline]
91fn u64_at(b: &[u8], off: usize) -> u64 {
92    let mut a = [0u8; 8];
93    a.copy_from_slice(&b[off..off + 8]);
94    u64::from_le_bytes(a)
95}
96
97/// Parse a raw FSCTL output buffer.
98///
99/// Returns the leading "next" cursor value, the decoded records, and whether
100/// trailing bytes had to be dropped (malformed/truncated input — callers
101/// surface this as a counter+warning instead of letting it vanish).
102#[must_use]
103pub fn parse_buffer(buf: &[u8]) -> (u64, Vec<UsnRecord>, bool) {
104    let mut records = Vec::new();
105    let mut truncated = false;
106    if buf.len() < 8 {
107        return (0, records, !buf.is_empty());
108    }
109    let next = u64_at(buf, 0);
110    let mut off = 8usize;
111
112    while off + 60 <= buf.len() {
113        let rec = &buf[off..];
114        let record_length = u32_at(rec, 0) as usize;
115        if record_length < 60 || off + record_length > buf.len() {
116            truncated = true;
117            break;
118        }
119        let major = u16_at(rec, 4);
120        if major == 2 {
121            let name_len = u16_at(rec, 56) as usize; // bytes
122            let name_off = u16_at(rec, 58) as usize;
123            if name_off + name_len <= record_length {
124                let mut name = Vec::with_capacity(name_len / 2);
125                let nb = &rec[name_off..name_off + name_len];
126                for ch in nb.chunks_exact(2) {
127                    name.push(u16::from_le_bytes([ch[0], ch[1]]));
128                }
129                records.push(UsnRecord {
130                    usn: u64_at(rec, 24) as i64,
131                    frn: u64_at(rec, 8),
132                    parent_frn: u64_at(rec, 16),
133                    reason: u32_at(rec, 40),
134                    attributes: u32_at(rec, 52),
135                    name,
136                });
137            } else {
138                // Name escapes its record: corrupt bytes. The record is
139                // dropped, but the caller must hear about it (counter +
140                // warning) — a silently lost rename means a stale index.
141                truncated = true;
142            }
143        }
144        // Records are 8-byte aligned; RecordLength already includes padding.
145        off += record_length.next_multiple_of(8);
146    }
147    if off != buf.len() {
148        truncated = true; // sub-record trailing garbage (< 60 bytes)
149    }
150    (next, records, truncated)
151}
152
153/// Serialize records into the FSCTL wire format — used to build test
154/// fixtures and replay files (`fmf capture-usn`).
155#[must_use]
156pub fn encode_buffer(next: u64, records: &[UsnRecord]) -> Vec<u8> {
157    let mut out = Vec::new();
158    out.extend_from_slice(&next.to_le_bytes());
159    for r in records {
160        let name_bytes: Vec<u8> = r.name.iter().flat_map(|u| u.to_le_bytes()).collect();
161        let len = (60 + name_bytes.len()).next_multiple_of(8);
162        let start = out.len();
163        out.resize(start + len, 0);
164        let w = &mut out[start..];
165        w[0..4].copy_from_slice(&(len as u32).to_le_bytes());
166        w[4..6].copy_from_slice(&2u16.to_le_bytes()); // major
167        w[8..16].copy_from_slice(&r.frn.to_le_bytes());
168        w[16..24].copy_from_slice(&r.parent_frn.to_le_bytes());
169        w[24..32].copy_from_slice(&(r.usn as u64).to_le_bytes());
170        w[40..44].copy_from_slice(&r.reason.to_le_bytes());
171        w[52..56].copy_from_slice(&r.attributes.to_le_bytes());
172        w[56..58].copy_from_slice(&(name_bytes.len() as u16).to_le_bytes());
173        w[58..60].copy_from_slice(&60u16.to_le_bytes());
174        w[60..60 + name_bytes.len()].copy_from_slice(&name_bytes);
175    }
176    out
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn rec(frn: u64, parent: u64, reason: u32, name: &str) -> UsnRecord {
184        UsnRecord {
185            usn: 1000,
186            frn,
187            parent_frn: parent,
188            reason,
189            attributes: 0x20,
190            name: name.encode_utf16().collect(),
191        }
192    }
193
194    #[test]
195    fn roundtrip() {
196        let records = vec![
197            rec(
198                0x1_0000_0000_0007,
199                5,
200                reason::FILE_CREATE | reason::CLOSE,
201                "new file.txt",
202            ),
203            rec(
204                0x2_0000_0000_0008,
205                5,
206                reason::FILE_DELETE | reason::CLOSE,
207                "夢.dat",
208            ),
209        ];
210        let buf = encode_buffer(42, &records);
211        let (next, parsed, truncated) = parse_buffer(&buf);
212        assert!(!truncated);
213        assert_eq!(next, 42);
214        assert_eq!(parsed, records);
215    }
216
217    #[test]
218    fn truncated_tail_is_dropped() {
219        let records = vec![rec(7, 5, reason::FILE_CREATE, "abc.txt")];
220        let mut buf = encode_buffer(9, &records);
221        buf.truncate(buf.len() - 4);
222        let (next, parsed, truncated) = parse_buffer(&buf);
223        assert!(truncated);
224        assert_eq!(next, 9);
225        assert!(parsed.is_empty());
226    }
227
228    #[test]
229    fn empty_buffer() {
230        assert_eq!(parse_buffer(&[]).1, vec![]);
231        assert_eq!(parse_buffer(&7u64.to_le_bytes()).1, vec![]);
232    }
233}