Skip to content

fix(cache): sanitize cache key segments to prevent ENOTDIR on fs drivers#4156

Draft
claygeo wants to merge 2 commits intonitrojs:mainfrom
claygeo:fix/fs-cache-enotdir-path-conflict
Draft

fix(cache): sanitize cache key segments to prevent ENOTDIR on fs drivers#4156
claygeo wants to merge 2 commits intonitrojs:mainfrom
claygeo:fix/fs-cache-enotdir-path-conflict

Conversation

@claygeo
Copy link
Copy Markdown

@claygeo claygeo commented Mar 28, 2026

Problem

Fixes #4142

When using fs or fs-lite as the storage driver, caching routes that share path prefixes crashes with ENOTDIR. For example:

  1. Cache /foo → creates a file at .cache/nitro/route-rules/**/foo/foo.abc123.json
  2. Cache /foo/bar → needs foo to be a directoryENOTDIR: not a directory

This breaks any Nitro/Nuxt app where a route can be both a leaf and a prefix of another route (e.g. /en and /en/about, /items/123 and /items/123/details).

Real-world impact: Nuxt 4.4.0's runtime payload caching (nuxt/nuxt#34410) triggers this for any site with nested routes, as reported in nuxt/nuxt#34547. @danielroe's Nuxt-side workaround (nuxt/nuxt#34569) covers devStorage but not production storage with fs-lite, and he suggested fixing this upstream in Nitro.

Root Cause

Cache group and name values contain / characters:

  • Groups: "nitro/route-rules", "nitro/handlers", "nitro/functions"
  • Names: "/**:/foo" (route path keys)

Unstorage's fs drivers convert : (key separator) to / (path separator). But the / characters already embedded in group/name values also become path separators, creating unpredictable directory structures where a path segment can be both a file and a directory prefix.

ocache _buildCacheKey: "/cache:nitro/route-rules:/**:/foo:key.json"
                                  ↑                 ↑
                        "/" in group          "/" in name
                                  ↓                 ↓
unstorage fs-lite:     .cache/nitro/route-rules/**/foo/key.json
                                                    ↑
                                              "foo" is a FILE
                                              but /foo/bar needs
                                              it as a DIRECTORY

Fix

Three layers of defense:

  1. Group names — Replace / with - in all cache group constants (nitro/functionsnitro-functions, etc.)
  2. Route-rules name — Sanitize route path keys by replacing / with _ before passing to ocache
  3. Public API — Apply sanitizeCacheKey() to user-provided name values in defineCachedFunction and defineCachedHandler, protecting any custom cache names from the same issue

No collision risk: ocache's getKey already appends a hash of the full path to the storage key, so the name is only a namespace prefix, not the unique identifier.

When using `fs` or `fs-lite` as the storage driver, caching a route like
`/foo` creates a file on disk. Caching `/foo/bar` then needs `/foo` to be
a directory, which crashes with ENOTDIR.

The root cause is that cache group and name values contain "/" characters
(e.g., group "nitro/route-rules", name "/**:/foo") which unstorage's
filesystem drivers interpret as directory separators after converting the
":" key separators to "/".

Changes:
- Replace "/" with "_" in all cache group names (nitro/functions ->
  nitro-functions, etc.) to prevent unexpected directory nesting
- Sanitize the route-rules cache name by replacing "/" with "_" in
  route path keys before passing to ocache
- Add sanitizeCacheKey() helper applied to user-provided cache names
  in defineCachedFunction/defineCachedHandler public APIs

Fixes nitrojs#4142

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claygeo claygeo requested a review from pi0 as a code owner March 28, 2026 01:14
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 28, 2026

@claygeo is attempting to deploy a commit to the Nitro Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a332c893-38cb-469f-9dd6-116588f39723

📥 Commits

Reviewing files that changed from the base of the PR and between 23e2a48 and db253b3.

📒 Files selected for processing (1)
  • src/runtime/internal/cache.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/runtime/internal/cache.ts

📝 Walkthrough

Walkthrough

Sanitized cache group and name values by replacing / with _ in cache keys and switching default group identifiers from slash-based to hyphen-based forms to make filesystem-backed storage keys safe.

Changes

Cohort / File(s) Summary
Cache internals
src/runtime/internal/cache.ts
Added sanitizeCacheKey() to replace / with _. defineCachedFunction and defineCachedHandler now use `sanitizeCacheKey(opts.group
Route rules -> cached handlers
src/runtime/internal/route-rules.ts
Route-rule-generated cache keys are sanitized: computed handler name replaces / with _ and defineCachedHandler uses hyphenated default group ("nitro-route-rules") and the sanitized name, while in-memory lookup keys remain unchanged.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title follows conventional commits format with 'fix' type and clearly describes the main change: sanitizing cache keys to prevent ENOTDIR errors on filesystem drivers.
Description check ✅ Passed The description comprehensively explains the problem, root cause, and the three-layer fix approach, directly addressing the linked issue #4142 about ENOTDIR errors with fs drivers.
Linked Issues check ✅ Passed The PR successfully addresses issue #4142 by sanitizing cache key segments to prevent ENOTDIR conflicts when filesystem drivers convert key separators to path separators.
Out of Scope Changes check ✅ Passed All changes are directly focused on sanitizing cache keys in route-rules and cached function/handler definitions, staying within scope of fixing the ENOTDIR issue.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/runtime/internal/route-rules.ts (1)

73-80: Avoid duplicate name sanitization in route rules.

defineCachedHandler already sanitizes opts.name in src/runtime/internal/cache.ts. Keeping another local key.replace(/\//g, "_") here duplicates behavior and can drift.

Suggested simplification
-      // Sanitize the name to avoid filesystem path conflicts.
-      // Route paths contain "/" which unstorage's fs drivers interpret as
-      // directory separators, causing ENOTDIR when a path like "/foo" is
-      // cached as a file but "/foo/bar" needs it to be a directory.
-      const safeName = key.replace(/\//g, "_");
       cachedHandler = defineCachedHandler(handler, {
         group: "nitro-route-rules",
-        name: safeName,
+        name: key,
         ...m.options,
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/internal/route-rules.ts` around lines 73 - 80, The local
sanitization using safeName (key.replace(/\//g, "_")) is duplicating logic
already performed by defineCachedHandler; remove the safeName variable and the
key.replace call and pass the original key (or handler key variable) directly to
defineCachedHandler when creating cachedHandler so the single sanitization in
src/runtime/internal/cache.ts is used; update any references to safeName to use
the original key name instead.
src/runtime/internal/cache.ts (1)

16-24: Move internal helper(s) to the bottom of the module.

sanitizeCacheKey is a non-exported helper; place it after exported APIs to match project structure conventions.

As per coding guidelines, "Place non-exported/internal helpers at the end of the file".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/internal/cache.ts` around lines 16 - 24, sanitizeCacheKey is an
internal, non-exported helper and should be moved after the module's exported
APIs; relocate the function declaration for sanitizeCacheKey(value: string |
undefined): string | undefined to the end of src/runtime/internal/cache.ts
(below all exported functions/variables) so file order follows the project
convention of exported symbols first and internal helpers last, and ensure any
references still resolve after the move.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/runtime/internal/cache.ts`:
- Around line 50-53: The code sanitizes opts.name via sanitizeCacheKey but
leaves opts.group unsanitized, allowing user-provided group values (e.g.,
containing "/") to cause filesystem path conflicts; fix by passing opts.group
through sanitizeCacheKey when merging defaults (replace group: "nitro-functions"
/ ...opts with group: sanitizeCacheKey(opts.group ?? "nitro-functions") or
equivalent) and apply the same change at the other occurrence referenced (the
block around lines 63-69) so any place that sets group from opts uses
sanitizeCacheKey.

---

Nitpick comments:
In `@src/runtime/internal/cache.ts`:
- Around line 16-24: sanitizeCacheKey is an internal, non-exported helper and
should be moved after the module's exported APIs; relocate the function
declaration for sanitizeCacheKey(value: string | undefined): string | undefined
to the end of src/runtime/internal/cache.ts (below all exported
functions/variables) so file order follows the project convention of exported
symbols first and internal helpers last, and ensure any references still resolve
after the move.

In `@src/runtime/internal/route-rules.ts`:
- Around line 73-80: The local sanitization using safeName (key.replace(/\//g,
"_")) is duplicating logic already performed by defineCachedHandler; remove the
safeName variable and the key.replace call and pass the original key (or handler
key variable) directly to defineCachedHandler when creating cachedHandler so the
single sanitization in src/runtime/internal/cache.ts is used; update any
references to safeName to use the original key name instead.
🪄 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

Run ID: 3727a061-6631-4730-983f-d8103f807036

📥 Commits

Reviewing files that changed from the base of the PR and between 589e8ad and 23e2a48.

📒 Files selected for processing (2)
  • src/runtime/internal/cache.ts
  • src/runtime/internal/route-rules.ts

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 28, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nitro@4156

commit: 23e2a48

Address CodeRabbit review: opts.group was passed through raw, so a
custom group containing "/" could still trigger ENOTDIR on fs drivers.
Now both group and name are sanitized through sanitizeCacheKey().
@pi0
Copy link
Copy Markdown
Member

pi0 commented Mar 29, 2026

Thank you for the PR ❤️ Keeping nested namespaces is a delibrate feature it allows faster lookups, easier invalidation and namespaced mounting for using different cache backends per prefix.

I haven't looked deeply into this issue but we can either workaround it in ocache key generation for /foo + /foo/bar(by adding a suffix to the end of HTTP endpoints) or add a special flag to unstorage'sfs` driver to disable namespacing when mounted (since this is very specific to fs backend)

I will keep PR as draft until we move it forward.

@pi0 pi0 marked this pull request as draft March 29, 2026 20:39
@claygeo
Copy link
Copy Markdown
Author

claygeo commented Mar 30, 2026

Thanks for the context — that makes total sense, keeping the namespace structure for lookups and invalidation is clearly the right call.

The ocache approach sounds cleaner to me since it keeps the fix at the key-generation layer rather than adding a flag that only applies to one driver. Happy to take a look at that path if you can point me at the relevant spot in ocache, or I can wait if you'd rather land a different fix there first.

Either way I'm happy to update this PR or close it in favour of whatever direction fits best.

@claygeo
Copy link
Copy Markdown
Author

claygeo commented Mar 30, 2026

Thanks @pi0 — that context makes sense. Keeping the hierarchical namespacing in ocache for performance/invalidation is the right call.

The issue is specific to the fs driver: when foo and foo/bar are both cache keys, the fs driver tries to create both a file at .../foo and a directory at .../foo/, which collide.

Happy to move the fix upstream to the unstorage fs driver (adding a suffix like : to HTTP endpoint keys to avoid the file/directory collision) if that direction works for you, or I can look at the ocache key generation approach. Let me know which you'd prefer and I'll update accordingly.

@pi0
Copy link
Copy Markdown
Member

pi0 commented Mar 31, 2026

I think we can first start by tackling unstorage fs driver issues. thanks!

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.

Caching routes with fs or fs-lite fails when a route is both a path and a prefix of another path

2 participants