Skip to content

fix(restrictions): compare a directory restriction by path components - #288

Merged
chenjiahan merged 7 commits into
mainfrom
fix/restrictions-directory-boundary
Aug 4, 2026
Merged

fix(restrictions): compare a directory restriction by path components#288
chenjiahan merged 7 commits into
mainfrom
fix/restrictions-directory-boundary

Conversation

@stormslowly

@stormslowly stormslowly commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Why

is_inside reasoned about paths as strings, and its last step could not succeed:

path.strip_prefix(parent).is_ok_and(|p| p == Path::new("./"))

strip_prefix yields index.js for /project/src/index.js under /project/src, never ./, so only a path exactly equal to the restriction passed. A directory restriction matched nothing and was unusable — web-infra-dev/rspack#14952.

restriction enhanced-resolve before after
<fixture> node_modules/pck1/index.js Cannot find module node_modules/pck1/index.js
<fixture>/node_modules/pck error error error

What

Compare paths as paths. Path::starts_with walks components, so /project/src-other falls outside /project/src on its own — enhanced-resolve needs a separate character probe after startsWith only because it matches raw strings.

Two things a component comparison alone still misses:

  • Spelling. ResolveOptions::sanitize normalizes each restriction once, so /a/x/../b/c and a trailing separator name the directory they look like. Normalizing never empties a restriction: an empty path prefixes everything, which would turn . or foo/.. from matching nothing into matching everything.
  • Finality. The candidate checks see the spelling a path has while it is being selected, and load_realpath runs afterwards, so an in-root symlink pointing outside — or an absolute specifier carrying .. — passed and was then returned from outside the restriction: restrictions: [<root>/allowed] handed back <root>/outside/secret.js. The normalized result is now checked once more after load_realpath.

Measured against enhanced-resolve 5.23.0 and 5.24.5 on every case above, plus "" (matches every path) and ., .., foo/.. (match none). Tests are ported from webpack/enhanced-resolve@d8693b6 and webpack/enhanced-resolve#595.

Known gaps

A candidate rejected by the post-realpath check cannot fall through to another extension or main field: realpath runs once after require() has committed, whereas enhanced-resolve resolves symlinks per candidate and rejects inside the loop. Follow-up PR.

Windows containment stays case-sensitive below the drive letter — Rust compares Normal components and Prefix::UNC byte-wise, and only std's parse_drive folds case, while upstream folds every component.

Copilot AI review requested due to automatic review settings July 29, 2026 07:45

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a995f04436

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes directory-style path restrictions so that a restriction path allows both the restricted directory itself and any descendants, aligning behavior with webpack/enhanced-resolve and unblocking real-world restrictions usage.

Changes:

  • Updates restriction checking to use component-aware Path::starts_with, correctly allowing descendants of a restricted directory.
  • Normalizes Restriction::Path values once in ResolveOptions::sanitize to handle .. segments and trailing separators consistently.
  • Adds targeted restriction boundary tests (posix via MemoryFS, Windows via direct checks) plus example programs for cross-validation against enhanced-resolve.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/lib.rs Fixes restriction containment logic by switching to component-aware prefix checks.
src/options.rs Normalizes path-based restrictions during options sanitization to avoid per-check normalization and improve correctness.
src/tests/restrictions.rs Adds regression tests and boundary suites (posix + Windows) for restriction semantics.
examples/restrictions.rs Adds a Rust example to reproduce/compare restriction behavior.
examples/restrictions.js Adds an enhanced-resolve counterpart script for behavior comparison.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/options.rs Outdated
Comment thread src/tests/restrictions.rs Outdated
Comment thread examples/restrictions.rs Outdated
@codspeed-hq

codspeed-hq Bot commented Jul 29, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 12 untouched benchmarks


Comparing fix/restrictions-directory-boundary (419c4bf) with main (c3bcf6b)

Open in CodSpeed

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Path restrictions are normalized during option sanitization and checked using prefix matching. Tests verify descendant and sibling resolution, normalization boundaries, empty and relative restrictions, separator handling, trailing slashes, drive-letter casing, UNC paths, and device paths on non-Windows and Windows targets.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 88.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title directly describes the main change: accepting descendants of a string restriction, which is the primary fix addressed by the pull request.
Description check ✅ Passed The description explains why the change was needed, what was changed, and includes test cases and known divergences related to the restriction logic.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/restrictions-directory-boundary

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
examples/restrictions.rs (1)

13-17: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider validating restriction is absolute too.

Only path is asserted absolute; a relative restriction won't meaningfully constrain resolution since resolved candidates are absolute and Path::starts_with requires matching path "kind". Mirroring the existing path assertion would make the demo less surprising to use.

♻️ Suggested addition
   assert!(path.is_absolute(), "{path:?} must be an absolute path.");
+  assert!(
+    restriction.is_absolute(),
+    "{restriction:?} must be an absolute path."
+  );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/restrictions.rs` around lines 13 - 17, Update the validation around
the existing path assertions to also assert that restriction is absolute, using
the same style and a clear message as the path.is_absolute check. Keep the
existing directory and path validations unchanged.
src/options.rs (1)

401-408: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Inconsistent UTF‑8 handling for restriction normalization.

Utf8Path::from_path(path) silently no-ops when the restriction path isn't valid UTF‑8, leaving it un-normalized. Elsewhere in the crate, non-UTF‑8 paths are treated as a hard invariant violation via .expect("path should be UTF-8") (e.g. src/lib.rs). For consistency, consider failing the same way here instead of silently skipping normalization.

♻️ Suggested consistency fix
-        if let Some(path_utf8) = Utf8Path::from_path(path) {
-          *path = path_utf8.normalize().into_std_path_buf();
-        }
+        let path_utf8 = Utf8Path::from_path(path).expect("path should be UTF-8");
+        *path = path_utf8.normalize().into_std_path_buf();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/options.rs` around lines 401 - 408, Update the restriction normalization
loop in the options normalization method to require UTF-8 conversion with the
crate’s established hard-failure behavior, using the existing “path should be
UTF-8” invariant message. Preserve normalization and replacement of valid paths
while removing the silent skip for invalid UTF-8 paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@examples/restrictions.rs`:
- Around line 13-17: Update the validation around the existing path assertions
to also assert that restriction is absolute, using the same style and a clear
message as the path.is_absolute check. Keep the existing directory and path
validations unchanged.

In `@src/options.rs`:
- Around line 401-408: Update the restriction normalization loop in the options
normalization method to require UTF-8 conversion with the crate’s established
hard-failure behavior, using the existing “path should be UTF-8” invariant
message. Preserve normalization and replacement of valid paths while removing
the silent skip for invalid UTF-8 paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 98a293de-4201-4dfc-926c-0d53736a5dd8

📥 Commits

Reviewing files that changed from the base of the PR and between 9a600a4 and a995f04.

📒 Files selected for processing (5)
  • examples/restrictions.js
  • examples/restrictions.rs
  • src/lib.rs
  • src/options.rs
  • src/tests/restrictions.rs

stormslowly and others added 2 commits July 30, 2026 14:56
A string entry in `restrictions` is a directory restriction, but `is_inside`
only accepted a path equal to it, so every file inside a restricted directory
was rejected.

Replace the hand-rolled prefix check with `Path::starts_with`, which is
component-aware, and normalize each restriction once in `sanitize`.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@stormslowly
stormslowly force-pushed the fix/restrictions-directory-boundary branch from 1ce7356 to 1e09143 Compare July 30, 2026 06:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release-npm.yml:
- Around line 106-107: Update the release workflow’s NAPI packaging step to run
`pnpm napi create-npm-dirs` before `pnpm napi version` and `pnpm napi
artifacts`, ensuring the target binding package directories exist before
artifacts are collected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7b3bd2a2-c186-450a-bdd7-ea8c827cd82d

📥 Commits

Reviewing files that changed from the base of the PR and between 1ce7356 and 247fede.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (8)
  • .github/workflows/ci.yml
  • .github/workflows/release-npm.yml
  • bindings/wasm32-wasi/package.json
  • package.json
  • pnpm-workspace.yaml
  • src/lib.rs
  • src/options.rs
  • src/tests/restrictions.rs
💤 Files with no reviewable changes (1)
  • .github/workflows/ci.yml

Comment thread .github/workflows/release-npm.yml
@stormslowly
stormslowly force-pushed the fix/restrictions-directory-boundary branch from 247fede to bc038f2 Compare July 30, 2026 07:34
@stormslowly
stormslowly force-pushed the fix/restrictions-directory-boundary branch from bc038f2 to 1271e74 Compare July 30, 2026 07:38
`Path::starts_with(Path::new(""))` holds for every path, and normalizing
collapses `.` and `foo/..` to an empty path, so such a restriction let every
candidate through instead of none.
Supersedes rejecting an empty restriction. The cause is narrower than that:
`Utf8Path::normalize` collapses `.`, `..` and `foo/..` to an empty path, and
`Path::starts_with(Path::new(""))` holds for every path, so normalizing turned a
restriction that matched nothing into one that matched everything.

Keep the original whenever normalizing empties it. enhanced-resolve's `normalize`
never empties a relative path, and measured against both 5.23.0 and 5.24.4 it
matches every path for `""` and none for `.`, `..` and `foo/..`.
The candidate checks run while a candidate is being selected, on the spelling it
has at that moment, and `load_realpath` runs afterwards. An in-root symlink whose
target sits outside the restriction, and an absolute specifier carrying `..`,
both passed that check and were then returned from outside the restriction —
`restrictions: [<root>/allowed]` handed back `<root>/outside/secret.js`.

Check the normalized result once more after `load_realpath`. The candidate checks
stay, since they are what lets a rejected candidate fall through to another
extension or main field. enhanced-resolve reaches the same place differently: it
taps `resolved`, which sits after `SymlinkPlugin` (ResolverFactory.js:817-835),
and PR #595 made the symlink-resolved path authoritative for the same reason.
@stormslowly

Copy link
Copy Markdown
Collaborator Author

Confirmed and fixed in 559a2e7.

Reproduced first with the upstream fixture shape (allowed/link.js and allowed/rel-link.js pointing at outside/secret.js), restrictions: [<root>/allowed]:

specifier before enhanced-resolve after
./real.js ALLOW ALLOW ALLOW
./link.js ALLOW → <root>/outside/secret.js REJECT REJECT
./rel-link.js ALLOW → <root>/outside/secret.js REJECT REJECT
<root>/allowed/../outside/secret.js ALLOW → <root>/outside/secret.js REJECT REJECT

The normalized result is now checked once more after load_realpath, which also closes the .. case under symlinks: false. The candidate-level checks stay: they are what lets a rejected candidate fall through to another extension or main field, covered by should_try_to_find_alternative_1..4. enhanced-resolve gets both from a single place because it taps resolved, which sits after SymlinkPlugin (ResolverFactory.js:817-835), and resolves symlinks per candidate; this crate defers realpath to the end, so the two checks are separate here.

Five regressions added under escaping_the_restriction, mirroring upstream's describe("with symlinks") from webpack/enhanced-resolve#595 (GHSA-fvr2-82rg-p3pp).

`canonicalize` answers with a `\\?\` path on windows, where `join` collapses the
`..` the traversal tests are about and the resolver cannot take the result as a
specifier. Keeping the plain drive path preserves both.
@stormslowly stormslowly changed the title fix: accept descendants of a string restriction fix(restrictions): compare a directory restriction by path components Aug 2, 2026
@chenjiahan
chenjiahan merged commit ab14d9d into main Aug 4, 2026
25 checks passed
@chenjiahan
chenjiahan deleted the fix/restrictions-directory-boundary branch August 4, 2026 13:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants