Skip to content

normalizePath / getNormalizedAbsolutePath drop a leading ".." for relative paths that ascend above their root (regression from #60812) #63592

Description

🔎 Search Terms

normalizePath, getNormalizedAbsolutePath, path normalization, relative path, leading "..", #60812, regression

🕗 Version & Regression Information

⏯ Playground Link

No response

💻 Code

getNormalizedAbsolutePath / normalizePath are @internal, so this is shown as assertions in the style of src/testRunner/unittests/paths.ts:

// getNormalizedAbsolutePath(path, /*currentDirectory*/ "")
getNormalizedAbsolutePath("a/../b", "");        // actual: "a/b"   expected: "b"   (the ".." is ignored entirely)
getNormalizedAbsolutePath("a/..", "");          // actual: "a"     expected: ""
getNormalizedAbsolutePath("a/../..", "");       // actual: ""      expected: ".."
getNormalizedAbsolutePath("foo/../../bar", "");  // actual: "bar"   expected: "../bar"
getNormalizedAbsolutePath("x/../../y", "");     // actual: "y"     expected: "../y"

// normalizePath() shares the same code path:
normalizePath("foo/../../bar");                 // actual: "bar"   expected: "../bar"
normalizePath("a/../b");                        // actual: "a/b"   expected: "b"

🙁 Actual behavior

For a relative path where the number of .. segments exceeds the preceding "normal" segments — i.e. removing the first segment, or generating a new leading .. — the rewritten state machine either keeps a segment that should be removed (a/../b → a/b) or drops a .. that should be preserved (foo/../../bar → bar).

🙂 Expected behavior

Same as the pre-#60812 implementation and as getPathFromPathComponents(reducePathComponents(getPathComponents(path))): a/../b → b, a/.. → "", a/../.. → "..", foo/../../bar → ../bar.

This is consistent with the assertions #60812 itself added, e.g. getNormalizedAbsolutePath("../../a/..", "") === "../.." and reducePathComponents(["", "..", "a"]) === ["", "..", "a"]. Cases where a leading .. was already present are preserved; only the subclass where a leading .. must be generated regressed — which the new tests don't cover.

Additional information about the issue

Root cause (FWIW)

In the ".." handling of getNormalizedAbsolutePath:

  1. the normalized === undefined branch derives the popped prefix from normalizedUpTo - 2, and when that is < 0 falls back to path.substring(0, normalizedUpTo) — keeping the first segment instead of removing it; and
  2. neither that branch nor the normalized !== undefined branch resets seenNonDotDotSegment when the pop collapses the result back to the root (or to a ..-tail at a relative root), so the next .. is treated as poppable and dropped instead of being emitted as a leading ...

Impact

This does not appear reachable through normal compilation today (#60812 changed no baselines), so it's latent. But it's a behavior change for an input class the previous implementation handled correctly, and these are general path utilities with no documented rooted-only precondition. A 540k-input fuzz of the rewrite vs the prior reducePathComponents-based implementation diverges only on this class. Filing so you can decide between restoring equivalence and documenting a rooted-only contract — happy to send a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions