From dc5a9f69c153683ca63ba27311a778a68de52279 Mon Sep 17 00:00:00 2001 From: kmaclip Date: Tue, 31 Mar 2026 08:52:12 -0400 Subject: [PATCH 1/5] feat(fs,fs-lite): add dataSuffix option to prevent file/directory key collisions When both `foo` and `foo:bar` exist as storage keys, the fs drivers try to create a file at `/foo` and a directory at `/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. --- src/drivers/fs-lite.ts | 40 +++++++++++++++++++------ src/drivers/fs.ts | 43 +++++++++++++++++++++------ test/drivers/fs-lite.test.ts | 57 +++++++++++++++++++++++++++++++++++- test/drivers/fs.test.ts | 54 ++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 19 deletions(-) diff --git a/src/drivers/fs-lite.ts b/src/drivers/fs-lite.ts index 79a6cdc85..70dac2273 100644 --- a/src/drivers/fs-lite.ts +++ b/src/drivers/fs-lite.ts @@ -8,6 +8,15 @@ export interface FSStorageOptions { ignore?: (path: string) => boolean; readOnly?: boolean; noClear?: boolean; + /** + * Suffix appended to all stored file paths on disk. + * + * When set (e.g. `".data"`), key `foo` is stored as `foo.data` and + * key `foo:bar` as `foo/bar.data`. This prevents file/directory + * collisions that occur when both `foo` and `foo:bar` exist as keys + * (the plain key would need `foo` to be both a file *and* a directory). + */ + dataSuffix?: string; } const PATH_TRAVERSE_RE = /\.\.:|\.\.$/; @@ -20,6 +29,8 @@ const driver: DriverFactory = (opts = {}) => { } opts.base = resolve(opts.base); + const dataSuffix = opts.dataSuffix; + const r = (key: string) => { if (PATH_TRAVERSE_RE.test(key)) { throw createError( @@ -31,6 +42,11 @@ const driver: DriverFactory = (opts = {}) => { return resolved; }; + const rFile = (key: string) => { + const resolved = r(key); + return dataSuffix ? resolved + dataSuffix : resolved; + }; + return { name: DRIVER_NAME, options: opts, @@ -38,17 +54,17 @@ const driver: DriverFactory = (opts = {}) => { maxDepth: true, }, hasItem(key) { - return existsSync(r(key)); + return existsSync(rFile(key)); }, getItem(key) { - return readFile(r(key), "utf8"); + return readFile(rFile(key), "utf8"); }, getItemRaw(key) { - return readFile(r(key)); + return readFile(rFile(key)); }, async getMeta(key) { const { atime, mtime, size, birthtime, ctime } = await fsp - .stat(r(key)) + .stat(rFile(key)) .catch(() => ({}) as Stats); return { atime, mtime, size, birthtime, ctime }; }, @@ -56,22 +72,28 @@ const driver: DriverFactory = (opts = {}) => { if (opts.readOnly) { return; } - return writeFile(r(key), value, "utf8"); + return writeFile(rFile(key), value, "utf8"); }, setItemRaw(key, value) { if (opts.readOnly) { return; } - return writeFile(r(key), value); + return writeFile(rFile(key), value); }, removeItem(key) { if (opts.readOnly) { return; } - return unlink(r(key)) as Promise; + return unlink(rFile(key)) as Promise; }, - getKeys(_base, topts) { - return readdirRecursive(r("."), opts.ignore, topts?.maxDepth); + async getKeys(_base, topts) { + const keys = await readdirRecursive(r("."), opts.ignore, topts?.maxDepth); + if (dataSuffix) { + return keys.map((key) => + key.endsWith(dataSuffix) ? key.slice(0, -dataSuffix.length) : key, + ); + } + return keys; }, async clear() { if (opts.readOnly || opts.noClear) { diff --git a/src/drivers/fs.ts b/src/drivers/fs.ts index 3facd0c79..1579b85bb 100644 --- a/src/drivers/fs.ts +++ b/src/drivers/fs.ts @@ -17,6 +17,15 @@ export interface FSStorageOptions { readOnly?: boolean; noClear?: boolean; watchOptions?: ChokidarOptions; + /** + * Suffix appended to all stored file paths on disk. + * + * When set (e.g. `".data"`), key `foo` is stored as `foo.data` and + * key `foo:bar` as `foo/bar.data`. This prevents file/directory + * collisions that occur when both `foo` and `foo:bar` exist as keys + * (the plain key would need `foo` to be both a file *and* a directory). + */ + dataSuffix?: string; } const PATH_TRAVERSE_RE = /\.\.:|\.\.$/; @@ -41,6 +50,8 @@ const driver: DriverFactory = (userOptions = {}) => { }); }; + const dataSuffix = userOptions.dataSuffix; + const r = (key: string) => { if (PATH_TRAVERSE_RE.test(key)) { throw createError( @@ -52,6 +63,11 @@ const driver: DriverFactory = (userOptions = {}) => { return resolved; }; + const rFile = (key: string) => { + const resolved = r(key); + return dataSuffix ? resolved + dataSuffix : resolved; + }; + let _watcher: FSWatcher | undefined; const _unwatch = async () => { if (_watcher) { @@ -67,17 +83,17 @@ const driver: DriverFactory = (userOptions = {}) => { maxDepth: true, }, hasItem(key) { - return existsSync(r(key)); + return existsSync(rFile(key)); }, getItem(key) { - return readFile(r(key), "utf8"); + return readFile(rFile(key), "utf8"); }, getItemRaw(key) { - return readFile(r(key)); + return readFile(rFile(key)); }, async getMeta(key) { const { atime, mtime, size, birthtime, ctime } = await fsp - .stat(r(key)) + .stat(rFile(key)) .catch(() => ({}) as Stats); return { atime, mtime, size, birthtime, ctime }; }, @@ -85,22 +101,28 @@ const driver: DriverFactory = (userOptions = {}) => { if (userOptions.readOnly) { return; } - return writeFile(r(key), value, "utf8"); + return writeFile(rFile(key), value, "utf8"); }, setItemRaw(key, value) { if (userOptions.readOnly) { return; } - return writeFile(r(key), value); + return writeFile(rFile(key), value); }, removeItem(key) { if (userOptions.readOnly) { return; } - return unlink(r(key)) as Promise; + return unlink(rFile(key)) as Promise; }, - getKeys(_base, topts) { - return readdirRecursive(r("."), ignore, topts?.maxDepth); + async getKeys(_base, topts) { + const keys = await readdirRecursive(r("."), ignore, topts?.maxDepth); + if (dataSuffix) { + return keys.map((key) => + key.endsWith(dataSuffix) ? key.slice(0, -dataSuffix.length) : key, + ); + } + return keys; }, async clear() { if (userOptions.readOnly || userOptions.noClear) { @@ -139,6 +161,9 @@ const driver: DriverFactory = (userOptions = {}) => { .on("error", reject) .on("all", (eventName, path) => { path = relative(base, path); + if (dataSuffix && path.endsWith(dataSuffix)) { + path = path.slice(0, -dataSuffix.length); + } if (eventName === "change" || eventName === "add") { callback("update", path); } else if (eventName === "unlink") { diff --git a/test/drivers/fs-lite.test.ts b/test/drivers/fs-lite.test.ts index e5e5b3b72..4fb232812 100644 --- a/test/drivers/fs-lite.test.ts +++ b/test/drivers/fs-lite.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { resolve } from "node:path"; import { readFile } from "../../src/drivers/utils/node-fs.ts"; import { testDriver } from "./utils.ts"; import driver from "../../src/drivers/fs-lite.ts"; +import { createStorage } from "../../src/storage.ts"; describe("drivers: fs-lite", () => { const dir = resolve(__dirname, "tmp/fs-lite"); @@ -65,4 +66,58 @@ describe("drivers: fs-lite", () => { }); }, }); + + describe("dataSuffix option", () => { + const suffixDir = resolve(__dirname, "tmp/fs-lite-suffix"); + + afterEach(async () => { + const s = createStorage({ + driver: driver({ base: suffixDir, dataSuffix: ".data" }), + }); + await s.clear(); + await s.dispose(); + }); + + it("prevents file/directory collision with dataSuffix", async () => { + const d = driver({ base: suffixDir, dataSuffix: ".data" }); + const storage = createStorage({ driver: d }); + + // This is the key scenario: "foo" and "foo:bar" must coexist. + // Without dataSuffix, "foo" creates a file at /foo, but + // "foo:bar" needs /foo/ to be a directory -> ENOTDIR. + await storage.setItem("foo", "value_foo"); + await storage.setItem("foo:bar", "value_foo_bar"); + + expect(await storage.getItem("foo")).toBe("value_foo"); + expect(await storage.getItem("foo:bar")).toBe("value_foo_bar"); + + // Verify on-disk layout uses suffix + expect(await readFile(resolve(suffixDir, "foo.data"), "utf8")).toBe( + "value_foo", + ); + expect( + await readFile(resolve(suffixDir, "foo/bar.data"), "utf8"), + ).toBe("value_foo_bar"); + + // getKeys should return clean keys without the suffix + const keys = (await storage.getKeys()).sort(); + expect(keys).toEqual(["foo", "foo:bar"]); + + await storage.dispose(); + }); + + it("runs standard driver tests with dataSuffix", async () => { + const d = driver({ base: suffixDir, dataSuffix: ".data" }); + const storage = createStorage({ driver: d }); + + await storage.setItem("s1:a", "test_data"); + expect(await storage.hasItem("s1:a")).toBe(true); + expect(await storage.getItem("s1:a")).toBe("test_data"); + + await storage.removeItem("s1:a"); + expect(await storage.hasItem("s1:a")).toBe(false); + + await storage.dispose(); + }); + }); }); diff --git a/test/drivers/fs.test.ts b/test/drivers/fs.test.ts index 366f5f0e0..260648fde 100644 --- a/test/drivers/fs.test.ts +++ b/test/drivers/fs.test.ts @@ -108,4 +108,58 @@ describe("drivers: fs", () => { await ctx.storage?.dispose(); await ctx.driver?.dispose?.(); }); + + describe("dataSuffix option", () => { + const suffixDir = resolve(__dirname, "tmp/fs-suffix"); + + afterEach(async () => { + const s = createStorage({ + driver: driver({ base: suffixDir, dataSuffix: ".data" }), + }); + await s.clear(); + await s.dispose(); + }); + + it("prevents file/directory collision with dataSuffix", async () => { + const d = driver({ base: suffixDir, dataSuffix: ".data" }); + const storage = createStorage({ driver: d }); + + // This is the key scenario: "foo" and "foo:bar" must coexist. + // Without dataSuffix, "foo" creates a file at /foo, but + // "foo:bar" needs /foo/ to be a directory -> ENOTDIR. + await storage.setItem("foo", "value_foo"); + await storage.setItem("foo:bar", "value_foo_bar"); + + expect(await storage.getItem("foo")).toBe("value_foo"); + expect(await storage.getItem("foo:bar")).toBe("value_foo_bar"); + + // Verify on-disk layout uses suffix + expect(await readFile(resolve(suffixDir, "foo.data"), "utf8")).toBe( + "value_foo", + ); + expect( + await readFile(resolve(suffixDir, "foo/bar.data"), "utf8"), + ).toBe("value_foo_bar"); + + // getKeys should return clean keys without the suffix + const keys = (await storage.getKeys()).sort(); + expect(keys).toEqual(["foo", "foo:bar"]); + + await storage.dispose(); + }); + + it("runs standard driver tests with dataSuffix", async () => { + const d = driver({ base: suffixDir, dataSuffix: ".data" }); + const storage = createStorage({ driver: d }); + + await storage.setItem("s1:a", "test_data"); + expect(await storage.hasItem("s1:a")).toBe(true); + expect(await storage.getItem("s1:a")).toBe("test_data"); + + await storage.removeItem("s1:a"); + expect(await storage.hasItem("s1:a")).toBe(false); + + await storage.dispose(); + }); + }); }); From 31f85d779d0c2f7ec07c26f7d7f02448b0437e62 Mon Sep 17 00:00:00 2001 From: kmaclip Date: Tue, 31 Mar 2026 09:01:50 -0400 Subject: [PATCH 2/5] fix: filter out non-suffixed files in getKeys and watcher when dataSuffix 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. --- src/drivers/fs-lite.ts | 6 +++--- src/drivers/fs.ts | 11 +++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/drivers/fs-lite.ts b/src/drivers/fs-lite.ts index 70dac2273..1b877061c 100644 --- a/src/drivers/fs-lite.ts +++ b/src/drivers/fs-lite.ts @@ -89,9 +89,9 @@ const driver: DriverFactory = (opts = {}) => { async getKeys(_base, topts) { const keys = await readdirRecursive(r("."), opts.ignore, topts?.maxDepth); if (dataSuffix) { - return keys.map((key) => - key.endsWith(dataSuffix) ? key.slice(0, -dataSuffix.length) : key, - ); + return keys + .filter((key) => key.endsWith(dataSuffix)) + .map((key) => key.slice(0, -dataSuffix.length)); } return keys; }, diff --git a/src/drivers/fs.ts b/src/drivers/fs.ts index 1579b85bb..ada099e01 100644 --- a/src/drivers/fs.ts +++ b/src/drivers/fs.ts @@ -118,9 +118,9 @@ const driver: DriverFactory = (userOptions = {}) => { async getKeys(_base, topts) { const keys = await readdirRecursive(r("."), ignore, topts?.maxDepth); if (dataSuffix) { - return keys.map((key) => - key.endsWith(dataSuffix) ? key.slice(0, -dataSuffix.length) : key, - ); + return keys + .filter((key) => key.endsWith(dataSuffix)) + .map((key) => key.slice(0, -dataSuffix.length)); } return keys; }, @@ -161,7 +161,10 @@ const driver: DriverFactory = (userOptions = {}) => { .on("error", reject) .on("all", (eventName, path) => { path = relative(base, path); - if (dataSuffix && path.endsWith(dataSuffix)) { + if (dataSuffix) { + if (!path.endsWith(dataSuffix)) { + return; // ignore non-suffixed files + } path = path.slice(0, -dataSuffix.length); } if (eventName === "change" || eventName === "add") { From a32128f5ffad5bfc0173fbca1d60013bf821fba0 Mon Sep 17 00:00:00 2001 From: claygeo Date: Fri, 19 Jun 2026 10:30:53 -0400 Subject: [PATCH 3/5] fix(fs,fs-lite): validate dataSuffix and keep root keys inside base 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. `.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. --- docs/2.drivers/fs.md | 37 ++++++++++++++++++++++ src/drivers/fs-lite.ts | 26 ++++++++++++++- src/drivers/fs.ts | 24 +++++++++++++- test/drivers/fs-lite.test.ts | 44 +++++++++++++++++++++++++- test/drivers/fs.test.ts | 61 ++++++++++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 3 deletions(-) diff --git a/docs/2.drivers/fs.md b/docs/2.drivers/fs.md index f227d3e50..096c1d879 100644 --- a/docs/2.drivers/fs.md +++ b/docs/2.drivers/fs.md @@ -28,6 +28,7 @@ const storage = createStorage({ - `base`: Base directory to isolate operations on this directory - `ignore`: Ignore patterns for watch - `watchOptions`: Additional [chokidar](https://github.com/paulmillr/chokidar) options. +- `dataSuffix`: Suffix appended to on-disk filenames to prevent key/namespace collisions (see [below](#preventing-keynamespace-collisions)). ## Node.js Filesystem (Lite) @@ -46,3 +47,39 @@ const storage = createStorage({ - `base`: Base directory to isolate operations on this directory - `ignore`: Optional callback function `(path: string) => boolean` +- `dataSuffix`: Suffix appended to on-disk filenames to prevent key/namespace collisions (see [below](#preventing-keynamespace-collisions)). + +## Preventing key/namespace collisions + +Both `fs` drivers map the `:` key separator to `/` on disk, so a key is also a +directory prefix of its sub-keys. That means a key and its own namespace cannot +normally coexist: writing `foo` creates the file `/foo`, and then writing +`foo:bar` needs `/foo` to be a directory, which fails (`ENOTDIR` on +POSIX, `ENOENT` on Windows). + +Set `dataSuffix` (for example `".data"`) to store every value in a suffixed +leaf file so values and namespaces never collide: + +```js +import { createStorage } from "unstorage"; +import fsDriver from "unstorage/drivers/fs"; + +const storage = createStorage({ + driver: fsDriver({ base: "./data", dataSuffix: ".data" }), +}); + +await storage.setItem("foo", "a"); // -> ./data/foo.data +await storage.setItem("foo:bar", "b"); // -> ./data/foo/bar.data (no collision) +``` + +The suffix is transparently added on writes and stripped from the keys returned +by `getKeys()` and watch callbacks. It must be a non-empty string other than `.` +or `..` and must not contain `/`, `\` or `:`. It defaults to `undefined` +(disabled) for backward compatibility. + +::alert{type="warning"} +`dataSuffix` changes the on-disk layout. Enabling it on a directory that already +holds un-suffixed files makes those files invisible to the driver (they are +skipped by `getItem`/`getKeys`). Treat enabling it as starting a fresh data +generation, or migrate existing files by renaming them to include the suffix. +:: diff --git a/src/drivers/fs-lite.ts b/src/drivers/fs-lite.ts index 1b877061c..12d5fcc46 100644 --- a/src/drivers/fs-lite.ts +++ b/src/drivers/fs-lite.ts @@ -31,6 +31,23 @@ const driver: DriverFactory = (opts = {}) => { opts.base = resolve(opts.base); const dataSuffix = opts.dataSuffix; + // `dataSuffix` is appended to leaf filenames, so it must not introduce path + // separators (which would create nesting) or the ":" key separator (which + // would break the round-trip in getKeys/watch). Reject those up front. + if ( + dataSuffix !== undefined && + (typeof dataSuffix !== "string" || + dataSuffix.length === 0 || + dataSuffix === "." || + dataSuffix === ".." || + /[/\\:]/.test(dataSuffix)) + ) { + throw createError( + DRIVER_NAME, + `Invalid dataSuffix: ${JSON.stringify(dataSuffix)}. It must be a non-empty string other than "." or ".." and must not contain "/", "\\" or ":".`, + ); + } + const r = (key: string) => { if (PATH_TRAVERSE_RE.test(key)) { throw createError( @@ -44,7 +61,14 @@ const driver: DriverFactory = (opts = {}) => { const rFile = (key: string) => { const resolved = r(key); - return dataSuffix ? resolved + dataSuffix : resolved; + if (!dataSuffix) { + return resolved; + } + // A root/empty key resolves to `base` itself; `base + suffix` would write a + // sibling *outside* base. Keep the suffixed file inside base instead. + return resolved === opts.base + ? join(opts.base!, dataSuffix) + : resolved + dataSuffix; }; return { diff --git a/src/drivers/fs.ts b/src/drivers/fs.ts index ada099e01..5d852ade9 100644 --- a/src/drivers/fs.ts +++ b/src/drivers/fs.ts @@ -52,6 +52,23 @@ const driver: DriverFactory = (userOptions = {}) => { const dataSuffix = userOptions.dataSuffix; + // `dataSuffix` is appended to leaf filenames, so it must not introduce path + // separators (which would create nesting) or the ":" key separator (which + // would break the round-trip in getKeys/watch). Reject those up front. + if ( + dataSuffix !== undefined && + (typeof dataSuffix !== "string" || + dataSuffix.length === 0 || + dataSuffix === "." || + dataSuffix === ".." || + /[/\\:]/.test(dataSuffix)) + ) { + throw createError( + DRIVER_NAME, + `Invalid dataSuffix: ${JSON.stringify(dataSuffix)}. It must be a non-empty string other than "." or ".." and must not contain "/", "\\" or ":".`, + ); + } + const r = (key: string) => { if (PATH_TRAVERSE_RE.test(key)) { throw createError( @@ -65,7 +82,12 @@ const driver: DriverFactory = (userOptions = {}) => { const rFile = (key: string) => { const resolved = r(key); - return dataSuffix ? resolved + dataSuffix : resolved; + if (!dataSuffix) { + return resolved; + } + // A root/empty key resolves to `base` itself; `base + suffix` would write a + // sibling *outside* base. Keep the suffixed file inside base instead. + return resolved === base ? join(base, dataSuffix) : resolved + dataSuffix; }; let _watcher: FSWatcher | undefined; diff --git a/test/drivers/fs-lite.test.ts b/test/drivers/fs-lite.test.ts index 4fb232812..1b15eb020 100644 --- a/test/drivers/fs-lite.test.ts +++ b/test/drivers/fs-lite.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach } from "vitest"; import { resolve } from "node:path"; -import { readFile } from "../../src/drivers/utils/node-fs.ts"; +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "../../src/drivers/utils/node-fs.ts"; import { testDriver } from "./utils.ts"; import driver from "../../src/drivers/fs-lite.ts"; import { createStorage } from "../../src/storage.ts"; @@ -119,5 +120,46 @@ describe("drivers: fs-lite", () => { await storage.dispose(); }); + + it("rejects an invalid dataSuffix", () => { + for (const bad of ["", ".", "..", "a/b", "a\\b", "a:b"]) { + expect(() => driver({ base: suffixDir, dataSuffix: bad })).toThrow( + /Invalid dataSuffix/, + ); + } + }); + + it("strips exactly one suffix (key already ending in the suffix)", async () => { + const storage = createStorage({ + driver: driver({ base: suffixDir, dataSuffix: ".data" }), + }); + // key "foo.data" -> on-disk "foo.data.data"; getKeys must strip one suffix only + await storage.setItem("foo.data", "v"); + expect(await storage.getItem("foo.data")).toBe("v"); + expect(await readFile(resolve(suffixDir, "foo.data.data"), "utf8")).toBe("v"); + expect(await storage.getKeys()).toEqual(["foo.data"]); + await storage.dispose(); + }); + + it("getKeys ignores files without the suffix", async () => { + const storage = createStorage({ + driver: driver({ base: suffixDir, dataSuffix: ".data" }), + }); + await storage.setItem("foo", "v"); // -> foo.data + await writeFile(resolve(suffixDir, "stray.txt"), "x", "utf8"); // not ours + expect((await storage.getKeys()).sort()).toEqual(["foo"]); + await storage.dispose(); + }); + + it("keeps an empty/root key inside base (no sibling escape)", async () => { + // Regression: rFile must not turn "" into `.data`, a sibling + // written outside the configured base directory. + const d = driver({ base: suffixDir, dataSuffix: ".data" }); + await d.setItem!("", "root", {}); + expect(existsSync(suffixDir + ".data")).toBe(false); // no escape + expect(await d.getItem!("")).toBe("root"); // still addressable + expect(await d.getKeys!("", {})).toEqual([""]); + await d.dispose?.(); + }); }); }); diff --git a/test/drivers/fs.test.ts b/test/drivers/fs.test.ts index 260648fde..dec59724c 100644 --- a/test/drivers/fs.test.ts +++ b/test/drivers/fs.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { resolve } from "node:path"; +import { existsSync } from "node:fs"; import { readFile, writeFile } from "../../src/drivers/utils/node-fs.ts"; import { testDriver, type TestContext } from "./utils.ts"; import driver from "../../src/drivers/fs.ts"; @@ -161,5 +162,65 @@ describe("drivers: fs", () => { await storage.dispose(); }); + + it("rejects an invalid dataSuffix", () => { + for (const bad of ["", ".", "..", "a/b", "a\\b", "a:b"]) { + expect(() => driver({ base: suffixDir, dataSuffix: bad })).toThrow( + /Invalid dataSuffix/, + ); + } + }); + + it("strips exactly one suffix (key already ending in the suffix)", async () => { + const storage = createStorage({ + driver: driver({ base: suffixDir, dataSuffix: ".data" }), + }); + // key "foo.data" -> on-disk "foo.data.data"; getKeys must strip one suffix only + await storage.setItem("foo.data", "v"); + expect(await storage.getItem("foo.data")).toBe("v"); + expect(await readFile(resolve(suffixDir, "foo.data.data"), "utf8")).toBe("v"); + expect(await storage.getKeys()).toEqual(["foo.data"]); + await storage.dispose(); + }); + + it("getKeys ignores files without the suffix", async () => { + const storage = createStorage({ + driver: driver({ base: suffixDir, dataSuffix: ".data" }), + }); + await storage.setItem("foo", "v"); // -> foo.data + await writeFile(resolve(suffixDir, "stray.txt"), "x", "utf8"); // not ours + expect((await storage.getKeys()).sort()).toEqual(["foo"]); + await storage.dispose(); + }); + + it("watcher strips the suffix and ignores non-suffixed files", async () => { + const storage = createStorage({ + driver: driver({ base: suffixDir, dataSuffix: ".data" }), + }); + const watcher = vi.fn(); + await storage.watch(watcher); + await writeFile(resolve(suffixDir, "s1/random.data"), "random", "utf8"); + await writeFile(resolve(suffixDir, "s1/ignored.txt"), "nope", "utf8"); + await new Promise((resolve) => setTimeout(resolve, 700)); + // separator-agnostic (key may use ":", "/" or "\\" depending on OS/normalization) + const updates = watcher.mock.calls + .filter(([event]) => event === "update") + .map(([, key]) => String(key)); + expect(updates.some((k) => /(^|[:/\\])random$/.test(k))).toBe(true); + expect(updates.every((k) => !k.includes(".data"))).toBe(true); // suffix never leaks + expect(updates.some((k) => k.includes("ignored"))).toBe(false); // non-suffixed ignored + await storage.dispose(); + }); + + it("keeps an empty/root key inside base (no sibling escape)", async () => { + // Regression: rFile must not turn "" into `.data`, a sibling + // written outside the configured base directory. + const d = driver({ base: suffixDir, dataSuffix: ".data" }); + await d.setItem!("", "root", {}); + expect(existsSync(suffixDir + ".data")).toBe(false); // no escape + expect(await d.getItem!("")).toBe("root"); // still addressable + expect(await d.getKeys!("", {})).toEqual([""]); + await d.dispose?.(); + }); }); }); From 58b7d91058b2aa6b2f084bbdacff7f3c3d1830e5 Mon Sep 17 00:00:00 2001 From: claygeo Date: Fri, 19 Jun 2026 15:42:24 -0400 Subject: [PATCH 4/5] docs(fs): use ::warning callout for dataSuffix migration note 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. --- docs/2.drivers/fs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/2.drivers/fs.md b/docs/2.drivers/fs.md index 096c1d879..dc656003a 100644 --- a/docs/2.drivers/fs.md +++ b/docs/2.drivers/fs.md @@ -77,7 +77,7 @@ by `getKeys()` and watch callbacks. It must be a non-empty string other than `.` or `..` and must not contain `/`, `\` or `:`. It defaults to `undefined` (disabled) for backward compatibility. -::alert{type="warning"} +::warning `dataSuffix` changes the on-disk layout. Enabling it on a directory that already holds un-suffixed files makes those files invisible to the driver (they are skipped by `getItem`/`getKeys`). Treat enabling it as starting a fresh data From 8740cea3c6b330df0d998348287302c7e5654ab7 Mon Sep 17 00:00:00 2001 From: claygeo Date: Fri, 19 Jun 2026 15:49:39 -0400 Subject: [PATCH 5/5] docs(fs): note residual collision and clear() behavior for dataSuffix 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. --- docs/2.drivers/fs.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/2.drivers/fs.md b/docs/2.drivers/fs.md index dc656003a..87e170cc7 100644 --- a/docs/2.drivers/fs.md +++ b/docs/2.drivers/fs.md @@ -75,11 +75,15 @@ await storage.setItem("foo:bar", "b"); // -> ./data/foo/bar.data (no collision) The suffix is transparently added on writes and stripped from the keys returned by `getKeys()` and watch callbacks. It must be a non-empty string other than `.` or `..` and must not contain `/`, `\` or `:`. It defaults to `undefined` -(disabled) for backward compatibility. +(disabled) for backward compatibility. Pick a suffix your keys do not end with: a +key that itself ends in the suffix can still collide with a same-named namespace +(for example `foo` and `foo.data:bar` when the suffix is `.data`). ::warning `dataSuffix` changes the on-disk layout. Enabling it on a directory that already holds un-suffixed files makes those files invisible to the driver (they are skipped by `getItem`/`getKeys`). Treat enabling it as starting a fresh data generation, or migrate existing files by renaming them to include the suffix. +`clear()` still removes the entire base directory, including any non-suffixed +files it otherwise ignores. ::