Skip to main content

forensic_mount/
win_map.rs

1//! Pure mapping helpers from the platform-agnostic `ForensicFs` model onto
2//! Windows filesystem semantics (path components, file attributes, timestamps).
3//!
4//! This module is **cross-platform on purpose**: the Windows mount backend
5//! (`fuse_windows`, `#[cfg(windows)]`) is a thin Humble-Object shell over
6//! Dokan's FFI callbacks, so every testable decision lives here where the
7//! Linux/macOS `cargo test` job can exercise it. The attribute constants are
8//! the documented Windows ABI values ([MS-FSCC] §2.6 / `WinNT.h`), so they are
9//! defined once here and reused by the shell rather than pulled from a
10//! Windows-only binding crate.
11
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use crate::{FsFileType, FsTimestamp};
15
16/// `FILE_ATTRIBUTE_READONLY` — the file is read-only.
17pub const FILE_ATTRIBUTE_READONLY: u32 = 0x0000_0001;
18/// `FILE_ATTRIBUTE_DIRECTORY` — the handle identifies a directory.
19pub const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010;
20/// `FILE_ATTRIBUTE_NORMAL` — no other attributes are set (valid only alone).
21pub const FILE_ATTRIBUTE_NORMAL: u32 = 0x0000_0080;
22
23/// Windows file-attribute bits for a `ForensicFs` file type.
24///
25/// Directories carry `FILE_ATTRIBUTE_DIRECTORY`; everything else is reported as
26/// a normal file. The mount is presented read-only at the volume level
27/// (`MountFlags::WRITE_PROTECT`), so a per-file read-only bit is redundant and
28/// is not set here.
29pub fn windows_attributes(ft: FsFileType) -> u32 {
30    match ft {
31        FsFileType::Directory => FILE_ATTRIBUTE_DIRECTORY,
32        _ => FILE_ATTRIBUTE_NORMAL,
33    }
34}
35
36/// Convert a Unix `FsTimestamp` to a `SystemTime` (Dokan converts it to a
37/// Windows `FILETIME` internally).
38///
39/// Non-positive seconds (missing/zero timestamps) map to the Unix epoch.
40pub fn to_system_time(ts: FsTimestamp) -> SystemTime {
41    if ts.seconds <= 0 {
42        return UNIX_EPOCH;
43    }
44    UNIX_EPOCH + Duration::new(ts.seconds as u64, ts.nanoseconds)
45}
46
47/// Split a Windows path (`\dir\file`, also tolerating `/`) into its non-empty
48/// components, for walking `ForensicFs::lookup` from the root.
49pub fn path_components(path: &str) -> Vec<&str> {
50    path.split(['\\', '/']).filter(|c| !c.is_empty()).collect()
51}
52
53/// Split a Dokan path into its file part and an optional NTFS Alternate Data
54/// Stream name, so a `create_file` on `\dir\file:4n6.status` resolves `\dir\file`
55/// and remembers the `4n6.status` stream.
56///
57/// An ADS suffix (`:name`) is only ever on the **final** path component, and `:`
58/// is illegal in a normal Windows filename, so the split is unambiguous. The
59/// `:$DATA` stream-type suffix is stripped, and the unnamed main stream — no
60/// suffix, or the explicit `::$DATA` — yields `None`.
61pub fn split_path_stream(path: &str) -> (&str, Option<&str>) {
62    // The ADS suffix lives only on the final component; locate it, then split
63    // that tail on its first `:`.
64    let comp_start = path.rfind(['\\', '/']).map_or(0, |i| i + 1);
65    let (head, last) = path.split_at(comp_start);
66    let Some(colon) = last.find(':') else {
67        return (path, None);
68    };
69    // `file` is everything up to the stream delimiter; the stream is the rest,
70    // minus a trailing `:$DATA` type suffix.
71    let file_len = head.len() + colon;
72    let stream = &last[colon + 1..];
73    let stream = stream.strip_suffix(":$DATA").unwrap_or(stream);
74    let stream = stream.strip_suffix("$DATA").unwrap_or(stream);
75    let stream = if stream.is_empty() {
76        None
77    } else {
78        Some(stream)
79    };
80    (&path[..file_len], stream)
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn directory_maps_to_directory_attribute() {
89        assert_eq!(
90            windows_attributes(FsFileType::Directory),
91            FILE_ATTRIBUTE_DIRECTORY
92        );
93    }
94
95    #[test]
96    fn non_directory_types_map_to_normal() {
97        for ft in [
98            FsFileType::RegularFile,
99            FsFileType::Symlink,
100            FsFileType::CharDevice,
101            FsFileType::BlockDevice,
102            FsFileType::Fifo,
103            FsFileType::Socket,
104            FsFileType::Unknown,
105        ] {
106            assert_eq!(windows_attributes(ft), FILE_ATTRIBUTE_NORMAL);
107        }
108    }
109
110    #[test]
111    fn zero_and_negative_timestamps_map_to_epoch() {
112        assert_eq!(
113            to_system_time(FsTimestamp {
114                seconds: 0,
115                nanoseconds: 0
116            }),
117            UNIX_EPOCH
118        );
119        assert_eq!(
120            to_system_time(FsTimestamp {
121                seconds: -5,
122                nanoseconds: 123
123            }),
124            UNIX_EPOCH
125        );
126    }
127
128    #[test]
129    fn positive_timestamp_offsets_from_epoch() {
130        assert_eq!(
131            to_system_time(FsTimestamp {
132                seconds: 1,
133                nanoseconds: 500_000_000
134            }),
135            UNIX_EPOCH + Duration::new(1, 500_000_000)
136        );
137    }
138
139    #[test]
140    fn path_components_splits_on_both_separators_and_drops_empties() {
141        assert_eq!(path_components(r"\dir\file.txt"), vec!["dir", "file.txt"]);
142        assert_eq!(path_components("/a//b/"), vec!["a", "b"]);
143    }
144
145    #[test]
146    fn root_and_empty_paths_have_no_components() {
147        assert!(path_components("").is_empty());
148        assert!(path_components(r"\").is_empty());
149    }
150
151    #[test]
152    fn split_path_stream_plain_path_has_no_stream() {
153        assert_eq!(
154            split_path_stream(r"\dir\file.txt"),
155            (r"\dir\file.txt", None)
156        );
157        assert_eq!(split_path_stream(r"\"), (r"\", None));
158        assert_eq!(split_path_stream(""), ("", None));
159    }
160
161    #[test]
162    fn split_path_stream_extracts_named_ads() {
163        assert_eq!(
164            split_path_stream(r"\dir\file.txt:4n6.status"),
165            (r"\dir\file.txt", Some("4n6.status"))
166        );
167        assert_eq!(
168            split_path_stream(r"\file:4n6.macb"),
169            (r"\file", Some("4n6.macb"))
170        );
171    }
172
173    #[test]
174    fn split_path_stream_strips_data_type_suffix() {
175        assert_eq!(
176            split_path_stream(r"\file:4n6.status:$DATA"),
177            (r"\file", Some("4n6.status"))
178        );
179    }
180
181    #[test]
182    fn split_path_stream_unnamed_main_stream_is_none() {
183        // The explicit unnamed data stream `::$DATA` is the main stream, not an
184        // ADS — it must not be mistaken for a named stream.
185        assert_eq!(split_path_stream(r"\file::$DATA"), (r"\file", None));
186    }
187
188    #[test]
189    fn split_path_stream_only_splits_the_final_component() {
190        // A `:` can appear only in the last component; a directory drive-letter
191        // form is left intact by the caller's path model (leading `\`).
192        assert_eq!(
193            split_path_stream(r"\a\b\c:4n6.status"),
194            (r"\a\b\c", Some("4n6.status"))
195        );
196    }
197}