Skip to main content

windows_namespace_request_sys/
full_path.rs

1// Copyright (c) Mike Grier.
2
3//! The `GetFullPathNameW` entry.
4//!
5//! Entry 9 of the audited catalogue, and the only one that takes neither a
6//! handle nor produces one.
7//!
8//! # What it solves, and what it leaves standing
9//!
10//! This call **does not verify what it produces**: it will happily resolve a
11//! path to something that does not exist, and it reports no error for one.
12//!
13//! That is the documented guarantee, and it is deliberately narrower than
14//! "touches no filesystem", which earlier revisions of this doc claimed.
15//! Microsoft specifies that the function does not verify that the resulting
16//! path and file name are valid or that they name an existing file; it does not
17//! specify that no I/O occurs.
18//!
19//! **And on one form it demonstrably does touch the filesystem.** Resolving a
20//! drive-relative path for a drive other than the current one validates that
21//! drive's recorded entry against the filesystem, and rewrites it when the
22//! entry does not name an existing directory -- see "The drive-relative form
23//! writes process state" below. So the narrow guarantee is the one to rely on
24//! precisely because the broad one is false, not merely unproven. A caller
25//! wanting existence must still open.
26//!
27//! It does **two** things, and keeping them apart is the whole reason this
28//! entry exists:
29//!
30//! 1. It rewrites the string. `.` and `..` are collapsed, `/` becomes `\`, and
31//!    trailing dots and spaces are trimmed -- but **not uniformly across
32//!    components**, and an earlier revision of this list said so without
33//!    qualification. Measured: the *final* component loses any run of trailing
34//!    dots and spaces (`C:\name...` and `C:\name   ` both become `C:\name`),
35//!    while an *intermediate* component loses a single trailing dot and nothing
36//!    else -- `C:\a.\b` becomes `C:\a\b`, but `C:\a...\b` and `C:\a \b` are
37//!    returned unchanged. This part's **output is a function of the input
38//!    alone**: `C:\a\..\b` becomes `C:\b` whatever the current directory
39//!    happens to be, and whether or not `C:\a` exists.
40//!
41//!    Stated that way deliberately. Earlier revisions said it "reads no process
42//!    state", which the evidence does not reach: varying the current directory
43//!    and getting the same answer shows the output does not DEPEND on it, not
44//!    that nothing was read. That is the same overreach this doc removes from
45//!    the current-drive entry below, and it sat here in the positive half while
46//!    seven reviews corrected the negative one. Invariance is the whole claim,
47//!    and it is also all a caller needs: this half can be reasoned about
48//!    without knowing the process's state.
49//! 2. It **roots** a path that is not fully qualified, using mutable process
50//!    state -- and on one form it also *changes* that state. There are three
51//!    such forms:
52//!
53//!    * A relative path like `rel.txt` is rooted at the *process current
54//!      directory*.
55//!    * A root-relative path like `\foo` takes only the *root* of that
56//!      directory, giving `C:\foo` rather than its subtree -- and
57//!      `\\server\share\foo` when the current directory is a UNC path, which
58//!      is why this says root and not drive.
59//!    * A drive-relative path like `C:foo` is rooted at the entry Windows
60//!      keeps for that drive in the hidden `=C:` environment variables. For
61//!      the *current* drive that entry makes no difference to the result and
62//!      the process current directory wins.
63//!
64//! **A whole class of input short-circuits both.** When the input names a
65//! legacy device and nothing else, it resolves into the device namespace and is
66//! not rooted at all: `CON` becomes `\\.\CON`, not a file under the current
67//! directory.
68//!
69//! "And nothing else" is doing real work, and is looser than it first looks.
70//! These all reach a device: a bare name (`CON`), a trailing colon (`CON:`,
71//! `CON::`), trailing dots or spaces (`CON.`, `CON `), and any casing
72//! (`con`). These do not, and root normally: anything with more of a path
73//! around it (`CON.txt`, `a\CON`, `.\CON`, `CON:x`), and `\CON`, which
74//! becomes `Q:\CON` for a current directory on `Q:`.
75//!
76//! **`NUL` does not follow that second list, and it is the only member that
77//! does not.** The paragraph above was written from `CON` and stated of the
78//! whole set; measured across all eight accepted names, seven behave as it
79//! says and `NUL` short-circuits as the *final component of any path*,
80//! however much path is in front of it:
81//!
82//! | input | `CON` | `NUL` |
83//! |---|---|---|
84//! | `X` | `\\.\CON` | `\\.\NUL` |
85//! | `\X` | `Q:\CON` | `\\.\NUL` |
86//! | `.\X` | `Q:\...\CON` | `\\.\NUL` |
87//! | `a\X` | `Q:\...\a\CON` | `\\.\NUL` |
88//! | `C:\X` | `C:\CON` | `\\.\NUL` |
89//! | `X.txt` | rooted | rooted |
90//! | `X:x` | rooted | rooted |
91//!
92//! So a *fully qualified* path can still resolve to a device, which is the
93//! part worth knowing: `prepare(r"C:\NUL")` yields `\\.\NUL`, and a caller
94//! treating a rooted path as proof it names a file on that volume is wrong for
95//! this one name. Only a suffix (`NUL.txt`, `NUL:x`) takes it out.
96//!
97//! **Everything above describes what this call returns, and a review asked
98//! whether that is a safe boundary for what a later `CreateFileW` does.**
99//! Measured on this build, at the open rather than the resolver: creating
100//! `<dir>\NUL` returns a `FILE_TYPE_CHAR` handle and leaves nothing on disk,
101//! while `<dir>\CON`, `<dir>\CON.txt` and `<dir>\NUL.txt` each create an
102//! ordinary `FILE_TYPE_DISK` file. The two layers agree -- the reservation
103//! lives in *rooting*, so a name that roots normally opens normally, and `NUL`
104//! reaches the device at both layers.
105//!
106//! That agreement is a measurement of one build, not a guarantee this crate
107//! makes. The durable statement is the narrower one: these paragraphs describe
108//! the RESOLVER's output. A caller sanitising untrusted names should decide
109//! against what it will do with the result, not infer open-time safety from a
110//! resolved spelling.
111//!
112//! **Do not build a name filter from the list below.** The accepted names are
113//! `CON`, `NUL`, `PRN`, `AUX`, `CONIN$`, `CONOUT$`, and `COM`/`LPT`
114//! followed by a single digit -- where "digit" includes the *superscripts*
115//! `COM\u{00b9}`, `COM\u{00b2}` and `COM\u{00b3}` as well as `1`-`9`. Those are
116//! written as Rust escapes deliberately: spelled `COM^1` with a caret, as an
117//! earlier revision had them, a reader copying the text gets an ordinary
118//! filename rather than a device.
119//! An exhaustive scan of the character after `COM` accepts exactly
120//! U+0031-U+0039, U+00B2, U+00B3 and U+00B9 on the tested build; `COM0` and
121//! `COM10` are not devices. The superscripts are precisely the sort of member a
122//! hand-written denylist omits, and this documentation asserted a list without
123//! them until a review measured it -- so treat the set as *observed on one
124//! build*, and prefer letting this call answer the question over reimplementing
125//! its judgement.
126//!
127//! So the call is **not** lexical as a whole, and describing it that way -- as
128//! an earlier revision of this doc did, in the sentence immediately before the
129//! one describing the current directory it reads -- loses exactly the half that
130//! matters here. A fully-qualified input resolves to the same output every
131//! time; an input that is rooted resolves to different outputs in the same
132//! process at different times, and pinning *that* is the property being bought.
133//! (Not every unqualified input is rooted, which is the point of the device
134//! short-circuit above: `CON` is unqualified and yet invariant.)
135//!
136//! So it solves exactly one problem -- the process current directory is shared
137//! mutable state that any thread can change, so a relative path means something
138//! different depending on *when* it is resolved. Performing this on the
139//! submitting thread pins that meaning.
140//!
141//! # Why not a genuinely lexical canonicalizer
142//!
143//! Two exist: `PathCchCanonicalizeEx` and `PathAllocCanonicalize`. Both
144//! canonicalize the string without rooting it.
145//!
146//! **They are the wrong call here, and the reason is a semantic difference, not
147//! a cost one.** Resolving against the current directory *at submission* is what
148//! this crate is buying. A lexical canonicalizer would leave a relative path
149//! still relative, so its meaning would be decided on the worker thread at
150//! execution time, against a current directory any thread may have changed in
151//! between -- reintroducing exactly the race preparation exists to close. What
152//! they omit is the part that is wanted.
153//!
154//! **No cost comparison is claimed, deliberately.** Nothing in this repository
155//! benchmarks either alternative, Microsoft documents behaviour rather than
156//! relative cost, and `PathAllocCanonicalize` allocates its own result -- so
157//! "cheaper" would be a guess. It is also not needed: the decision rests on the
158//! rooting semantics alone. Nor is either one reliably free of process state,
159//! since `PATHCCH_ALLOW_LONG_PATHS` makes `PathCchCanonicalizeEx` consult the
160//! process long-path setting unless the FORCE variant is used.
161//!
162//! Recorded so the next reader does not re-derive it. If this reasoning is ever
163//! wrong -- for a consumer that genuinely wants a pure string operation and has
164//! resolved relativity some other way -- the alternatives are named here.
165//!
166//! # The drive-relative form writes process state, and touches the filesystem
167//!
168//! Measured, and it overturns what four earlier revisions of this doc asserted.
169//! Resolving `X:foo` for a drive that is **not** the current one does not
170//! merely read the `=X:` entry:
171//!
172//! * An **accepted** entry is used **verbatim**, including a directory on a
173//!   *different* drive. With `=X:` set to `C:\Windows`, `X:foo` resolves to
174//!   `C:\Windows\foo`, so "that drive's own current directory" describes the
175//!   convention the entry usually holds, not a guarantee about the result.
176//!   Verbatim really means verbatim: `C:\Windows\` yields `C:\Windows\\foo`,
177//!   with no normalisation at the join.
178//! * Otherwise the entry is **written** to the drive root and that is used --
179//!   created when absent, so this happens on a pristine host and not only on
180//!   one carrying a stale entry. The write mutates the process environment
181//!   block as a side effect of what reads like a pure query.
182//!
183//! **Acceptance needs both a shape and an existence check, and the observed
184//! necessary conditions are worth listing because they are not guessable.** An
185//! entry naming a directory that exists is still rejected unless it is already
186//! in fully-qualified `X:\...` form: measured on one build, `C:/Windows/System32`,
187//! `C:\Windows\System32\.`, `C:\Windows\System32\..\System32` and
188//! `\\?\C:\Windows\System32` were each rejected while naming the same existing
189//! directory that `C:\Windows\System32` was accepted for. An existing *file* and
190//! a missing directory are rejected too, so existence is checked as well -- but
191//! saying the gate is "a filesystem query rather than a syntax test", as a draft
192//! of this doc did, states a mechanism the evidence contradicts. It is both, and
193//! this list is a set of observations rather than a specification.
194//!
195//! For the current drive neither happens, and the guarantee is stated at the
196//! boundary observation can actually reach: **the entry makes no difference to
197//! the result, and is not rewritten.** Both halves are measured -- an entry the
198//! non-current arm would honour verbatim is installed and the process directory
199//! wins anyway, and an entry the non-current arm would replace is left
200//! untouched. Whether Windows *reads* it internally is not established, because
201//! setting a value and observing the result cannot separate "not read" from
202//! "read and ignored". An earlier revision said "not consulted", which is the
203//! same overreach this section corrects two paragraphs above.
204//!
205//! This is why the "does not verify what it produces" guarantee above is worth
206//! stating narrowly. The broad reading -- that the call touches no filesystem --
207//! is not merely unproven, it is false here. Earlier revisions said the
208//! opposite, reasoning that the current directory lives in the PEB and the
209//! `=X:` variables in the environment block and that both are ordinary process
210//! memory. The reasoning was sound and the conclusion wrong, which is the
211//! standing hazard this crate keeps meeting: a mechanism argued from the data
212//! sources rather than measured.
213//!
214//! # What a resolution costs
215//!
216//! The figure the repo's own instrument produces is a **bound, not this call's
217//! cost**, and the difference matters. On x86_64 `probe-request-cost` measures
218//! building an open request as a construct-and-drop cycle at roughly 210 ns and
219//! cloning an already-resolved path at roughly 45 ns. The ~165 ns between them
220//! is what recycling a resolved path recovers, and that is all it is.
221//!
222//! **That probe exercises [`crate::path::prepare`], not this module**, and the
223//! two have different allocation shapes -- which is itself why the gap cannot be
224//! read as this call's cost. `prepare` copies the input and then allocates a
225//! `MAX_PATH` output buffer, so two allocations against the clone's one, and the
226//! builder chain sits on top. [`ResolveFullPath`] takes its input already owned
227//! and allocates one buffer per attempt instead. Either way the allocator work
228//! is the crate's, not `GetFullPathNameW`'s, and attributing the gap to the call
229//! -- as a draft of this doc did -- credits it with the work the same sentence
230//! is busy excluding.
231//!
232//! Timed on its own -- input already marshalled, output buffer pre-allocated,
233//! so no allocation is in the loop -- the call costs about **110 ns** on this
234//! host, roughly two thirds of that gap. That measurement is a direct one taken
235//! for this note and is *not* something the probe reports; no instrument in
236//! this repository isolates the call, and the honest reading of
237//! `probe-request-cost` alone is an upper bound.
238//!
239//! It does **not** solve the session-relative drive-letter hazard, and saying
240//! so plainly matters more than the part it does solve. `GetFullPathNameW`
241//! never expands a drive letter, and a drive letter is resolved against the
242//! logon session of whatever token is in effect at open time. A path resolved
243//! here and opened on a worker under a captured token from another logon
244//! session can still name a different device. That hazard is open at the
245//! workspace level; this entry inherits it and does not close it.
246//!
247//! A consumer that wants the *final*, filesystem-verified path of an object
248//! wants [`crate::final_path`], which requires a handle and therefore an open.
249
250use std::fmt;
251
252use windows_sys::Win32::Storage::FileSystem::GetFullPathNameW;
253use wtf_string::{Wtf16Str, Wtf16String};
254
255use crate::outcome::{Win32Error, perform_nonzero};
256
257/// How many times the buffer is grown before giving up.
258///
259/// As in [`crate::final_path`], one retry is the expected path; more means the
260/// answer is changing under us.
261const MAX_ATTEMPTS: usize = 8;
262
263/// The buffer size the first attempt uses, in characters.
264const FIRST_ATTEMPT_CHARS: usize = 260;
265
266/// Why a full path could not be resolved.
267///
268/// This mirrors [`crate::final_path::FinalPathError`] deliberately: the two
269/// entries share a retry shape, so they share a failure vocabulary. An earlier
270/// revision returned a synthesized `ERROR_INSUFFICIENT_BUFFER` for the unstable
271/// case, which left a caller unable to tell that apart from the same code
272/// arriving from Windows, and made this entry the one place in the crate that
273/// invented a code Win32 had not produced.
274#[derive(Debug)]
275#[non_exhaustive]
276pub enum FullPathError {
277    /// Windows refused the call, with the raw code unaltered.
278    Win32(Win32Error),
279    /// The required size kept changing, so the retry was abandoned.
280    ///
281    /// A path does not normally grow between two calls a microsecond apart, so
282    /// this means something pathological rather than a transient. It is
283    /// reported rather than looped on, because spinning here would hang the
284    /// worker that a consumer moved this call onto in the first place.
285    Unstable {
286        /// How many attempts were made before giving up.
287        attempts: usize,
288    },
289}
290
291impl fmt::Display for FullPathError {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        match self {
294            Self::Win32(error) => write!(f, "GetFullPathNameW: {error}"),
295            Self::Unstable { attempts } => write!(
296                f,
297                "GetFullPathNameW: the required size changed on each of {attempts} attempts"
298            ),
299        }
300    }
301}
302
303impl std::error::Error for FullPathError {
304    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
305        match self {
306            Self::Win32(error) => Some(error),
307            Self::Unstable { .. } => None,
308        }
309    }
310}
311
312impl From<Win32Error> for FullPathError {
313    fn from(error: Win32Error) -> Self {
314        Self::Win32(error)
315    }
316}
317
318/// An owned, marshalable parameter set for `GetFullPathNameW`.
319///
320/// # Example
321///
322/// ```
323/// use windows_namespace_request_sys::full_path::ResolveFullPath;
324/// use wtf_string::Wtf16String;
325///
326/// // `.` and `..` are collapsed as string work, with no component verified:
327/// // this holds whether or not `C:\Windows\System32` exists.
328/// let resolved = ResolveFullPath::new(Wtf16String::from(r"C:\Windows\System32\..\.\Temp"))
329///     .perform()?
330///     .to_string_lossy();
331///
332/// assert_eq!(resolved, r"C:\Windows\Temp");
333/// # Ok::<(), Box<dyn std::error::Error>>(())
334/// ```
335///
336/// # Example: it does not check existence
337///
338/// ```
339/// use windows_namespace_request_sys::full_path::ResolveFullPath;
340/// use wtf_string::Wtf16String;
341///
342/// // A path to nothing resolves perfectly happily, because the call
343/// // does not verify it. A consumer wanting a verified path wants an open
344/// // plus GetFinalPathNameByHandleW instead.
345/// let resolved = ResolveFullPath::new(Wtf16String::from(r"C:\no-such-directory\..\file.txt"))
346///     .perform()?
347///     .to_string_lossy();
348///
349/// assert_eq!(resolved, r"C:\file.txt");
350/// # Ok::<(), Box<dyn std::error::Error>>(())
351/// ```
352#[derive(Clone, Debug)]
353#[must_use = "an unperformed request resolves nothing"]
354pub struct ResolveFullPath {
355    path: Wtf16String,
356}
357
358impl ResolveFullPath {
359    /// Begins a request to resolve `path`.
360    ///
361    /// Takes a raw path rather than a [`crate::path::PreparedPath`], because
362    /// preparation is what this call *performs*. Handing it an already-prepared
363    /// path would be resolving twice.
364    pub fn new(path: Wtf16String) -> Self {
365        Self { path }
366    }
367
368    /// The path this request will resolve.
369    #[must_use]
370    pub fn path(&self) -> &Wtf16Str {
371        &self.path
372    }
373
374    /// Performs the call on the calling thread, growing the buffer as needed.
375    ///
376    /// Resolution happens against the current directory of **whichever thread
377    /// performs this**, which is the one thing a caller must keep in mind: a
378    /// request built on a submitter and performed on a worker resolves against
379    /// the process current directory as it stands at *performance* time.
380    /// [`crate::path::prepare`] is the function for pinning that at
381    /// construction.
382    ///
383    /// # Errors
384    ///
385    /// Returns [`FullPathError::Win32`] with the raw Win32 code, unaltered, or
386    /// [`FullPathError::Unstable`] if the required size kept changing.
387    pub fn perform(&self) -> Result<Wtf16String, FullPathError> {
388        let mut capacity = FIRST_ATTEMPT_CHARS;
389
390        for _ in 0..MAX_ATTEMPTS {
391            let mut buffer = Wtf16String::with_capacity(capacity);
392            let requested = u32::try_from(capacity).unwrap_or(u32::MAX);
393
394            let written = perform_nonzero(|| {
395                // SAFETY: the input has no interior NUL by Wtf16String's own
396                // invariant for a terminated pointer, and the buffer is
397                // writable for `requested` characters. The buffer's invariant
398                // is restored below before it is observed.
399                unsafe {
400                    GetFullPathNameW(
401                        self.path.as_terminated_ptr(),
402                        requested,
403                        buffer.as_mut_ptr(),
404                        core::ptr::null_mut(),
405                    )
406                }
407            })?;
408
409            let written = written as usize;
410            if written < capacity {
411                // Success: `written` excludes the terminator.
412                // SAFETY: exactly `written` content characters were written,
413                // within the requested capacity.
414                unsafe { buffer.set_len_from_ffi(written) };
415                return Ok(buffer);
416            }
417
418            // Too small: `written` is the size required *including* the
419            // terminator, and nothing usable was written.
420            capacity = written;
421        }
422
423        // The required size kept changing across every attempt. Report it
424        // rather than looping, for the reason final_path gives: spinning here
425        // would hang the worker a consumer moved this call onto.
426        //
427        // Reported as its own variant rather than as a Win32 code. Windows has
428        // none for "and it kept happening", and the nearest candidate --
429        // `ERROR_INSUFFICIENT_BUFFER`, which each individual attempt really did
430        // hit -- is one Win32 can also return on its own, so borrowing it would
431        // leave a caller unable to tell the two apart.
432        Err(FullPathError::Unstable {
433            attempts: MAX_ATTEMPTS,
434        })
435    }
436}
437
438impl crate::request::Request for ResolveFullPath {
439    type Error = FullPathError;
440    type Output = Wtf16String;
441
442    fn perform(&self) -> Result<Wtf16String, FullPathError> {
443        Self::perform(self)
444    }
445}
446
447#[cfg(test)]
448mod tests;