diff --git a/docs/content/6.drivers/fs.md b/docs/content/6.drivers/fs.md index c16e3b13..edadcbfa 100644 --- a/docs/content/6.drivers/fs.md +++ b/docs/content/6.drivers/fs.md @@ -22,3 +22,21 @@ 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. + +## Node.js Filesystem (Lite) + +This driver uses pure Node.js API without extra dependencies. + +```js +import { createStorage } from "unstorage"; +import fsLiteDriver from "unstorage/drivers/fs-lite"; + +const storage = createStorage({ + driver: fsLiteDriver({ base: "./tmp" }), +}); +``` + +**Options:** + +- `base`: Base directory to isolate operations on this directory +- `ignore`: Optional callback function `(path: stirng) => boolean` diff --git a/src/drivers/fs-lite.ts b/src/drivers/fs-lite.ts new file mode 100644 index 00000000..1ed9ed2c --- /dev/null +++ b/src/drivers/fs-lite.ts @@ -0,0 +1,87 @@ +import { existsSync, promises as fsp, Stats } from "fs"; +import { resolve, join } from "path"; +import { createError, createRequiredError, defineDriver } from "./utils"; +import { + readFile, + writeFile, + readdirRecursive, + rmRecursive, + unlink, +} from "./utils/node-fs"; +import anymatch from "anymatch"; + +export interface FSStorageOptions { + base?: string; + ignore?: (path: string) => boolean; + readOnly?: boolean; + noClear?: boolean; +} + +const PATH_TRAVERSE_RE = /\.\.\:|\.\.$/; + +const DRIVER_NAME = "fs-lite"; + +export default defineDriver((opts: FSStorageOptions = {}) => { + if (!opts.base) { + throw createRequiredError(DRIVER_NAME, "base"); + } + + opts.base = resolve(opts.base); + const r = (key: string) => { + if (PATH_TRAVERSE_RE.test(key)) { + throw createError( + DRIVER_NAME, + `Invalid key: ${JSON.stringify(key)}. It should not contain .. segments` + ); + } + const resolved = join(opts.base!, key.replace(/:/g, "/")); + return resolved; + }; + + return { + name: DRIVER_NAME, + options: opts, + hasItem(key) { + return existsSync(r(key)); + }, + getItem(key) { + return readFile(r(key), "utf8"); + }, + getItemRaw(key) { + return readFile(r(key)); + }, + async getMeta(key) { + const { atime, mtime, size, birthtime, ctime } = await fsp + .stat(r(key)) + .catch(() => ({}) as Stats); + return { atime, mtime, size, birthtime, ctime }; + }, + setItem(key, value) { + if (opts.readOnly) { + return; + } + return writeFile(r(key), value, "utf8"); + }, + setItemRaw(key, value) { + if (opts.readOnly) { + return; + } + return writeFile(r(key), value); + }, + removeItem(key) { + if (opts.readOnly) { + return; + } + return unlink(r(key)); + }, + getKeys() { + return readdirRecursive(r("."), opts.ignore); + }, + async clear() { + if (opts.readOnly || opts.noClear) { + return; + } + await rmRecursive(r(".")); + }, + }; +}); diff --git a/src/drivers/utils/node-fs.ts b/src/drivers/utils/node-fs.ts index 31c42967..2d8eb029 100644 --- a/src/drivers/utils/node-fs.ts +++ b/src/drivers/utils/node-fs.ts @@ -62,7 +62,7 @@ export async function readdirRecursive( const dirFiles = await readdirRecursive(entryPath, ignore); files.push(...dirFiles.map((f) => entry.name + "/" + f)); } else { - if (ignore && !ignore(entry.name)) { + if (!(ignore && ignore(entry.name))) { files.push(entry.name); } } diff --git a/src/index.ts b/src/index.ts index 7fbb9696..d1eef423 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ export const builtinDrivers = { cloudflareKVHTTP: "unstorage/drivers/cloudflare-kv-http", cloudflareR2Binding: "unstorage/drivers/cloudflare-r2-binding", fs: "unstorage/drivers/fs", + fsLite: "unstorage/drivers/fs-lite", github: "unstorage/drivers/github", http: "unstorage/drivers/http", indexedb: "unstorage/drivers/indexedb", @@ -60,6 +61,7 @@ export type BuiltinDriverOptions = { (typeof import("./drivers/cloudflare-r2-binding"))["default"] >; fs: ExtractOpts<(typeof import("./drivers/fs"))["default"]>; + fsLite: ExtractOpts<(typeof import("./drivers/fs-lite"))["default"]>; github: ExtractOpts<(typeof import("./drivers/github"))["default"]>; http: ExtractOpts<(typeof import("./drivers/http"))["default"]>; indexedb: ExtractOpts<(typeof import("./drivers/indexedb"))["default"]>; diff --git a/test/drivers/fs-lite.test.ts b/test/drivers/fs-lite.test.ts new file mode 100644 index 00000000..683e7280 --- /dev/null +++ b/test/drivers/fs-lite.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { readFile } from "../../src/drivers/utils/node-fs"; +import { testDriver } from "./utils"; +import driver from "../../src/drivers/fs-lite"; + +describe("drivers: fs-lite", () => { + const dir = resolve(__dirname, "tmp/fs-lite"); + + testDriver({ + driver: driver({ base: dir }), + additionalTests(ctx) { + it("check filesystem", async () => { + expect(await readFile(resolve(dir, "s1/a"), "utf8")).toBe("test_data"); + }); + it("native meta", async () => { + const meta = await ctx.storage.getMeta("/s1/a"); + expect(meta.atime?.constructor.name).toBe("Date"); + expect(meta.mtime?.constructor.name).toBe("Date"); + expect(meta.size).toBeGreaterThan(0); + }); + + const invalidKeys = ["../foobar", "..:foobar", "../", "..:", ".."]; + for (const key of invalidKeys) { + it("disallow path travesal: ", async () => { + await expect(ctx.storage.getItem(key)).rejects.toThrow("Invalid key"); + }); + } + + it("allow double dots in filename: ", async () => { + await ctx.storage.setItem("s1/te..st..js", "ok"); + expect(await ctx.storage.getItem("s1/te..st..js")).toBe("ok"); + }); + }, + }); +}); diff --git a/test/drivers/fs.test.ts b/test/drivers/fs.test.ts index 65eb95b6..987561ef 100644 --- a/test/drivers/fs.test.ts +++ b/test/drivers/fs.test.ts @@ -5,7 +5,7 @@ import { testDriver } from "./utils"; import driver from "../../src/drivers/fs"; describe("drivers: fs", () => { - const dir = resolve(__dirname, "tmp"); + const dir = resolve(__dirname, "tmp/fs"); testDriver({ driver: driver({ base: dir }),