RFA-268 · Case file with fixtures · Case 240 of 694 · Runtime evidence
Why Path::components Removes . but Preserves ..
Path component iteration performs limited lexical normalization. It can ignore redundant separators and internal dot components, but preserves parent traversal because resolving it can require filesystem semantics.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- Unix and Windows path semantics
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Component iteration performs limited lexical cleanup while preserving parent traversal whose meaning can depend on filesystem state.
- First discriminating check
- Inspect Component values directly and decide whether the application needs lexical parsing or filesystem canonicalization.
Iterating a path's components looks like normalization, but it is intentionally limited.
The failing program inspects a/./b/../c. Path::components omits the internal . while preserving .. as Component::ParentDir.
Dot and dot-dot do not carry the same information
An internal current-directory marker normally refers to the same point in lexical traversal, so component iteration can normalize it away along with repeated separators.
A parent marker asks to move relative to what came before. Simplifying b/.. to nothing can be wrong around symbolic links, mount points, permissions, or missing paths. The filesystem meaning is not available from characters alone.
Rust therefore exposes Component::ParentDir instead of pretending it resolved the path.
Component cleanup is not canonicalization
fs::canonicalize queries the filesystem, resolves symbolic links and dot components according to platform behavior, and returns an absolute path. It can fail when the path does not exist or cannot be accessed.
components is a lexical iterator and requires no filesystem access. It is deterministic over the supplied path representation but cannot prove where the live filesystem resolves it.
Choosing between them means choosing whether the question is syntactic or environmental.
Removing parent components lexically can escape policy
A common stack algorithm pushes normal components and pops on ... This can be useful for virtual paths with a defined pure grammar. Applied to operating-system paths, it can disagree with symlink traversal.
For security containment, neither raw string prefixes nor casual lexical cleanup is sufficient. I define a root, resolve according to the actual access model, and consider races between validation and opening.
The Atlas fixture only proves component behavior; it does not present lexical normalization as a sandbox.
Leading dot can remain meaningful
Path component normalization has platform and position details. A leading ./ can be represented differently from an internal dot, and prefixes/root components differ between Unix and Windows.
I test paths on every supported target rather than snapshotting Unix text and assuming Windows components match. Path and OsStr also need not be valid Unicode.
Converting to a lossy string before analysis can change names and separators.
Parent traversal needs a root policy
For archive entries or URL-like virtual paths, .. may be rejected entirely, allowed only while a component stack is nonempty, or preserved. These are application grammar rules.
I implement them over components and return a useful error at the first prohibited traversal. Silently dropping a leading .. changes a relative path into another location.
If virtual paths use / independent of the host, an OS Path parser may be the wrong abstraction on Windows. The format's own grammar should lead.
Existing versus future destinations
Canonicalization needs existing ancestors. Code preparing a new output file cannot necessarily canonicalize the full destination before creation.
One approach resolves and opens a trusted parent directory, then performs relative operations through handles or platform-specific safe APIs. Joining strings and checking once leaves time-of-check/time-of-use gaps.
This system concern is larger than component iteration, but the preserved ParentDir is a useful warning that lexical text has not settled filesystem identity.
What I test
My lexical table includes repeated separators, internal and leading dots, parent markers, roots, prefixes, empty paths, and non-Unicode names where the platform permits them. It asserts components rather than display strings.
Filesystem tests create directories and symlinks in an isolated temporary root and are target-gated. They verify the actual open/containment design, not only canonicalize output captured at one instant.
Display output is not a reversible component encoding
Formatting a path for logs is useful, but the display form is not a portable serialization. Non-Unicode components can be replaced in lossy output, and platform prefixes or separators can change meaning on another host.
When a test needs exact components, I compare Component and OsStr values. When an API transmits a virtual path, I define a platform-independent encoding instead of round-tripping Path::display().
This also protects diagnostics: I can include a readable display string while retaining an escaped byte or wide-unit representation for exact investigation.
Normalized equality needs a named layer
Two path texts can refer to the same live object, produce the same limited components, or merely look equivalent after a custom lexical algorithm. These are three different equality relations.
I name cache keys and containment checks according to the layer used. A component-normalized key is not automatically a filesystem identity key, especially across symlinks and case rules.
The core principle is that normalization has layers. Path::components performs safe limited lexical cleanup but preserves .. because resolving parent traversal can depend on filesystem state. Treat its output as structured syntax, not proof of canonical identity or containment.