Skip to content

feat(fs,fs-lite): add dataSuffix option to prevent file/directory key collisions#767

Open
claygeo wants to merge 5 commits into
unjs:mainfrom
claygeo:fix/fs-driver-key-collision
Open

feat(fs,fs-lite): add dataSuffix option to prevent file/directory key collisions#767
claygeo wants to merge 5 commits into
unjs:mainfrom
claygeo:fix/fs-driver-key-collision

Conversation

@claygeo

@claygeo claygeo commented Mar 31, 2026

Copy link
Copy Markdown

Summary

When both foo and foo:bar exist as storage keys, the fs and fs-lite drivers map them to <base>/foo (file) and <base>/foo/bar (inside directory foo/). This creates an ENOTDIR collision because foo cannot be both a file and a directory on disk.

This PR adds a dataSuffix option to both drivers. When set (e.g. dataSuffix: ".data"):

  • Key foo is stored as foo.data on disk
  • Key foo:bar is stored as foo/bar.data on disk
  • No collision -- foo.data is a file, foo/ is a directory

The suffix is transparently stripped from keys returned by getKeys() and watch callbacks. The option defaults to undefined for full backward compatibility.

Context

This addresses the root cause of file/directory collisions reported in:

Per pi0's feedback, the fix belongs in the unstorage fs driver rather than in Nitro's cache key sanitization.

Changes

  • src/drivers/fs.ts -- Added dataSuffix option, split r() into r() (directory resolution) and rFile() (file resolution with suffix), updated all file operations to use rFile(), strip suffix in getKeys() and watch callbacks
  • src/drivers/fs-lite.ts -- Same changes as fs.ts
  • test/drivers/fs.test.ts -- Added test verifying foo + foo:bar coexistence with dataSuffix, and basic CRUD with suffix
  • test/drivers/fs-lite.test.ts -- Same tests as fs.test.ts

Usage

import fsDriver from "unstorage/drivers/fs";

const storage = createStorage({
  driver: fsDriver({ base: "./cache", dataSuffix: ".data" }),
});

// These now coexist without ENOTDIR errors:
await storage.setItem("foo", "value1");
await storage.setItem("foo:bar", "value2");

Summary by CodeRabbit

  • New Features
    • Added optional dataSuffix to filesystem storage drivers (fs/fs-lite) to prevent on-disk collisions (e.g., foo vs foo:bar) via suffixed leaf files.
    • getKeys() and change notifications now return logical keys with the suffix stripped.
  • Bug Fixes
    • Added validation for invalid dataSuffix values and ensured the empty/root key can’t escape the storage base.
  • Tests
    • Added coverage for coexistence, suffix stripping, ignoring non-matching files, validation errors, and watcher/update behavior.
  • Documentation
    • Documented dataSuffix, collision scenarios, constraints, and related behaviors (including clear()).

… collisions

When both `foo` and `foo:bar` exist as storage keys, the fs drivers try
to create a file at `<base>/foo` and a directory at `<base>/foo/`,
causing ENOTDIR errors.

Add a `dataSuffix` option (e.g. `".data"`) that appends a suffix to all
stored file paths on disk. With `dataSuffix: ".data"`, key `foo` is
stored as `foo.data` and key `foo:bar` as `foo/bar.data`, eliminating
the collision. The suffix is transparently stripped from keys returned
by `getKeys()` and watch callbacks.

The option defaults to `undefined` (no suffix) for full backward
compatibility -- consumers like Nitro can opt in by setting it.
@claygeo claygeo requested a review from pi0 as a code owner March 31, 2026 12:52
@coderabbitai

coderabbitai Bot commented Mar 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2ee7e3ef-e424-40ac-8bb6-437cb7db76a0

📥 Commits

Reviewing files that changed from the base of the PR and between a32128f and 8740cea.

📒 Files selected for processing (1)
  • docs/2.drivers/fs.md
✅ Files skipped from review due to trivial changes (1)
  • docs/2.drivers/fs.md

📝 Walkthrough

Walkthrough

Filesystem drivers fs and fs-lite gain an optional dataSuffix option that appends a suffix to on-disk filenames to prevent collisions between logical keys and nested namespaces. A new rFile(key) resolver maps keys to suffixed paths; all storage operations use it. getKeys became async and filters/strips the suffix when enabled. The fs driver's watcher filters and strips suffix from event callbacks.

Changes

Collision Avoidance via File Suffix

Layer / File(s) Summary
Contract definition and validation
src/drivers/fs-lite.ts, src/drivers/fs.ts
FSStorageOptions interface extended with optional dataSuffix?: string. Both drivers validate the suffix upfront, rejecting empty strings, relative patterns (., ..), and paths containing separators (/, \) or colon (:).
fs-lite driver implementation
src/drivers/fs-lite.ts
rFile(key) resolver appends dataSuffix to the resolved physical path (with special-case for root key staying within base). All item/metadata operations (hasItem, getItem, getItemRaw, getMeta, setItem, setItemRaw, removeItem) and async getKeys() use rFile instead of bare r(key); getKeys filters entries ending with the suffix and strips it for returned logical keys.
fs driver implementation
src/drivers/fs.ts
rFile(key) resolver appends dataSuffix to physical paths. All storage API methods updated to use rFile. Async getKeys() filters and strips the suffix. Chokidar watcher event handler filters paths lacking the suffix and strips dataSuffix from relative paths before invoking update/remove callbacks with logical keys.
fs-lite test suite
test/drivers/fs-lite.test.ts
Tests verify collision avoidance (e.g., keys "foo" and "foo:bar" as distinct files), standard storage operations under dataSuffix, validation of invalid suffixes, single-suffix stripping when key ends with suffix, suffix-filtered key enumeration, and regression ensuring empty key remains base-addressable.
fs test suite
test/drivers/fs.test.ts
Comprehensive test suite covering collision avoidance with disk path verification, standard storage flow, input validation, suffix stripping behavior, key enumeration filtering, watcher event suffix stripping with OS path normalization, and root key base-containment regression.
User documentation
docs/2.drivers/fs.md
Updated option list and added "Preventing key/namespace collisions" section explaining collision scenarios, suffix behavior, automatic key stripping, and warnings that enabling dataSuffix changes on-disk layout visibility of existing unsuffixed files.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Driver as "FS Driver<br/>(fs / fs-lite)"
    participant rFile as "rFile(key)<br/>Path Resolver"
    participant Disk as "Filesystem"
    participant Watcher as "Chokidar Watcher<br/>(fs only)"

    Client->>Driver: setItem("foo", value)
    Driver->>rFile: rFile("foo")
    rFile->>rFile: r("foo") + ".data"
    rFile-->>Driver: "/base/foo.data"
    Driver->>Disk: write "/base/foo.data"
    Disk-->>Driver: ✓
    Driver-->>Client: ✓

    Client->>Driver: getKeys()
    Driver->>Disk: readdir recursive
    Disk-->>Driver: ["foo.data", "bar.data", ...]
    Driver->>Driver: filter *.data, strip suffix
    Driver-->>Client: ["foo", "bar", ...]

    Disk-->>Watcher: fs event "/base/foo.data"
    Watcher->>Driver: event(relPath: "foo.data")
    Driver->>Driver: strip ".data"
    Driver->>Client: callback("update", "foo")
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A suffix blooms on files so bright,
Keys once tangled now take flight,
.data marks each treasured store,
Collisions vanish evermore,
Hop, verify, and watch in delight! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding a dataSuffix option to fs/fs-lite drivers to prevent file/directory key collisions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

✏️ 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.

@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 (1)
test/drivers/fs.test.ts (1)

115-121: Consider reusing the storage instance from the test.

The afterEach hook creates a new storage instance for cleanup. Since each test already creates and disposes its own storage instance, you could track the storage instance at the describe level and reuse it for cleanup.

♻️ Suggested refactor
   describe("dataSuffix option", () => {
     const suffixDir = resolve(__dirname, "tmp/fs-suffix");
+    let storage: ReturnType<typeof createStorage>;

     afterEach(async () => {
-      const s = createStorage({
-        driver: driver({ base: suffixDir, dataSuffix: ".data" }),
-      });
-      await s.clear();
-      await s.dispose();
+      if (storage) {
+        await storage.clear();
+        await storage.dispose();
+      }
     });

     it("prevents file/directory collision with dataSuffix", async () => {
       const d = driver({ base: suffixDir, dataSuffix: ".data" });
-      const storage = createStorage({ driver: d });
+      storage = createStorage({ driver: d });
       // ... rest of test without the dispose call at the end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/drivers/fs.test.ts` around lines 115 - 121, The afterEach currently
creates a new storage with createStorage/driver to clean up; instead declare a
describe-scoped variable (e.g., let storage = null) and have each test assign
its storage instance to that variable when it creates one, then update afterEach
to check that variable and call storage.clear() and storage.dispose() if present
(and reset it to null); update references in afterEach from the temporary s to
this describe-scoped storage and ensure tests still dispose or let afterEach
handle disposal consistently (methods: createStorage, driver, afterEach, clear,
dispose).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/drivers/fs.test.ts`:
- Around line 115-121: The afterEach currently creates a new storage with
createStorage/driver to clean up; instead declare a describe-scoped variable
(e.g., let storage = null) and have each test assign its storage instance to
that variable when it creates one, then update afterEach to check that variable
and call storage.clear() and storage.dispose() if present (and reset it to
null); update references in afterEach from the temporary s to this
describe-scoped storage and ensure tests still dispose or let afterEach handle
disposal consistently (methods: createStorage, driver, afterEach, clear,
dispose).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 40e828ed-5314-4a62-93d0-75b0b27886e2

📥 Commits

Reviewing files that changed from the base of the PR and between fb7b8aa and dc5a9f6.

📒 Files selected for processing (4)
  • src/drivers/fs-lite.ts
  • src/drivers/fs.ts
  • test/drivers/fs-lite.test.ts
  • test/drivers/fs.test.ts

…ffix is set

getKeys was using map (passing through non-suffixed files as phantom keys)
instead of filter+map. Watcher was emitting events for non-suffixed files.
Both now ignore files that don't end with the configured dataSuffix.
@Norbiros

Norbiros commented May 9, 2026

Copy link
Copy Markdown

@pi0 (sorry for the direct ping), but would you mind taking a look at this PR? The issue it resolves directly impacts Nitro and, as a result, real Nuxt applications. Thanks in advance!

Harden the dataSuffix option from this PR:

- Validate dataSuffix at construction: reject empty, "." , ".." and any value
  containing "/", "\" or ":". These would create unexpected nesting, escape the
  base directory, or break the getKeys/watch suffix round-trip.
- Fix a base escape: a root/empty key resolves to `base` itself, so
  `base + dataSuffix` wrote a sibling file *outside* the configured base
  directory (e.g. `<base>.data`). Keep the suffixed file inside base instead.
- Document `dataSuffix` for both drivers, including the on-disk migration caveat
  (enabling it hides pre-existing un-suffixed files).
- Add tests: suffix validation, strip-exactly-once for a key already ending in
  the suffix, getKeys ignoring non-suffixed files, watcher suffix stripping, and
  a root-key no-escape regression.

@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 `@test/drivers/fs.test.ts`:
- Around line 204-205: Replace the hardcoded 700ms setTimeout delay in the
watcher test with a condition-based polling mechanism that repeatedly checks for
the expected update (the watcher observing the file change) within a reasonable
bounded timeout (such as 5 seconds with small intervals like 50-100ms). This
ensures the test passes quickly when the condition is met while still providing
enough time on slow CI runners, eliminating flakiness from fixed sleep
durations.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: a3965eb4-ae28-413e-89d4-2e94ac5ab2d8

📥 Commits

Reviewing files that changed from the base of the PR and between 31f85d7 and a32128f.

📒 Files selected for processing (5)
  • docs/2.drivers/fs.md
  • src/drivers/fs-lite.ts
  • src/drivers/fs.ts
  • test/drivers/fs-lite.test.ts
  • test/drivers/fs.test.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/2.drivers/fs.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/drivers/fs.ts
  • src/drivers/fs-lite.ts

Comment thread test/drivers/fs.test.ts
Comment on lines +204 to +205
await new Promise((resolve) => setTimeout(resolve, 700));
// separator-agnostic (key may use ":", "/" or "\\" depending on OS/normalization)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace fixed sleep with condition-based waiting in watcher test.

The hardcoded 700ms delay can cause flaky CI (slow runners) and unnecessary wait time (fast runners). Wait until the expected update is observed with a bounded timeout instead of sleeping a fixed duration.

Suggested change
-      await new Promise((resolve) => setTimeout(resolve, 700));
+      const deadline = Date.now() + 3000;
+      while (Date.now() < deadline) {
+        const updates = watcher.mock.calls
+          .filter(([event]) => event === "update")
+          .map(([, key]) => String(key));
+        if (updates.some((k) => /(^|[:/\\])random$/.test(k))) {
+          break;
+        }
+        await new Promise((resolve) => setTimeout(resolve, 25));
+      }
🤖 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 `@test/drivers/fs.test.ts` around lines 204 - 205, Replace the hardcoded 700ms
setTimeout delay in the watcher test with a condition-based polling mechanism
that repeatedly checks for the expected update (the watcher observing the file
change) within a reasonable bounded timeout (such as 5 seconds with small
intervals like 50-100ms). This ensures the test passes quickly when the
condition is met while still providing enough time on slow CI runners,
eliminating flakiness from fixed sleep durations.

@claygeo

claygeo commented Jun 19, 2026

Copy link
Copy Markdown
Author

Polished this and re-confirmed it's still needed: the released and current main fs/fs-lite drivers still hit the foo + foo:bar file/directory collision (ENOTDIR on POSIX, ENOENT on Windows), verified at runtime.

This push adds:

  • Validation of dataSuffix at construction — rejects empty, ./.., and any value containing /, \ or : (these would create nesting, escape the base directory, or break the getKeys/watch round-trip).
  • A base-escape fix: an empty/root key resolves to base itself, so base + dataSuffix wrote a sibling file outside the configured base directory (e.g. <base>.data). It now stays inside base.
  • Docs for both drivers in docs/drivers/fs, including the on-disk migration caveat (enabling the option hides pre-existing un-suffixed files).
  • Tests: validation, strip-exactly-once (a key already ending in the suffix), getKeys ignoring non-suffixed files, watcher suffix-stripping, and the root-key no-escape regression.

@pi0 this is the unstorage fs-driver fix you pointed to from nitrojs/nitro#4156 — ready for review when you have a moment.

claygeo added 2 commits June 19, 2026 15:42
The migration caveat used ::alert{type="warning"}, which is not a
container component in this docs setup (every other doc uses ::warning).
It would have rendered as literal text. Switch to ::warning.
Adversarial review surfaced two honest caveats worth documenting (no code
change needed):
- A key that itself ends with the suffix can still collide with a same-named
  namespace (e.g. "foo" vs "foo.data:bar" with suffix ".data"). Tell users
  to pick a suffix their keys do not end with.
- clear() removes the whole base dir, including the non-suffixed files that
  getItem/getKeys otherwise ignore.
@claygeo

claygeo commented Jun 19, 2026

Copy link
Copy Markdown
Author

Friendly nudge on this one, and a small update. I re-confirmed the file/directory key collision still reproduces on the latest main (keys foo and foo:bar can't coexist: <base>/foo would need to be a file and a directory), so this is still relevant.

Since the original PR I've hardened the dataSuffix implementation:

  • Validation of dataSuffix at construction: rejects empty, ., .., and any value containing /, \, or : (these would create nesting, escape the base dir, or break the getKeys/watch round-trip).
  • Fixed a base-directory escape: a root/empty key resolves to base itself, so base + dataSuffix wrote a sibling file outside the configured base (e.g. <base>.data). It now stays inside base.
  • Docs for both fs and fs-lite, including the on-disk migration caveat and clear() behavior.
  • Tests: collision coexistence, suffix validation, strip-exactly-once round-trip, watcher suffix stripping, and a root-key no-escape regression. All fs/fs-lite tests pass locally.

Still mergeable with no conflicts, default undefined keeps it fully backward compatible.

@pi0 you pointed nitrojs/nitro#4156 here as the right home for this fix. Would you (or another maintainer) be able to take a look when you get a chance? Happy to adjust naming or scope. 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.

2 participants