Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
41 changes: 41 additions & 0 deletions docs/2.drivers/fs.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const storage = createStorage({
- `base`: Base directory to isolate operations on this directory
- `ignore`: Ignore patterns for watch <!-- and key listing -->
- `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)

Expand All @@ -46,3 +47,43 @@ 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 `<base>/foo`, and then writing
`foo:bar` needs `<base>/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. 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.
::
64 changes: 55 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,25 @@ const driver: DriverFactory<FSStorageOptions> = (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(
Expand All @@ -31,47 +59,65 @@ const driver: DriverFactory<FSStorageOptions> = (opts = {}) => {
return resolved;
};

const rFile = (key: string) => {
const resolved = r(key);
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 {
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
.filter((key) => key.endsWith(dataSuffix))
.map((key) => key.slice(0, -dataSuffix.length));
}
return keys;
},
async clear() {
if (opts.readOnly || opts.noClear) {
Expand Down
68 changes: 59 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,25 @@ const driver: DriverFactory<FSStorageOptions> = (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(
Expand All @@ -52,6 +80,16 @@ const driver: DriverFactory<FSStorageOptions> = (userOptions = {}) => {
return resolved;
};

const rFile = (key: string) => {
const resolved = r(key);
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;
const _unwatch = async () => {
if (_watcher) {
Expand All @@ -67,40 +105,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
.filter((key) => key.endsWith(dataSuffix))
.map((key) => key.slice(0, -dataSuffix.length));
}
return keys;
},
async clear() {
if (userOptions.readOnly || userOptions.noClear) {
Expand Down Expand Up @@ -139,6 +183,12 @@ const driver: DriverFactory<FSStorageOptions> = (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") {
Expand Down
Loading