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