path: normalized_components emits ".." for absolute paths (POSIX non-conformance; disagrees with fs::resolver)
@jaybosamiya-ms is already working on this.
Since Jul 29, 2026.
- Dominant language
- Rust
- Stars
- 2.7k
- Forks
- 144
- Avg merge
- 12h 21m
- Merged PRs (30d)
- 146
Description
## Summary
`Arg::normalized_components` ([`litebox/src/path.rs` L67-L90](https://github.com/microsoft/litebox/blob/6a03ec80f065d2a66b937bde3d6f0708d282ca27/litebox/src/path.rs#L67-L90))
emits literal `..` components for absolute paths whose `..` count exceeds the
number of preceding normal components. POSIX requires that `..` be clamped at
the root — [POSIX.1-2017 §4.13 Pathname Resolution](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_13)
states that dot-dot in the root directory refers to the root directory itself,
so `/foo/../..` is equivalent to `/`.
The same crate already contains a second resolver that handles this correctly,
so the two disagree on the same input.
## Observed vs. expected
| Input | current output | POSIX-expected |
|---|---|---|
| `/foo/../..` | `["", ".."]` | `[""]` |
| `/..` | `["", ".."]` | `[""]` |
| `/../../host_secret` | `["", "..", "..", "host_secret"]` | `["", "host_secret"]` |
| `/a/../..` | `["", ".."]` | `[""]` |
| `/../x` | `["", "..", "x"]` | `["", "x"]` |
| `//..//..` | `["", "..", ".."]` | `[""]` |
| `/a/b/../../c` | `["", "c"]` | `["", "c"]` ✔ |
| `/etc/passwd` | `["", "etc", "passwd"]` | ✔ |
## Root cause
The algorithm walks components right-to-left via `rsplit('/')`, counting `..`
into `parent_count` and consuming normal components against it. Any leftover
count is emitted directly:
```rust
rev_norm_components.extend(core::iter::repeat_n("..", parent_count));
if self.as_rust_str()?.starts_with('/') {
rev_norm_components.push("");
}
```
For an absolute path the leftover `parent_count` should be dropped.
## Internal inconsistency
[`litebox/src/fs/resolver.rs` L84-L102](https://github.com/microsoft/litebox/blob/6a03ec80f065d2a66b937bde3d6f0708d282ca27/litebox/src/fs/resolver.rs#L84-L102)
resolves `..` with `let _ = components.pop();`. Popping an empty vector is a
no-op, so it clamps at the root as POSIX requires. Two resolvers in the same
crate therefore produce different results for the same input.
## Where it surfaces
[`walk_to` in `litebox/src/fs/nine_p/mod.rs` L343-L355](https://github.com/microsoft/litebox/blob/6a03ec80f065d2a66b937bde3d6f0708d282ca27/litebox/src/fs/nine_p/mod.rs#L343-L355)
collects `normalized_components()` and passes the result verbatim to
`client.walk(&self.root.1, &components)`, rooted at the exported root fid. So
opening `/../../x` emits a `Twalk` for `["", "..", "..", "x"]` against the
export root, and correct behaviour depends on the 9P server clamping `..`.
Conventional servers (qemu virtfs, diod) do clamp, so this is a robustness /
hardening concern rather than a demonstrated escape — see the note below.
Secondary: every absolute path also produces an empty-string first wname
(`/etc/passwd` → `["", "etc", "passwd"]`, `/` → `[""]`), which is passed to
`client.walk`. An empty `wname` is not a valid 9P path element
([walk(5)](https://man.cat-v.org/plan_9/5/walk)).
## Reproduction
The function is self-contained; transcribing it verbatim reproduces the table
above. Full runnable repro:
```rust
fn normalized_components(s: &str) -> Vec<&str> {
let mut parent_count = 0;
let mut rev = s.rsplit('/')
.filter(|&c| match c {
"" | "." => false,
".." => { parent_count += 1; false }
_ if parent_count > 0 => { parent_count -= 1; false }
_ => true,
})
.collect::>();
rev.extend(core::iter::repeat_n("..", parent_count));
if s.starts_with('/') { rev.push(""); }
rev.into_iter().rev().collect()
}
fn main() {
for c in ["/foo/../..", "/..", "/../../host_secret", "/a/b/../../c", "/etc/passwd"] {
println!("{c:<20} -> {:?}", normalized_components(c));
}
}
```
Verified against `6a03ec80f065d2a66b937bde3d6f0708d282ca27` (upstream `main`
HEAD at time of writing) with rustc 1.96.0. `path.rs` has had no functional
change since 2025, so this is long-standing rather than a recent regression.
## Suggested fix
When the input is absolute, drop the leftover `parent_count` rather than
emitting it:
```rust
rev_norm_components.extend(core::iter::repeat_n(
"..",
if self.as_rust_str()?.starts_with('/') { 0 } else { parent_count },
));
```
Suggested tests: `/..`, `/foo/../..`, `/../../x`, `//..//..`, `/a/../..`.
Longer term, consolidating onto the `resolver.rs` semantics would remove the
divergence entirely.
## Note on how this is being reported
I first sent this to MSRC (VULN-205467) since `SECURITY.md` directs security
issues there. MSRC reviewed it and determined it does not meet Microsoft's
definition of a security vulnerability — the trust boundary is enforced at the
9P server and is not demonstrably crossed by the client normalizer alone — and
encouraged filing it here as a hardening fix. Posting publicly on that basis.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.