diff --git a/src/drivers/fs-lite.ts b/src/drivers/fs-lite.ts index 79a6cdc8..1b877061 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 + .filter((key) => key.endsWith(dataSuffix)) + .map((key) => key.slice(0, -dataSuffix.length)); + } + return keys; }, async clear() { if (opts.readOnly || opts.noClear) { diff --git a/src/drivers/fs.ts b/src/drivers/fs.ts index 3facd0c7..ada099e0 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 + .filter((key) => key.endsWith(dataSuffix)) + .map((key) => key.slice(0, -dataSuffix.length)); + } + return keys; }, async clear() { if (userOptions.readOnly || userOptions.noClear) { @@ -139,6 +161,12 @@ const driver: DriverFactory = (userOptions = {}) => { .on("error", reject) .on("all", (eventName, path) => { path = relative(base, path); + if (dataSuffix) { + if (!path.endsWith(dataSuffix)) { + return; // ignore non-suffixed files + } + 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 e5e5b3b7..4fb23281 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 366f5f0e..260648fd 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(); + }); + }); });