windows_namespace_request_sys/anchored.rs
1// Copyright (c) Mike Grier.
2
3//! A path as the caller gave it, with a relative one fixed to the current directory of the moment
4//! the request was built (D-19).
5//!
6//! The process current directory is shared and mutable, and a request is performed later, on any
7//! thread. So a relative path is grafted onto the current directory **when the request is built**
8//! and the result is what `perform` hands to Win32, however the current directory has changed since.
9//!
10//! # The graft
11//!
12//! The captured directory, a backslash unless it already ends in one (a drive root does), then the
13//! child. Nothing is combined, canonicalised, length-checked or prefixed with `\\?\`:
14//! `CreateFileW` normalises `.`, `..` and trailing dots and spaces on the final path itself, and
15//! Win32 decides whether a long result is acceptable to the host.
16//!
17//! # Only a clearly safe child is grafted
18//!
19//! All of these must hold, and a child that fails any of them is kept exactly as given, for Win32 to
20//! resolve when the call is made:
21//!
22//! 1. it is not empty;
23//! 2. it does not begin with `\` or `/`, which rules out root-relative, UNC, `\\?\` and `\\.\`;
24//! 3. it contains no `:`, which rules out drive-relative `C:a` and `C:` and a stream `name:stream`;
25//! 4. if the captured directory is a UNC path, the child has no `..` component;
26//! 5. no component of the child is a reserved device name: `CON`, `PRN`, `AUX`, `NUL`, `COM` or
27//! `LPT` with a digit, `CONIN$` or `CONOUT$`, compared without regard to ASCII case, after
28//! removing everything from the first `.` and any trailing spaces;
29//! 6. the captured directory does not begin with `\\?\` or `\\.\`. A path with the `\\?\` prefix is
30//! not normalised by Win32, so a child joined onto one would be taken literally, `.` and `..`
31//! components, forward slashes and trailing dots and spaces among what would then mean something
32//! else than they meant relative to that directory. A `\\.\` path is normalised (measured, in
33//! D-28), so for it the rule is wider than its reason, which errs toward not grafting.
34//!
35//! The rules err toward not grafting. Where they are wider than Microsoft's list, the child is
36//! passed through, which is what happens to every form that is not grafted. The digits of rule 5
37//! are `0` to `9` and the superscripts `U+00B9`, `U+00B2` and `U+00B3` that the documentation also
38//! names. A directory that begins with two separators of either kind counts as UNC for rule 4.
39//!
40//! The current directory is read only when it is needed: a path that rules 1, 2, 3 and 5 already
41//! refuse makes no call. Rules 4 and 6 are about the directory itself, so they are decided after it
42//! is read.
43//!
44//! # The one thing refused
45//!
46//! An interior NUL, which a `PCWSTR` cannot carry: Win32 would read the path as ending there and
47//! act on a shorter path than the caller named. It is refused when the request is built, where the
48//! caller can still act on it, and so is a failure to read the current directory. Nothing else about
49//! a path is checked here.
50
51use std::fmt;
52
53use windows_sys::Win32::System::Environment::GetCurrentDirectoryW;
54use wtf_string::{Wtf16Str, Wtf16String};
55
56use crate::outcome::{Win32Error, perform_nonzero};
57
58/// How many times the buffer is grown before giving up, as in [`crate::full_path`].
59const MAX_ATTEMPTS: usize = 8;
60
61/// The buffer size the first attempt uses, in characters.
62const FIRST_ATTEMPT_CHARS: usize = 260;
63
64const BACKSLASH: u16 = b'\\' as u16;
65const SLASH: u16 = b'/' as u16;
66const COLON: u16 = b':' as u16;
67const DOT: u16 = b'.' as u16;
68const QUESTION: u16 = b'?' as u16;
69const SPACE: u16 = b' ' as u16;
70const DIGIT_ZERO: u16 = b'0' as u16;
71const DIGIT_NINE: u16 = b'9' as u16;
72
73/// The superscript one, two and three, which Microsoft's list also names after `COM` and `LPT`.
74const SUPERSCRIPT_DIGITS: [u16; 3] = [0x00B9, 0x00B2, 0x00B3];
75
76/// The names that are reserved whole. Changing one changes which children are grafted.
77const RESERVED_NAMES: [&[u8]; 6] = [b"CON", b"PRN", b"AUX", b"NUL", b"CONIN$", b"CONOUT$"];
78
79/// The names that are reserved with a digit after them.
80const RESERVED_NAMES_WITH_DIGIT: [&[u8]; 2] = [b"COM", b"LPT"];
81
82/// Why a path could not be captured for a request.
83///
84/// The failures are the two a path can have at construction: it cannot be carried to Win32 as it
85/// is, or the current directory it is to be fixed to could not be read. The second mirrors
86/// [`crate::full_path::FullPathError`], which shares the retry shape and so the vocabulary.
87#[derive(Debug)]
88#[non_exhaustive]
89pub enum PathCaptureError {
90 /// The path contains a NUL before its end. Win32 reads a path up to its first NUL, so it would
91 /// act on a shorter path than the one named.
92 InteriorNul,
93 /// `GetCurrentDirectoryW` was refused, with the raw code unaltered.
94 CurrentDirectory(Win32Error),
95 /// The size of the current directory kept changing, so the retry was abandoned.
96 CurrentDirectoryUnstable {
97 /// How many attempts were made before giving up.
98 attempts: usize,
99 },
100}
101
102impl fmt::Display for PathCaptureError {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 match self {
105 Self::InteriorNul => f.write_str("the path contains an interior NUL"),
106 Self::CurrentDirectory(error) => write!(f, "GetCurrentDirectoryW: {error}"),
107 Self::CurrentDirectoryUnstable { attempts } => write!(
108 f,
109 "GetCurrentDirectoryW: the required size changed on each of {attempts} attempts"
110 ),
111 }
112 }
113}
114
115impl std::error::Error for PathCaptureError {
116 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
117 match self {
118 Self::CurrentDirectory(error) => Some(error),
119 Self::InteriorNul | Self::CurrentDirectoryUnstable { .. } => None,
120 }
121 }
122}
123
124/// A path as given, and the path to hand to Win32 for it.
125///
126/// The two are the same unless the path was grafted, by the rules in this module's documentation. It is
127/// what [`OpenFile`](crate::OpenFile) and [`WatchDirectory`](crate::WatchDirectory) hold, and is public
128/// for a crate that builds a request of its own and performs it later on another thread, so that it
129/// fixes the meaning of a relative path by the same contract and does not keep a second one (D-30).
130///
131/// # Example
132///
133/// ```
134/// use windows_namespace_request_sys::AnchoredPath;
135/// use wtf_string::Wtf16String;
136///
137/// // An absolute path is handed to Win32 as it was given.
138/// let absolute = AnchoredPath::new(Wtf16String::from(r"C:\Windows"))?;
139/// assert_eq!(absolute.given(), absolute.win32());
140///
141/// // A relative name is grafted onto the current directory as it is now.
142/// let relative = AnchoredPath::new(Wtf16String::from("a-child"))?;
143/// assert_eq!(relative.given().to_string_lossy(), "a-child");
144/// let expected = std::env::current_dir()?.join("a-child");
145/// assert_eq!(relative.win32().to_string_lossy(), expected.to_string_lossy());
146/// # Ok::<(), Box<dyn std::error::Error>>(())
147/// ```
148#[derive(Clone, Debug, PartialEq, Eq, Hash)]
149pub struct AnchoredPath {
150 given: Wtf16String,
151 grafted: Option<Wtf16String>,
152}
153
154impl AnchoredPath {
155 /// Anchors `given` to the process current directory as it is now.
156 ///
157 /// # Errors
158 ///
159 /// [`PathCaptureError::InteriorNul`] for a path with a NUL before its end, and otherwise the
160 /// failure of `GetCurrentDirectoryW`, which is called only for a path whose shape permits a
161 /// graft.
162 pub fn new(given: Wtf16String) -> Result<Self, PathCaptureError> {
163 if given.has_interior_nul() {
164 return Err(PathCaptureError::InteriorNul);
165 }
166 if !has_graftable_shape(given.as_units()) {
167 return Ok(Self {
168 given,
169 grafted: None,
170 });
171 }
172 let root = capture_current_directory()?;
173 Ok(Self::anchored_at(given, &root))
174 }
175
176 /// Anchors `given` to `root`, which stands for the current directory.
177 ///
178 /// No call is made, so what the rules do is testable without touching process state.
179 pub(crate) fn anchored_at(given: Wtf16String, root: &Wtf16Str) -> Self {
180 let grafted =
181 graft(given.as_units(), root.as_units()).map(|units| Wtf16String::from_units(&units));
182 Self { given, grafted }
183 }
184
185 /// The path exactly as the caller gave it.
186 #[must_use]
187 pub fn given(&self) -> &Wtf16Str {
188 &self.given
189 }
190
191 /// The path that is handed to Win32.
192 #[must_use]
193 pub fn win32(&self) -> &Wtf16Str {
194 self.grafted.as_ref().unwrap_or(&self.given)
195 }
196
197 /// Whether the path was grafted onto the captured directory.
198 #[cfg(test)]
199 pub(crate) fn is_grafted(&self) -> bool {
200 self.grafted.is_some()
201 }
202
203 /// A NUL-terminated pointer to [`win32`](Self::win32), for a Win32 call.
204 ///
205 /// Borrows from this value and must not outlive it.
206 pub(crate) fn win32_terminated(&self) -> *const u16 {
207 self.grafted
208 .as_ref()
209 .unwrap_or(&self.given)
210 .as_terminated_ptr()
211 }
212}
213
214/// The root joined to the child, when the child may be grafted onto that root.
215fn graft(child: &[u16], root: &[u16]) -> Option<Vec<u16>> {
216 if root.is_empty() || !has_graftable_shape(child) {
217 return None;
218 }
219 if is_verbatim_or_device(root) {
220 return None;
221 }
222 if is_unc(root) && has_parent_component(child) {
223 return None;
224 }
225
226 let needs_separator = root.last() != Some(&BACKSLASH);
227 let mut joined = Vec::with_capacity(root.len() + 1 + child.len());
228 joined.extend_from_slice(root);
229 if needs_separator {
230 joined.push(BACKSLASH);
231 }
232 joined.extend_from_slice(child);
233 Some(joined)
234}
235
236/// Rules 1, 2, 3 and 5: what can be decided without the directory.
237fn has_graftable_shape(child: &[u16]) -> bool {
238 let Some(first) = child.first() else {
239 return false;
240 };
241 if is_separator(*first) || child.contains(&COLON) {
242 return false;
243 }
244 !components(child).any(is_reserved_device_name)
245}
246
247/// Rule 6: a directory with the `\\?\` or `\\.\` prefix, which only backslashes make.
248fn is_verbatim_or_device(root: &[u16]) -> bool {
249 matches!(root, [BACKSLASH, BACKSLASH, prefix, BACKSLASH, ..] if *prefix == QUESTION || *prefix == DOT)
250}
251
252/// Rule 4's two halves: a directory that is a UNC path, and a child with a `..` component.
253fn is_unc(root: &[u16]) -> bool {
254 matches!(root, [first, second, ..] if is_separator(*first) && is_separator(*second))
255}
256
257fn has_parent_component(child: &[u16]) -> bool {
258 components(child).any(|component| component == [DOT, DOT])
259}
260
261fn is_separator(unit: u16) -> bool {
262 unit == BACKSLASH || unit == SLASH
263}
264
265fn components(path: &[u16]) -> impl Iterator<Item = &[u16]> {
266 path.split(|unit| is_separator(*unit))
267}
268
269/// Rule 5, for one component.
270fn is_reserved_device_name(component: &[u16]) -> bool {
271 let stem = component.split(|unit| *unit == DOT).next().unwrap_or(&[]);
272 let end = stem
273 .iter()
274 .rposition(|unit| *unit != SPACE)
275 .map_or(0, |last| last + 1);
276 let stem = &stem[..end];
277
278 if RESERVED_NAMES.iter().any(|name| equals_ascii(stem, name)) {
279 return true;
280 }
281 let Some((digit, name)) = stem.split_last() else {
282 return false;
283 };
284 let is_digit = (DIGIT_ZERO..=DIGIT_NINE).contains(digit) || SUPERSCRIPT_DIGITS.contains(digit);
285 is_digit
286 && RESERVED_NAMES_WITH_DIGIT
287 .iter()
288 .any(|reserved| equals_ascii(name, reserved))
289}
290
291/// Whether `units` is `name`, ignoring ASCII case. A unit that is not ASCII never matches.
292fn equals_ascii(units: &[u16], name: &[u8]) -> bool {
293 units.len() == name.len()
294 && units.iter().zip(name).all(|(unit, expected)| {
295 u8::try_from(*unit).is_ok_and(|unit| unit.eq_ignore_ascii_case(expected))
296 })
297}
298
299fn capture_current_directory() -> Result<Wtf16String, PathCaptureError> {
300 read_current_directory(|buffer| {
301 let capacity = u32::try_from(buffer.len()).unwrap_or(u32::MAX);
302 perform_nonzero(|| {
303 // SAFETY: the buffer is writable for `capacity` characters.
304 unsafe { GetCurrentDirectoryW(capacity, buffer.as_mut_ptr()) }
305 })
306 })
307}
308
309/// The retry around the call, with the call as a parameter so that its answers can be chosen.
310fn read_current_directory(
311 mut call: impl FnMut(&mut [u16]) -> Result<u32, Win32Error>,
312) -> Result<Wtf16String, PathCaptureError> {
313 let mut buffer = vec![0_u16; FIRST_ATTEMPT_CHARS];
314
315 for _ in 0..MAX_ATTEMPTS {
316 let written = call(&mut buffer).map_err(PathCaptureError::CurrentDirectory)? as usize;
317 if written < buffer.len() {
318 // Success: `written` excludes the terminator.
319 return Ok(Wtf16String::from_units(&buffer[..written]));
320 }
321 // Too small: `written` is the size required including the terminator.
322 buffer.resize(written, 0);
323 }
324
325 Err(PathCaptureError::CurrentDirectoryUnstable {
326 attempts: MAX_ATTEMPTS,
327 })
328}
329
330#[cfg(test)]
331mod tests;