Expand description
The GetFullPathNameW entry.
Entry 9 of the audited catalogue, and the only one that takes neither a handle nor produces one.
§What it solves, and what it leaves standing
This call does not verify what it produces: it will happily resolve a path to something that does not exist, and it reports no error for one.
That is the documented guarantee, and it is deliberately narrower than “touches no filesystem”, which earlier revisions of this doc claimed. Microsoft specifies that the function does not verify that the resulting path and file name are valid or that they name an existing file; it does not specify that no I/O occurs.
And on one form it demonstrably does touch the filesystem. Resolving a drive-relative path for a drive other than the current one validates that drive’s recorded entry against the filesystem, and rewrites it when the entry does not name an existing directory – see “The drive-relative form writes process state” below. So the narrow guarantee is the one to rely on precisely because the broad one is false, not merely unproven. A caller wanting existence must still open.
It does two things, and keeping them apart is the whole reason this entry exists:
-
It rewrites the string.
.and..are collapsed,/becomes\, and trailing dots and spaces are trimmed – but not uniformly across components, and an earlier revision of this list said so without qualification. Measured: the final component loses any run of trailing dots and spaces (C:\name...andC:\nameboth becomeC:\name), while an intermediate component loses a single trailing dot and nothing else –C:\a.\bbecomesC:\a\b, butC:\a...\bandC:\a \bare returned unchanged. This part’s output is a function of the input alone:C:\a\..\bbecomesC:\bwhatever the current directory happens to be, and whether or notC:\aexists.Stated that way deliberately. Earlier revisions said it “reads no process state”, which the evidence does not reach: varying the current directory and getting the same answer shows the output does not DEPEND on it, not that nothing was read. That is the same overreach this doc removes from the current-drive entry below, and it sat here in the positive half while seven reviews corrected the negative one. Invariance is the whole claim, and it is also all a caller needs: this half can be reasoned about without knowing the process’s state.
-
It roots a path that is not fully qualified, using mutable process state – and on one form it also changes that state. There are three such forms:
- A relative path like
rel.txtis rooted at the process current directory. - A root-relative path like
\footakes only the root of that directory, givingC:\foorather than its subtree – and\\server\share\foowhen the current directory is a UNC path, which is why this says root and not drive. - A drive-relative path like
C:foois rooted at the entry Windows keeps for that drive in the hidden=C:environment variables. For the current drive that entry makes no difference to the result and the process current directory wins.
- A relative path like
A whole class of input short-circuits both. When the input names a
legacy device and nothing else, it resolves into the device namespace and is
not rooted at all: CON becomes \\.\CON, not a file under the current
directory.
“And nothing else” is doing real work, and is looser than it first looks.
These all reach a device: a bare name (CON), a trailing colon (CON:,
CON::), trailing dots or spaces (CON., CON ), and any casing
(con). These do not, and root normally: anything with more of a path
around it (CON.txt, a\CON, .\CON, CON:x), and \CON, which
becomes Q:\CON for a current directory on Q:.
NUL does not follow that second list, and it is the only member that
does not. The paragraph above was written from CON and stated of the
whole set; measured across all eight accepted names, seven behave as it
says and NUL short-circuits as the final component of any path,
however much path is in front of it:
| input | CON | NUL |
|---|---|---|
X | \\.\CON | \\.\NUL |
\X | Q:\CON | \\.\NUL |
.\X | Q:\...\CON | \\.\NUL |
a\X | Q:\...\a\CON | \\.\NUL |
C:\X | C:\CON | \\.\NUL |
X.txt | rooted | rooted |
X:x | rooted | rooted |
So a fully qualified path can still resolve to a device, which is the
part worth knowing: prepare(r"C:\NUL") yields \\.\NUL, and a caller
treating a rooted path as proof it names a file on that volume is wrong for
this one name. Only a suffix (NUL.txt, NUL:x) takes it out.
Everything above describes what this call returns, and a review asked
whether that is a safe boundary for what a later CreateFileW does.
Measured on this build, at the open rather than the resolver: creating
<dir>\NUL returns a FILE_TYPE_CHAR handle and leaves nothing on disk,
while <dir>\CON, <dir>\CON.txt and <dir>\NUL.txt each create an
ordinary FILE_TYPE_DISK file. The two layers agree – the reservation
lives in rooting, so a name that roots normally opens normally, and NUL
reaches the device at both layers.
That agreement is a measurement of one build, not a guarantee this crate makes. The durable statement is the narrower one: these paragraphs describe the RESOLVER’s output. A caller sanitising untrusted names should decide against what it will do with the result, not infer open-time safety from a resolved spelling.
Do not build a name filter from the list below. The accepted names are
CON, NUL, PRN, AUX, CONIN$, CONOUT$, and COM/LPT
followed by a single digit – where “digit” includes the superscripts
COM\u{00b9}, COM\u{00b2} and COM\u{00b3} as well as 1-9. Those are
written as Rust escapes deliberately: spelled COM^1 with a caret, as an
earlier revision had them, a reader copying the text gets an ordinary
filename rather than a device.
An exhaustive scan of the character after COM accepts exactly
U+0031-U+0039, U+00B2, U+00B3 and U+00B9 on the tested build; COM0 and
COM10 are not devices. The superscripts are precisely the sort of member a
hand-written denylist omits, and this documentation asserted a list without
them until a review measured it – so treat the set as observed on one
build, and prefer letting this call answer the question over reimplementing
its judgement.
So the call is not lexical as a whole, and describing it that way – as
an earlier revision of this doc did, in the sentence immediately before the
one describing the current directory it reads – loses exactly the half that
matters here. A fully-qualified input resolves to the same output every
time; an input that is rooted resolves to different outputs in the same
process at different times, and pinning that is the property being bought.
(Not every unqualified input is rooted, which is the point of the device
short-circuit above: CON is unqualified and yet invariant.)
So it solves exactly one problem – the process current directory is shared mutable state that any thread can change, so a relative path means something different depending on when it is resolved. Performing this on the submitting thread pins that meaning.
§Why not a genuinely lexical canonicalizer
Two exist: PathCchCanonicalizeEx and PathAllocCanonicalize. Both
canonicalize the string without rooting it.
They are the wrong call here, and the reason is a semantic difference, not a cost one. Resolving against the current directory at submission is what this crate is buying. A lexical canonicalizer would leave a relative path still relative, so its meaning would be decided on the worker thread at execution time, against a current directory any thread may have changed in between – reintroducing exactly the race preparation exists to close. What they omit is the part that is wanted.
No cost comparison is claimed, deliberately. Nothing in this repository
benchmarks either alternative, Microsoft documents behaviour rather than
relative cost, and PathAllocCanonicalize allocates its own result – so
“cheaper” would be a guess. It is also not needed: the decision rests on the
rooting semantics alone. Nor is either one reliably free of process state,
since PATHCCH_ALLOW_LONG_PATHS makes PathCchCanonicalizeEx consult the
process long-path setting unless the FORCE variant is used.
Recorded so the next reader does not re-derive it. If this reasoning is ever wrong – for a consumer that genuinely wants a pure string operation and has resolved relativity some other way – the alternatives are named here.
§The drive-relative form writes process state, and touches the filesystem
Measured, and it overturns what four earlier revisions of this doc asserted.
Resolving X:foo for a drive that is not the current one does not
merely read the =X: entry:
- An accepted entry is used verbatim, including a directory on a
different drive. With
=X:set toC:\Windows,X:fooresolves toC:\Windows\foo, so “that drive’s own current directory” describes the convention the entry usually holds, not a guarantee about the result. Verbatim really means verbatim:C:\Windows\yieldsC:\Windows\\foo, with no normalisation at the join. - Otherwise the entry is written to the drive root and that is used – created when absent, so this happens on a pristine host and not only on one carrying a stale entry. The write mutates the process environment block as a side effect of what reads like a pure query.
Acceptance needs both a shape and an existence check, and the observed
necessary conditions are worth listing because they are not guessable. An
entry naming a directory that exists is still rejected unless it is already
in fully-qualified X:\... form: measured on one build, C:/Windows/System32,
C:\Windows\System32\., C:\Windows\System32\..\System32 and
\\?\C:\Windows\System32 were each rejected while naming the same existing
directory that C:\Windows\System32 was accepted for. An existing file and
a missing directory are rejected too, so existence is checked as well – but
saying the gate is “a filesystem query rather than a syntax test”, as a draft
of this doc did, states a mechanism the evidence contradicts. It is both, and
this list is a set of observations rather than a specification.
For the current drive neither happens, and the guarantee is stated at the boundary observation can actually reach: the entry makes no difference to the result, and is not rewritten. Both halves are measured – an entry the non-current arm would honour verbatim is installed and the process directory wins anyway, and an entry the non-current arm would replace is left untouched. Whether Windows reads it internally is not established, because setting a value and observing the result cannot separate “not read” from “read and ignored”. An earlier revision said “not consulted”, which is the same overreach this section corrects two paragraphs above.
This is why the “does not verify what it produces” guarantee above is worth
stating narrowly. The broad reading – that the call touches no filesystem –
is not merely unproven, it is false here. Earlier revisions said the
opposite, reasoning that the current directory lives in the PEB and the
=X: variables in the environment block and that both are ordinary process
memory. The reasoning was sound and the conclusion wrong, which is the
standing hazard this crate keeps meeting: a mechanism argued from the data
sources rather than measured.
§What a resolution costs
The figure the repo’s own instrument produces is a bound, not this call’s
cost, and the difference matters. On x86_64 probe-request-cost measures
building an open request as a construct-and-drop cycle at roughly 210 ns and
cloning an already-resolved path at roughly 45 ns. The ~165 ns between them
is what recycling a resolved path recovers, and that is all it is.
That probe exercises crate::path::prepare, not this module, and the
two have different allocation shapes – which is itself why the gap cannot be
read as this call’s cost. prepare copies the input and then allocates a
MAX_PATH output buffer, so two allocations against the clone’s one, and the
builder chain sits on top. ResolveFullPath takes its input already owned
and allocates one buffer per attempt instead. Either way the allocator work
is the crate’s, not GetFullPathNameW’s, and attributing the gap to the call
– as a draft of this doc did – credits it with the work the same sentence
is busy excluding.
Timed on its own – input already marshalled, output buffer pre-allocated,
so no allocation is in the loop – the call costs about 110 ns on this
host, roughly two thirds of that gap. That measurement is a direct one taken
for this note and is not something the probe reports; no instrument in
this repository isolates the call, and the honest reading of
probe-request-cost alone is an upper bound.
It does not solve the session-relative drive-letter hazard, and saying
so plainly matters more than the part it does solve. GetFullPathNameW
never expands a drive letter, and a drive letter is resolved against the
logon session of whatever token is in effect at open time. A path resolved
here and opened on a worker under a captured token from another logon
session can still name a different device. That hazard is open at the
workspace level; this entry inherits it and does not close it.
A consumer that wants the final, filesystem-verified path of an object
wants crate::final_path, which requires a handle and therefore an open.
Structs§
- Resolve
Full Path - An owned, marshalable parameter set for
GetFullPathNameW.
Enums§
- Full
Path Error - Why a full path could not be resolved.