1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
use std::io;
use std::path::{Path, PathBuf};
use crate::error::error_with_path;
#[inline]
pub(crate) fn is_incomplete_unc(p: &Path) -> bool {
// Detect \\server, //server, or any two-separator prefix with no share.
// Why: forward-slash UNCs are not classified by Rust's Prefix parser, so
// `//server` would otherwise slip past this guard and be reinterpreted by
// stdlib as relative-to-cwd (e.g., `//secret` → `C:\secret`), surprising
// callers who passed what they thought was a UNC path.
// Exclude verbatim (`\\?\`, `//?/`) and device (`\\.\`, `//./`) namespaces.
let raw = p.as_os_str().to_string_lossy();
let starts_with_two_seps = raw.starts_with("\\\\")
|| raw.starts_with("//")
|| raw.starts_with("\\/")
|| raw.starts_with("/\\");
if !starts_with_two_seps {
return false;
}
// Check for verbatim/device namespaces using either slash variant at positions 2-3
let bytes = raw.as_bytes();
let is_verbatim_or_device =
bytes.len() >= 4 && matches!(bytes[2], b'?' | b'.') && matches!(bytes[3], b'\\' | b'/');
if is_verbatim_or_device {
return false;
}
let mut parts = raw
.trim_start_matches(['\\', '/'])
.split(['\\', '/'])
.filter(|s| !s.is_empty());
let server = parts.next();
let share = parts.next();
server.is_some() && share.is_none()
}
pub(crate) fn validate_windows_ads_layout(p: &Path) -> io::Result<()> {
use std::path::Component;
// Collect normal components (exclude prefix/root for positional analysis)
let comps: Vec<_> = p
.components()
.filter(|c| matches!(c, Component::Normal(_)))
.collect();
if comps.len() <= 1 {
return Ok(()); // Nothing to validate in single-component cases
}
for (i, comp) in comps.iter().enumerate() {
if let Component::Normal(name) = comp {
let s = name.to_string_lossy();
if s.contains(':') {
if i < comps.len() - 1 {
return Err(error_with_path(
io::ErrorKind::InvalidInput,
p,
format!(
"invalid NTFS ADS placement: colon-containing component '{s}' must be final"
),
));
}
// Split into base + stream [+ type]
let parts: Vec<&str> = s.split(':').collect();
if parts.len() < 2 {
continue; // shouldn't happen; contains(':') implies >=2 parts
}
if parts.len() > 3 {
return Err(error_with_path(
io::ErrorKind::InvalidInput,
p,
format!(
"invalid NTFS ADS stream: too many colons in final component '{s}'"
),
));
}
let stream_part = match parts.get(1) {
Some(s) => *s,
None => continue, // unreachable: parts.len() >= 2 per check above
};
if stream_part.is_empty()
|| stream_part == "."
|| stream_part == ".."
|| stream_part.trim().is_empty()
{
return Err(error_with_path(
io::ErrorKind::InvalidInput,
p,
format!("invalid NTFS ADS stream name in '{s}'"),
));
}
// Reject whitespace manipulation (leading/trailing whitespace in stream names)
if stream_part != stream_part.trim() {
return Err(error_with_path(
io::ErrorKind::InvalidInput,
p,
format!("invalid NTFS ADS stream name contains leading/trailing whitespace in '{s}'"),
));
}
// Reject control characters and null bytes in stream names
if stream_part.chars().any(|c| c.is_control() || c == '\0') {
return Err(error_with_path(
io::ErrorKind::InvalidInput,
p,
format!(
"invalid NTFS ADS stream name contains control characters in '{s}'"
),
));
}
// SECURITY: Reject Unicode manipulation attacks (zero-width chars, BOM, etc.)
if stream_part.chars().any(|c| {
matches!(
c,
'\u{200B}' | // Zero-width space
'\u{200C}' | // Zero-width non-joiner
'\u{200D}' | // Zero-width joiner
'\u{FEFF}' | // Byte order mark
'\u{200E}' | // Left-to-right mark
'\u{200F}' | // Right-to-left mark
'\u{202A}' | // Left-to-right embedding
'\u{202B}' | // Right-to-left embedding
'\u{202C}' | // Pop directional formatting
'\u{202D}' | // Left-to-right override
'\u{202E}' // Right-to-left override
)
}) {
return Err(error_with_path(
io::ErrorKind::InvalidInput,
p,
format!("invalid NTFS ADS stream name contains Unicode manipulation characters in '{s}'"),
));
}
// Reject overly long stream names (NTFS limit ~255 chars for stream name)
if stream_part.len() > 255 {
return Err(error_with_path(
io::ErrorKind::InvalidInput,
p,
format!("invalid NTFS ADS stream name too long in '{s}'"),
));
}
// Disallow separators or traversal markers anywhere after first colon
let after_first_colon = s.find(':').and_then(|i| s.get(i + 1..)).unwrap_or("");
if after_first_colon.contains(['\\', '/'])
|| after_first_colon.contains("..\\")
|| after_first_colon.contains("../")
{
return Err(error_with_path(
io::ErrorKind::InvalidInput,
p,
format!("invalid NTFS ADS stream name contains path separator or traversal in '{s}'"),
));
}
// Additional security: reject Windows device names as stream names to prevent confusion
let stream_upper = stream_part.to_ascii_uppercase();
if matches!(
stream_upper.as_str(),
"CON"
| "PRN"
| "AUX"
| "NUL"
| "COM1"
| "COM2"
| "COM3"
| "COM4"
| "COM5"
| "COM6"
| "COM7"
| "COM8"
| "COM9"
| "LPT1"
| "LPT2"
| "LPT3"
| "LPT4"
| "LPT5"
| "LPT6"
| "LPT7"
| "LPT8"
| "LPT9"
) {
return Err(error_with_path(
io::ErrorKind::InvalidInput,
p,
format!(
"invalid NTFS ADS stream name uses reserved device name '{stream_part}'"
),
));
}
if parts.len() == 3 {
let ty = match parts.get(2) {
Some(t) => *t,
None => continue, // unreachable: parts.len() == 3
};
// Allow NTFS stream type tokens: $ + alphanumeric/underscore (case-insensitive for real types like $DATA, $BITMAP)
let valid_type = ty.starts_with('$')
&& ty.len() > 1
&& ty
.chars()
.skip(1)
.all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !ty.chars().any(|c| c.is_control() || c.is_whitespace());
if !valid_type {
return Err(error_with_path(
io::ErrorKind::InvalidInput,
p,
format!("invalid NTFS ADS stream type '{ty}' in component '{s}'"),
));
}
}
}
}
}
Ok(())
}
#[inline]
pub(crate) fn ensure_windows_extended_prefix(p: &Path) -> PathBuf {
use std::path::{Component, Prefix};
let mut comps = p.components();
let first = match comps.next() {
Some(Component::Prefix(pr)) => pr,
_ => return p.to_path_buf(),
};
match first.kind() {
Prefix::Verbatim(_) | Prefix::VerbatimDisk(_) | Prefix::VerbatimUNC(_, _) => {
// Already extended-length
p.to_path_buf()
}
Prefix::Disk(drive) => {
// Build an extended-length disk path. If the input was drive-relative (e.g., "C:dir"),
// resolve relative to the process's current directory on that drive (Windows semantics).
// Otherwise (already absolute like "C:\\..."), just add the verbatim prefix.
use std::ffi::OsString;
// Peek the next component to detect drive-relative vs absolute
let mut rest = comps.clone();
let is_absolute = matches!(rest.next(), Some(Component::RootDir));
if is_absolute {
// Fast path: already absolute -> just prefix with \\?\
let mut s = OsString::from(r"\\?\");
s.push(p.as_os_str());
PathBuf::from(s)
} else {
// Drive-relative: base is the current directory on that drive if available
// Fallback to the drive root if no per-drive current directory is found.
#[inline]
fn current_dir_on_drive(drive: u8) -> Option<PathBuf> {
// First, if the process current_dir is on this drive, use it directly
if let Ok(cwd) = std::env::current_dir() {
if let Some(std::path::Component::Prefix(pr)) = cwd.components().next() {
if let std::path::Prefix::Disk(d) = pr.kind() {
if d == drive {
return Some(cwd);
}
}
}
}
// Next, try Windows per-drive current directory env var: "=<DRIVE>:"
// e.g., "=C:" -> "C:\\path\\to\\cwd"
let mut name = String::with_capacity(3);
name.push('=');
name.push((drive as char).to_ascii_uppercase());
name.push(':');
if let Some(val) = std::env::var_os(&name) {
let base = PathBuf::from(val);
// Ensure it looks like an absolute path (has RootDir)
if matches!(
base.components().nth(1),
Some(std::path::Component::RootDir)
) {
return Some(base);
}
}
None
}
let base = current_dir_on_drive(drive)
.unwrap_or_else(|| PathBuf::from(format!("{}:\\", drive as char)));
// Ensure verbatim prefix on the base
let mut out = ensure_windows_extended_prefix(&base);
// Append remaining components (after the drive prefix) lexically
for c in comps {
out.push(c.as_os_str());
}
out
}
}
Prefix::UNC(server, share) => {
// \\?\UNC\server\share\...
let mut out = PathBuf::from(r"\\?\UNC\");
out.push(server);
out.push(share);
for c in comps {
out.push(c.as_os_str());
}
out
}
_ => p.to_path_buf(),
}
}
#[inline]
pub(crate) fn has_windows_short_component(p: &Path) -> bool {
use std::path::Component;
for comp in p.components() {
if let Component::Normal(name) = comp {
// Fast path: check for '~' in UTF-16 code units without allocating a String
use std::os::windows::ffi::OsStrExt;
let mut saw_tilde = false;
for u in name.encode_wide() {
if u == b'~' as u16 {
saw_tilde = true;
break;
}
}
if !saw_tilde {
continue;
}
if is_likely_8_3_short_name_wide(name) {
return true;
}
}
}
false
}
#[inline]
fn is_likely_8_3_short_name_wide(name: &std::ffi::OsStr) -> bool {
use std::os::windows::ffi::OsStrExt;
// Stream over UTF-16 code units without heap allocation using a small state machine.
// States:
// 0 = before '~' (must see at least one ASCII char)
// 1 = reading one-or-more digits after '~'
let mut it = name.encode_wide();
let mut seen_pre_char = false; // at least one ASCII char before '~'
let mut state = 0u8;
let mut saw_digit = false;
// Iterate through all code units once.
while let Some(u) = it.next() {
// Enforce ASCII-only for 8.3 short names
if u > 0x7F {
return false;
}
let b = u as u8;
match state {
0 => {
if b == b'~' {
// Require at least one char before '~'
if !seen_pre_char {
return false;
}
state = 1;
} else {
// Any ASCII char counts as pre-tilde content
seen_pre_char = true;
}
}
1 => {
if b.is_ascii_digit() {
saw_digit = true;
} else {
// Digit run ended; accept only "." followed by at least one more char
if !saw_digit {
return false;
}
if b == b'.' {
// Must have at least one ASCII unit after '.'
match it.next() {
Some(u2) if u2 <= 0x7F => return true,
_ => return false,
}
} else {
return false;
}
}
}
_ => unreachable!(),
}
}
// End of stream: valid only if we were parsing digits and saw at least one.
state == 1 && saw_digit
}