RFA-194 · Case file with fixtures · Case 166 of 694 · Runtime evidence
Why Path::starts_with Is Not a String Prefix Check
Path::starts_with compares complete path components, which correctly separates a child path from a sibling whose filename shares the same text. It is a lexical operation, however, and does not resolve parent components, symlinks, or filesystem races.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- all targets; path syntax remains platform-specific
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Path prefix checks compare normalized path components rather than arbitrary character prefixes inside a filename component.
- First discriminating check
- Compare the components of the candidate and base, then test a real child component and a same-text sibling name separately.
Path prefixes often appear in access checks: “is this output under the allowed directory?” Using a raw string prefix for that question creates the classic sibling-name bug. Rust's Path::starts_with deliberately does something stricter.
The failing program compares /srv/application-cache with /srv/application. The first text begins with the second text, but Path::starts_with returns false. application-cache is one component, not a child named cache below an application component.
Paths have components, not only characters
Path::starts_with only considers whole path components. A real child passes:
/srv/application/cache
components: /, srv, application, cache
base: /, srv, application
The sibling name does not:
/srv/application-cache
components: /, srv, application-cache
base: /, srv, application
The repaired program tests both cases. This is a useful regression pair because testing only a valid child proves little about prefix-collision rejection.
The same principle applies to /data/user-10 and /data/user-1. String matching would mix two identities. Component matching keeps their boundaries visible.
components explains surprising comparisons
When a result is unclear, I print or collect Path::components for both operands. This exposes roots, prefixes, current-directory markers, parent-directory markers, and normal names without requiring UTF-8 conversion.
Rust performs limited lexical normalization for component iteration. Repeated separators and most non-leading . components do not create distinct normal components. Trailing separators are also disregarded in these comparisons.
It does not generally resolve ... The path a/b/../secret cannot be simplified to a/secret safely from text alone because b might be a symbolic link. Filesystem meaning is different from lexical shape.
This is why I avoid implementing path logic with to_string_lossy().starts_with(...). Apart from losing component boundaries, an operating-system path does not have to be valid Unicode.
Lexical prefix is not full containment proof
Component-aware comparison fixes one important bug, but it does not access the filesystem. A path that lexically starts below an allowed directory may traverse a symlink that points elsewhere. A .. component may also change the eventual location.
Path::canonicalize resolves an existing path to an absolute canonical form, including symbolic links. Comparing canonical base and candidate paths can answer a stronger question at one moment.
It still does not automatically make a security-sensitive open safe. Another process can change parts of the directory tree between checking and opening: a time-of-check/time-of-use race. Strong sandboxing often needs directory handles and operating-system-specific relative-open facilities rather than two path strings.
I describe the guarantee honestly:
starts_withanswers a lexical component-prefix question.- canonicalization asks the filesystem for a resolved path snapshot.
- secure containment during use may require handle-based operating-system primitives.
One method should not be credited with all three jobs.
Platform syntax belongs in the test matrix
Path uses platform-specific rules. Unix has one root separator. Windows also has drive and network prefixes, and drive-letter behaviour needs special treatment.
A test written only with /srv/... checks the component principle but not every platform case. For a cross-platform application I add cases for relative paths, roots, trailing separators, Windows prefixes where applicable, and names that differ only by case.
The standard path comparisons are normally case-sensitive even if the deployed filesystem is case-insensitive. Filesystem identity and lexical equality can therefore disagree.
I keep pure component tests platform-neutral where possible and gate syntax-specific cases with the target family. The evidence here makes no filesystem call and is deterministic on the pinned environment.
Decide whether the input is a path or a namespace key
Sometimes a slash-separated value is not a filesystem path at all. Object-store keys, URL paths, package names, and routing prefixes have their own normalization rules. Converting them to Path borrows host-platform semantics that may be wrong.
I use Path for operating-system paths. I use URL or domain-specific types for other namespaces. This prevents a Linux build and a Windows build from interpreting the same protocol key differently.
For an ordinary text autocomplete feature, string prefix matching may be exactly right. The failure comes from asking a path-containment question with text semantics, or asking a text-search question with path semantics, without noticing the difference.
My prefix-check sequence
When a path prefix test surprises me, I first inspect components rather than displayed text. I then classify the desired claim as textual, lexical-path, resolved-filesystem, or security containment. I add a sibling with the same textual prefix, include . and .., check roots and platform prefixes, and decide whether symlinks or concurrent tree changes are in scope.
The core principle is that boundaries carry meaning. Path::starts_with refuses to split a filename component just because its characters share a prefix. That makes it safer and more useful than a string check, while still remaining a lexical tool rather than proof about the live filesystem.