Repository navigation
Conversation
… segment Pattern::match_ accepted an empty value for a [param] segment, so docs/[page] matched "/docs" with page: "" (and, through the optional catch-all lookahead, docs/[page]/[[...rest]] matched it too), and blog/[slug]/[id] matched "/blog//42" with slug: "". Reject the candidate as soon as the dynamic segment has no non-empty URL segment to consume, clearing any params recorded by earlier segments like the other failure paths do, so the URL falls through to the remaining routes.
|
Warning Review limit reached
Next review available in: 1 minute Limit details: You’ve used all 5 included reviews currently available under your plan. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: closed as a duplicate. The The one case this branch covers beyond #39290 is an empty segment in the dynamic position ( Reproduced on the released Fix: #39290. |
There was a problem hiding this comment.
LGTM — a 4-line guard at the top of the Value::Dynamic arm that rejects an empty URL segment, mirroring the !path_.is_empty() check the CatchAll arm already has.
What was reviewed:
- Traced the three previously-accepting paths in the
Dynamicarm (emptypath_atis_end, empty at optional-catch-all lookahead,index_of_char == Some(0)on//) — all now short-circuit before pushing an empty param. - Checked
path_cannot legitimately start with/at this point: the Static arm's[segment.len()+1..]and the entrytrim_leftalways advance past the separator, so the only way to hitpath_[0] == b'/'is a//in the input. - Verified the fall-through tests' route ordering holds under
sort_by_name(Dynamic < CatchAll < OptionalCatchAll), and thatparams.clear()on the new rejection is exercised by the[org]/[...rest]case.
Extended reasoning...
Overview
Adds an early rejection at the top of the Value::Dynamic match arm in Pattern::match_ (src/router/lib.rs): when path_ is empty or starts with /, there is no URL segment for a [param] to bind, so clear params and return false. Adds one positive row to the existing pattern_match unit test, a new pattern_rejects_empty_dynamic_segment unit test asserting no-match and empty-params for five route/URL shapes, and two integration tests in filesystem_router.test.ts (a table asserting exact {name, params} or null per URL, and a fall-through test proving the rejected route yields to the next candidate without leaking params).
Security risks
None. This is pure route-pattern matching over already-normalized byte slices; no allocation, no unsafe, no external I/O. The change only makes matching stricter (previously-matched inputs now return null), which cannot introduce new exposure.
Level of scrutiny
Medium. The production change is 4 lines and mechanically parallels the existing CatchAll arm's non-empty check. It is a user-visible behavior change (match("/docs") on docs/[page].tsx now returns null instead of {page: ""}), but that is the fix — Next.js compiles [param] to ([^/]+?), so the old result was a compat bug under style: "nextjs". I traced each of the three sub-paths inside the Dynamic arm that previously produced an empty param and confirmed the new guard preempts all of them, and confirmed that a leading / on path_ can only arise from a // in the input (segment-advance logic elsewhere always steps past exactly one separator).
Other factors
Test coverage is thorough: positive controls alongside every negative, exact-object toEqual assertions (not toMatchObject), the Rust unit test asserts params.is_empty() after rejection so the params.clear() line is load-bearing, and the fall-through test's [org]/[...rest] case would surface a duplicated org if clearing were dropped. The PR description documents fail-before/pass-after on both the Rust and TS suites and flags the merge-order interaction with #36680 (which centralizes the per-path params.clear() calls). No prior human reviews; the only timeline entry is a CodeRabbit rate-limit notice.
|
Updated 9:51 PM PT - Aug 15th, 2026
✅ @robobun, your commit 1cefe5cf83b89beedca85e91e3a6b269db48bd5b passed in 🧪 To try this PR locally: bunx bun-pr 39294That installs a local version of the PR into your bun-39294 --bun |
blog/[slug]/[id] matched "/blog//42" with an empty slug. Carries the empty-segment case and tests from #39294, which was closed into this PR.
Problem
Bun.FileSystemRouter({ style: "nextjs" })matches a route whose next segment is a[param]against a URL that has nothing in that position. Withpages/docs/[page].js,match("/docs")(and"/docs/") returns/docs/[page]withparams: { page: "" }; Next.js does not matchpages/docs/[page].jsfor/docsat all.docs/[page]/[[...rest]]also matches/docswithpage: "", andblog/[slug]/[id]matches/blog//42withslug: "".Routes::match_dynamicreturns the first candidate that matches, the bogus match also shadows routes that should own the URL: withdocs/[page].jsanddocs/[[...rest]].jsboth present,/docsis served bydocs/[page]with an empty param instead of the optional catch-all.Value::Dynamicarm ofPattern::match_(src/router/lib.rs, around line 1190) never checks that the URL segment it is about to consume is non-empty. When the URL is already exhausted, thepattern.is_end()branch pushespath_(empty) as the value and returnstrue, and the optional catch-all lookahead branch does the same; when the URL has an empty segment (//), theindex_of_char(b'/') == Some(0)branch pushes an empty slice. Robustness pass across install, css, ffi, crypto, spawn, shell, and node compat #36165 fixed the case where more pattern follows the dynamic segment; these paths were left as is. TheCatchAllarm already requires!path_.is_empty().Fix
Value::Dynamicarm, reject the candidate whenpath_is empty or starts with/, i.e. when there is no non-empty URL segment for the parameter to take. The rejection clearsparams, like the arm's other failure paths, so a param recorded by an earlier segment of the losing route (orgin[org]/settings/[id]vs/acme/settings) does not leak into the route that is tried next.[param]segment stands for exactly one non-empty path segment (Next.js compiles it to([^/]+?)), so a URL with no segment there cannot be a match for the route; returningfalseletsmatch_dynamiccontinue to the next candidate, which is how/docsreachesdocs/[[...rest]]. Static segments are unaffected (they already compare against the exact segment), and catch-all segments keep their existing checks.test/js/bun/util/filesystem_router.test.ts: two new tests, one table of trees (docs/[page],docs/[page]/[[...rest]],[org]/settings/[id],blog/[slug]/[id],blog/[slug]/[[...rest]]) asserting the exact{ name, params }ornullfor the short, trailing-slash,//, and control URLs, and one asserting that the short URL falls through todocs/[[...rest]](params: {}) and to[org]/[...rest](params: { org, rest }, which also fails if the new rejection did not clearorg). Both fail on the releasedbun1.4.0 with thepage: ""result shown above and pass with this change; the whole file (35 tests) passes.src/router/lib.rsgainspattern_rejects_empty_dynamic_segment(the five patterns above at thePattern::match_level, also assertingparamsends up empty) and a positivedocs/[page]/[[...rest]]vsdocs/introrow inpattern_match. Run withbun run rust:miri -p bun_router --lib -- pattern(the crate's test binary does not link outside Miri becausebun_core::stringscalls into the C++ Highway helpers): all three pass, and the new one fails with the guard disabled.Related open PRs, each a different defect in the same file: #39291 (dynamic route ordering, which found this one), #39290 (root-level
[[...slug]]vs/), #36680 (param leak on other failure paths and single-character trailing segments; it removes the per-pathparams.clear()calls in favour of one truncate in a wrapper, so whichever lands second drops or keeps oneclear()line).Background
FileSystemRouter.match()looks the path up in a hash map of fully static routes first, then walks the sorted list of dynamic routes and returns the first one for whichPattern::match_returnstrue, so for dynamic routes list order is precedence and a false positive from one route hides every route after it.Pattern::match_walks the route name one pattern at a time (Pattern::inityields the next static segment,[param],[...param]or[[...param]]) while consuming the URL (path_, already lowercased and without its leading slash, with trailing slashes and/indexsuffixes stripped by the caller) segment by segment.pattern.is_end()reports that the pattern just consumed was the last one in the route name.match()call shares a singleparamsscratch list; a route that pushed a param and then failed has to clear it, otherwise the winning route reports that name twice (the symptom from Bun.FileSystemRouter router match unknown behaviour #12206).Released bun 1.4.0 vs this branch on the repro trees