Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 31 additions & 9 deletions src/drivers/fs-lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /\.\.:|\.\.$/;
Expand All @@ -20,6 +29,8 @@ const driver: DriverFactory<FSStorageOptions> = (opts = {}) => {
}

opts.base = resolve(opts.base);
const dataSuffix = opts.dataSuffix;

const r = (key: string) => {
if (PATH_TRAVERSE_RE.test(key)) {
throw createError(
Expand All @@ -31,47 +42,58 @@ const driver: DriverFactory<FSStorageOptions> = (opts = {}) => {
return resolved;
};

const rFile = (key: string) => {
const resolved = r(key);
return dataSuffix ? resolved + dataSuffix : resolved;
};

return {
name: DRIVER_NAME,
options: opts,
flags: {
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 };
},
setItem(key, value) {
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<void>;
return unlink(rFile(key)) as Promise<void>;
},
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) {
Expand Down
43 changes: 34 additions & 9 deletions src/drivers/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /\.\.:|\.\.$/;
Expand All @@ -41,6 +50,8 @@ const driver: DriverFactory<FSStorageOptions> = (userOptions = {}) => {
});
};

const dataSuffix = userOptions.dataSuffix;

const r = (key: string) => {
if (PATH_TRAVERSE_RE.test(key)) {
throw createError(
Expand All @@ -52,6 +63,11 @@ const driver: DriverFactory<FSStorageOptions> = (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) {
Expand All @@ -67,40 +83,46 @@ const driver: DriverFactory<FSStorageOptions> = (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 };
},
setItem(key, value) {
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<void>;
return unlink(rFile(key)) as Promise<void>;
},
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) {
Expand Down Expand Up @@ -139,6 +161,9 @@ const driver: DriverFactory<FSStorageOptions> = (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") {
Expand Down
57 changes: 56 additions & 1 deletion test/drivers/fs-lite.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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 <base>/foo, but
// "foo:bar" needs <base>/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();
});
});
});
54 changes: 54 additions & 0 deletions test/drivers/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base>/foo, but
// "foo:bar" needs <base>/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();
});
});
});