Skip to content

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

Description

@KaivenAshford

🔎 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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions