Skip to main content

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;