feat: unify store helpers
This commit is contained in:
parent
ff2e84aaea
commit
e210a26af2
@ -219,8 +219,8 @@ and policy knobs.
|
||||
Use `privateStateStore()` when the state is a directory of private text or JSON
|
||||
files rather than one known JSON file: credentials, auth profiles, tokens, and
|
||||
per-agent private state. It always writes files at `0o600` under directories at
|
||||
`0o700`, returns `null` for missing reads, and intentionally keeps the method
|
||||
set small.
|
||||
`0o700`, returns `null` for missing text/JSON reads, and otherwise shares the
|
||||
same managed-store shape as `fileStore`.
|
||||
|
||||
Use `fileStore()` for cache/blob/media-style directories where callers
|
||||
need safe relative paths, size limits, atomic replacement, stream writes, and
|
||||
@ -236,6 +236,7 @@ const media = fileStore({
|
||||
});
|
||||
|
||||
await media.write("inbound/photo.jpg", bytes);
|
||||
await media.writeJson("state/photo.json", { id: "photo" });
|
||||
const opened = await media.open("inbound/photo.jpg");
|
||||
await media.pruneExpired({ ttlMs: 10 * 60 * 1000, recursive: true });
|
||||
```
|
||||
|
||||
@ -27,6 +27,7 @@ const cache = fileStore({
|
||||
mode: 0o600, // file mode for writes (default 0o600)
|
||||
dirMode: 0o700, // mode for parent directories created on demand (default 0o700)
|
||||
maxBytes: 64 * 1024 * 1024, // optional: refuse writes/reads larger than this
|
||||
private: true, // optional: document intent for private 0600/0700 stores
|
||||
});
|
||||
```
|
||||
|
||||
@ -43,6 +44,10 @@ type FileStore = {
|
||||
open(rel, options?): Promise<OpenResult>;
|
||||
read(rel, options?): Promise<ReadResult>;
|
||||
readBytes(rel, options?): Promise<Buffer>;
|
||||
readText(rel, options?): Promise<string>;
|
||||
readJson<T = unknown>(rel, options?): Promise<T>;
|
||||
writeText(rel, data: string, options?): Promise<string>;
|
||||
writeJson(rel, data: unknown, options?): Promise<string>;
|
||||
remove(rel): Promise<void>;
|
||||
exists(rel): Promise<boolean>;
|
||||
pruneExpired(options: FileStorePruneOptions): Promise<void>;
|
||||
@ -65,6 +70,10 @@ const path = await cache.write("entries/2026/05/05.json", JSON.stringify(entry))
|
||||
|
||||
Buffer or string. Returns the final absolute path. Throws `too-large` if `data.byteLength` exceeds `maxBytes`.
|
||||
|
||||
### `writeText(rel, data, options?)` / `writeJson(rel, data, options?)`
|
||||
|
||||
Convenience wrappers over `write`. `writeJson` pretty-prints with a trailing newline by default and accepts `{ trailingNewline: false }` when the exact bytes matter.
|
||||
|
||||
### `writeStream(rel, stream, options?)`
|
||||
|
||||
```ts
|
||||
@ -97,7 +106,7 @@ type FileStoreWriteOptions = {
|
||||
|
||||
## Reads
|
||||
|
||||
`open`, `read`, `readBytes` delegate to a fresh `Root` with `hardlinks: "reject"` and the store's `maxBytes`. Same return shapes as `Root`.
|
||||
`open`, `read`, `readBytes`, `readText`, and `readJson` delegate to a fresh `Root` with `hardlinks: "reject"` and the store's `maxBytes`. Same return shapes as `Root`.
|
||||
|
||||
## `remove(rel)` / `exists(rel)`
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Private state store
|
||||
|
||||
`privateStateStore({ rootDir })` returns a small handle for reading and writing **JSON or text state** inside a trusted root directory. Every write atomically creates the parent directory tree at mode `0o700` and the file at mode `0o600`.
|
||||
`privateStateStore({ rootDir })` returns a private-mode `fileStore` handle for **JSON or text state** inside a trusted root directory. Every write atomically creates the parent directory tree at mode `0o700` and the file at mode `0o600`.
|
||||
|
||||
```ts
|
||||
import { privateStateStore } from "@openclaw/fs-safe/store";
|
||||
@ -15,9 +15,9 @@ const loaded = await store.readJson<State>("state.json");
|
||||
|
||||
- You have a single trusted directory holding small JSON or text state.
|
||||
- You want every write to land at mode `0o600` in dirs at `0o700` without thinking about it.
|
||||
- You don't need `move`, `remove`, `list`, `copyIn`, or streaming — only read/write.
|
||||
- You want lenient missing-file reads for text/JSON state, while keeping `remove`, `exists`, `open`, `copyIn`, stream writes, and pruning available from the same handle.
|
||||
|
||||
For richer file-store needs (remove, exists, open, copy-in, pruning, streams), use [`fileStore`](file-store.md). For general root operations, use [`root()`](root.md). For one-off credential reads, use the [secret-file helpers](secret-file.md).
|
||||
For non-private modes or cache/media-style stores, use [`fileStore`](file-store.md). For general root operations, use [`root()`](root.md). For one-off credential reads, use the [secret-file helpers](secret-file.md).
|
||||
|
||||
## API
|
||||
|
||||
@ -26,15 +26,12 @@ type PrivateStateStoreOptions = {
|
||||
rootDir: string;
|
||||
};
|
||||
|
||||
type PrivateStateStore = {
|
||||
rootDir: string;
|
||||
path(relativePath: string): string;
|
||||
|
||||
type PrivateStateStore = Omit<FileStore, "readText" | "readJson" | "writeText" | "writeJson"> & {
|
||||
readText(relativePath: string, options?: { maxBytes?: number }): Promise<string | null>;
|
||||
readJson<T = unknown>(relativePath: string, options?: { maxBytes?: number }): Promise<T | null>;
|
||||
|
||||
writeText(relativePath: string, content: string | Uint8Array): Promise<void>;
|
||||
writeJson(relativePath: string, value: unknown, options?: { trailingNewline?: boolean }): Promise<void>;
|
||||
writeText(relativePath: string, content: string | Uint8Array): Promise<string>;
|
||||
writeJson(relativePath: string, value: unknown, options?: { trailingNewline?: boolean }): Promise<string>;
|
||||
};
|
||||
|
||||
function privateStateStore(options: PrivateStateStoreOptions): PrivateStateStore;
|
||||
@ -42,7 +39,7 @@ function privateStateStore(options: PrivateStateStoreOptions): PrivateStateStore
|
||||
|
||||
`store.path(rel)` returns the absolute path the store would use, useful for logging or for handing to other libraries that take absolute paths.
|
||||
|
||||
`readText` and `readJson` return `null` when the file is missing — lenient by design. Callers that want strict failure on missing should check the result and throw.
|
||||
`readText` and `readJson` return `null` when the file is missing — lenient by design. Other inherited store methods keep the stricter `fileStore` behavior. Callers that want strict failure on missing should check the result and throw.
|
||||
|
||||
## Advanced standalone helpers
|
||||
|
||||
@ -122,7 +119,7 @@ if (!config) throw new Error("config missing");
|
||||
| Writes always set 0600 on the file and 0700 on the parents. | Writes use the umask unless you override. |
|
||||
| No streaming. | `open()` returns a `FileHandle`. |
|
||||
|
||||
If you find yourself asking "does the store have an X?" — reach for `fileStore()` or `root()`.
|
||||
If you find yourself asking for a root-level operation the store does not expose, call `store.root()` or use `root()` directly.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
Five minutes. By the end you will have a working `root()` and know how to read, write, atomically replace, and unpack an archive — without your code being able to escape the workspace.
|
||||
|
||||
If you have used Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std), this is the same shape: a capability-style handle that carries the boundary across every operation. The first thing to internalize is that you stop reasoning about *paths* and start reasoning about *the handle*.
|
||||
|
||||
## 1. Build a root
|
||||
|
||||
```ts
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
`fs-safe` is a library-level guardrail: a capability-style root handle for Node.js code that handles untrusted relative paths. It assumes the calling process already has whatever filesystem permissions it needs and aims to stop trivial path tricks from broadening that authority. It is not a sandbox and does not replace operating-system isolation.
|
||||
|
||||
The same shape exists in other languages: Go's [`os.Root` / `OpenInRoot`](https://go.dev/blog/osroot) and Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std) both expose a root handle whose operations refuse to escape it. `fs-safe` is the Node-side equivalent: a single `root()` capability that carries the boundary across every read, write, move, and remove, instead of leaving each call site to redo `path.resolve(...).startsWith(...)` and hope.
|
||||
|
||||
## Threat model
|
||||
|
||||
You hand a `root()` boundary to a piece of code that takes caller-controlled relative paths. The library defends against a caller that:
|
||||
|
||||
@ -90,7 +90,7 @@
|
||||
"scripts": {
|
||||
"benchmark": "node scripts/benchmark.mjs",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"prepack": "pnpm build",
|
||||
"prepack": "node scripts/prepack-build.mjs",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"check": "pnpm build && pnpm test",
|
||||
|
||||
@ -222,7 +222,7 @@ function markdownToHtml(markdown, currentRel) {
|
||||
closeList();
|
||||
flushBlockquote();
|
||||
if (fence) {
|
||||
html.push(`<pre><code class="language-${escapeAttr(fence.lang)}">${escapeHtml(fence.lines.join("\n"))}</code></pre>`);
|
||||
html.push(`<pre><code class="language-${escapeAttr(fence.lang)}">${highlightCode(fence.lines.join("\n"), fence.lang)}</code></pre>`);
|
||||
fence = null;
|
||||
} else {
|
||||
fence = { lang: fenceMatch[1] || "text", lines: [] };
|
||||
@ -541,6 +541,88 @@ function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
|
||||
}
|
||||
|
||||
function highlightLangAliases() {
|
||||
return {
|
||||
ts: "ts",
|
||||
typescript: "ts",
|
||||
js: "ts",
|
||||
javascript: "ts",
|
||||
tsx: "ts",
|
||||
jsx: "ts",
|
||||
bash: "bash",
|
||||
sh: "bash",
|
||||
shell: "bash",
|
||||
zsh: "bash",
|
||||
jsonc: "jsonc",
|
||||
json: "jsonc",
|
||||
};
|
||||
}
|
||||
|
||||
function highlightRules() {
|
||||
return {
|
||||
ts: [
|
||||
{ type: "comment", pattern: /\/\/[^\n]*|\/\*[\s\S]*?\*\// },
|
||||
{ type: "string", pattern: /"(?:[^"\\\n]|\\[\s\S])*"|'(?:[^'\\\n]|\\[\s\S])*'|`(?:[^`\\$]|\\[\s\S]|\$(?!\{)|\$\{[^}]*\})*`/ },
|
||||
{ type: "keyword", pattern: /\b(?:abstract|any|as|async|await|boolean|break|case|catch|class|const|continue|declare|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|infer|instanceof|interface|is|keyof|let|namespace|never|new|number|of|out|override|package|private|protected|public|readonly|return|satisfies|set|static|string|super|switch|symbol|this|throw|try|type|typeof|undefined|unique|unknown|var|void|while|yield)\b/ },
|
||||
{ type: "boolean", pattern: /\b(?:true|false|null|undefined)\b/ },
|
||||
{ type: "number", pattern: /\b(?:0[xX][\da-fA-F_]+|0[bB][01_]+|0[oO][0-7_]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?)n?\b/ },
|
||||
{ type: "type", pattern: /\b[A-Z][A-Za-z0-9_]*\b/ },
|
||||
{ type: "function", pattern: /\b[a-zA-Z_$][\w$]*(?=\s*\()/ },
|
||||
{ type: null, pattern: /\b[a-zA-Z_$][\w$]*\b/ },
|
||||
{ type: "operator", pattern: /=>|\.\.\.|[!=<>]==?|&&|\|\||\?\?|\+\+|--|\*\*|[+\-*/%&|^!~?]=?|=/ },
|
||||
{ type: "punctuation", pattern: /[{}\[\](),.;:@]/ },
|
||||
],
|
||||
bash: [
|
||||
{ type: "comment", pattern: /#[^\n]*/ },
|
||||
{ type: "string", pattern: /"(?:[^"\\]|\\[\s\S])*"|'[^']*'/ },
|
||||
{ type: "variable", pattern: /\$\{[^}]+\}|\$[A-Za-z_]\w*|\$[0-9*@#?!$-]/ },
|
||||
{ type: "keyword", pattern: /\b(?:if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|return|exit|break|continue|export|local|readonly|set|unset|declare)\b/ },
|
||||
{ type: "function", pattern: /(?<=^|[\s|;&(])(?:pnpm|npm|yarn|bun|node|git|cd|ls|mkdir|rm|cp|mv|cat|echo|grep|sed|awk|find|curl|wget|tar|zip|unzip)\b/ },
|
||||
{ type: "number", pattern: /\b\d+\b/ },
|
||||
{ type: null, pattern: /\b[a-zA-Z_][\w-]*\b/ },
|
||||
{ type: "operator", pattern: /\|\||&&|>>|<<|[|&;<>!=]/ },
|
||||
{ type: "punctuation", pattern: /[{}\[\](),.:]/ },
|
||||
],
|
||||
jsonc: [
|
||||
{ type: "comment", pattern: /\/\/[^\n]*|\/\*[\s\S]*?\*\// },
|
||||
{ type: "property", pattern: /"(?:[^"\\\n]|\\[\s\S])*"(?=\s*:)/ },
|
||||
{ type: "string", pattern: /"(?:[^"\\\n]|\\[\s\S])*"/ },
|
||||
{ type: "boolean", pattern: /\b(?:true|false|null)\b/ },
|
||||
{ type: "number", pattern: /-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/ },
|
||||
{ type: "punctuation", pattern: /[{}\[\]:,]/ },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function highlightCode(code, langInput) {
|
||||
const aliases = highlightLangAliases();
|
||||
const lang = aliases[langInput] || null;
|
||||
const rules = lang ? highlightRules()[lang] : null;
|
||||
if (!rules) return escapeHtml(code);
|
||||
let out = "";
|
||||
let pos = 0;
|
||||
while (pos < code.length) {
|
||||
let matched = false;
|
||||
for (const { type, pattern } of rules) {
|
||||
const re = new RegExp(pattern.source, "y");
|
||||
re.lastIndex = pos;
|
||||
const m = re.exec(code);
|
||||
if (m && m.index === pos && m[0].length > 0) {
|
||||
const text = escapeHtml(m[0]);
|
||||
out += type ? `<span class="token ${type}">${text}</span>` : text;
|
||||
pos += m[0].length;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
out += escapeHtml(code[pos]);
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
@ -89,6 +89,8 @@ main{min-width:0;padding:32px clamp(20px,4.5vw,56px) 80px;max-width:1180px;margi
|
||||
.home-cta .btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:10px 16px;font-weight:600;font-size:.92rem;text-decoration:none;transition:background .15s,border-color .15s,color .15s,transform .12s}
|
||||
.home-cta .btn-primary{background:var(--accent);color:#fff;border:1px solid var(--accent)}
|
||||
.home-cta .btn-primary:hover{background:var(--accent-strong);border-color:var(--accent-strong);text-decoration:none}
|
||||
:root[data-theme="dark"] .home-cta .btn-primary{color:#0a0e16}
|
||||
:root[data-theme="dark"] .home-cta .btn-primary:hover{color:#04080f}
|
||||
.home-cta .btn-ghost{padding:10px 16px}
|
||||
.home-install{display:flex;align-items:center;gap:12px;background:var(--code-bg);color:var(--code-fg);border-radius:8px;padding:10px 10px 10px 16px;font:500 .9rem/1.2 "JetBrains Mono","SF Mono",ui-monospace,monospace;max-width:32em;border:1px solid #1f2937}
|
||||
.home-install .prompt{color:#64748b;user-select:none;flex:0 0 auto}
|
||||
@ -123,6 +125,16 @@ body:not(.home) .doc>h1:first-child{display:none}
|
||||
.doc pre::-webkit-scrollbar{height:8px;width:8px}
|
||||
.doc pre::-webkit-scrollbar-thumb{background:#334155;border-radius:8px}
|
||||
.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre}
|
||||
.doc pre code .token.comment{color:#7c8597;font-style:italic}
|
||||
.doc pre code .token.string{color:#a8e0a3}
|
||||
.doc pre code .token.number,.doc pre code .token.boolean{color:#f6c177}
|
||||
.doc pre code .token.keyword{color:#e387cb}
|
||||
.doc pre code .token.type{color:#7dd3fc}
|
||||
.doc pre code .token.function{color:#82caff}
|
||||
.doc pre code .token.property{color:#7dd3fc}
|
||||
.doc pre code .token.variable{color:#fcd28a}
|
||||
.doc pre code .token.operator{color:#e387cb}
|
||||
.doc pre code .token.punctuation{color:#a0a8b6}
|
||||
.doc pre .copy{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.06);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:3px 9px;font:500 .7rem/1 "Inter",sans-serif;cursor:pointer;opacity:0;transition:opacity .15s,background .15s,border-color .15s}
|
||||
.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
|
||||
.doc pre .copy:hover{background:rgba(255,255,255,.12)}
|
||||
|
||||
16
scripts/prepack-build.mjs
Normal file
16
scripts/prepack-build.mjs
Normal file
@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, { stdio: "inherit", shell: process.platform === "win32" });
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync("node_modules/typescript/bin/tsc")) {
|
||||
run("pnpm", ["install", "--prod=false", "--ignore-scripts", "--frozen-lockfile=false"]);
|
||||
}
|
||||
|
||||
run("pnpm", ["exec", "tsc", "-p", "tsconfig.json"]);
|
||||
@ -9,6 +9,7 @@ import { resolveSafeRelativePath } from "./path.js";
|
||||
|
||||
export type FileStoreOptions = {
|
||||
rootDir: string;
|
||||
private?: boolean;
|
||||
dirMode?: number;
|
||||
mode?: number;
|
||||
maxBytes?: number;
|
||||
@ -50,8 +51,26 @@ export type FileStore = {
|
||||
open(relativePath: string, options?: RootReadOptions): Promise<OpenResult>;
|
||||
read(relativePath: string, options?: RootReadOptions): Promise<ReadResult>;
|
||||
readBytes(relativePath: string, options?: RootReadOptions): Promise<Buffer>;
|
||||
readText(
|
||||
relativePath: string,
|
||||
options?: RootReadOptions & { encoding?: BufferEncoding },
|
||||
): Promise<string>;
|
||||
readJson<T = unknown>(
|
||||
relativePath: string,
|
||||
options?: RootReadOptions & { encoding?: BufferEncoding },
|
||||
): Promise<T>;
|
||||
remove(relativePath: string): Promise<void>;
|
||||
exists(relativePath: string): Promise<boolean>;
|
||||
writeText(
|
||||
relativePath: string,
|
||||
data: string,
|
||||
options?: FileStoreWriteOptions,
|
||||
): Promise<string>;
|
||||
writeJson(
|
||||
relativePath: string,
|
||||
data: unknown,
|
||||
options?: FileStoreWriteOptions & { trailingNewline?: boolean },
|
||||
): Promise<string>;
|
||||
pruneExpired(options: FileStorePruneOptions): Promise<void>;
|
||||
};
|
||||
|
||||
@ -120,29 +139,35 @@ export function fileStore(options: FileStoreOptions): FileStore {
|
||||
return await root(rootDir, { hardlinks: "reject", maxBytes });
|
||||
}
|
||||
|
||||
async function write(
|
||||
relativePath: string,
|
||||
data: string | Buffer,
|
||||
writeOptions?: FileStoreWriteOptions,
|
||||
): Promise<string> {
|
||||
const destination = resolveStorePath(rootDir, relativePath);
|
||||
const content = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
assertMaxBytes(content.byteLength, writeOptions?.maxBytes ?? maxBytes);
|
||||
await ensureParent(destination, writeOptions?.dirMode ?? dirMode);
|
||||
const result = await writeSiblingTempFile({
|
||||
dir: path.dirname(destination),
|
||||
dirMode: writeOptions?.dirMode ?? dirMode,
|
||||
mode: writeOptions?.mode ?? mode,
|
||||
tempPrefix: writeOptions?.tempPrefix ?? `.${path.basename(destination)}`,
|
||||
writeTemp: async (tempPath) => {
|
||||
await fs.writeFile(tempPath, content);
|
||||
},
|
||||
resolveFinalPath: () => destination,
|
||||
syncTempFile: true,
|
||||
syncParentDir: true,
|
||||
});
|
||||
return result.filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
path: (relativePath) => resolveStorePath(rootDir, relativePath),
|
||||
root: openRoot,
|
||||
write: async (relativePath, data, writeOptions) => {
|
||||
const destination = resolveStorePath(rootDir, relativePath);
|
||||
const content = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
assertMaxBytes(content.byteLength, writeOptions?.maxBytes ?? maxBytes);
|
||||
await ensureParent(destination, writeOptions?.dirMode ?? dirMode);
|
||||
const result = await writeSiblingTempFile({
|
||||
dir: path.dirname(destination),
|
||||
dirMode: writeOptions?.dirMode ?? dirMode,
|
||||
mode: writeOptions?.mode ?? mode,
|
||||
tempPrefix: writeOptions?.tempPrefix ?? `.${path.basename(destination)}`,
|
||||
writeTemp: async (tempPath) => {
|
||||
await fs.writeFile(tempPath, content);
|
||||
},
|
||||
resolveFinalPath: () => destination,
|
||||
syncTempFile: true,
|
||||
syncParentDir: true,
|
||||
});
|
||||
return result.filePath;
|
||||
},
|
||||
write,
|
||||
writeStream: async (relativePath, stream, writeOptions) => {
|
||||
const destination = resolveStorePath(rootDir, relativePath);
|
||||
const limit = writeOptions?.maxBytes ?? maxBytes;
|
||||
@ -192,10 +217,34 @@ export function fileStore(options: FileStoreOptions): FileStore {
|
||||
await (await openRoot()).read(assertRelativePath(relativePath), readOptions),
|
||||
readBytes: async (relativePath, readOptions) =>
|
||||
await (await openRoot()).readBytes(assertRelativePath(relativePath), readOptions),
|
||||
readText: async (relativePath, readOptions) => {
|
||||
const { encoding = "utf8", ...options } = readOptions ?? {};
|
||||
return (await (await openRoot()).read(assertRelativePath(relativePath), options)).buffer
|
||||
.toString(encoding);
|
||||
},
|
||||
readJson: async <T = unknown>(
|
||||
relativePath: string,
|
||||
readOptions?: RootReadOptions & { encoding?: BufferEncoding },
|
||||
) => {
|
||||
const { encoding = "utf8", ...options } = readOptions ?? {};
|
||||
return JSON.parse(
|
||||
(await (await openRoot()).read(assertRelativePath(relativePath), options)).buffer
|
||||
.toString(encoding),
|
||||
) as T;
|
||||
},
|
||||
remove: async (relativePath) => {
|
||||
await (await openRoot()).remove(assertRelativePath(relativePath));
|
||||
},
|
||||
exists: async (relativePath) => await (await openRoot()).exists(assertRelativePath(relativePath)),
|
||||
writeText: async (relativePath, data, writeOptions) => await write(relativePath, data, writeOptions),
|
||||
writeJson: async (relativePath, data, writeOptions) => {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
return await write(
|
||||
relativePath,
|
||||
writeOptions?.trailingNewline === false ? json : `${json}\n`,
|
||||
writeOptions,
|
||||
);
|
||||
},
|
||||
pruneExpired: async (pruneOptions) => {
|
||||
const now = Date.now();
|
||||
const recursive = pruneOptions.recursive ?? false;
|
||||
|
||||
@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { FsSafeError } from "./errors.js";
|
||||
import { fileStore, type FileStore } from "./file-store.js";
|
||||
import { isPathInside } from "./path.js";
|
||||
import { readRegularFileSync } from "./regular-file.js";
|
||||
import { root } from "./root.js";
|
||||
@ -11,13 +12,11 @@ export type PrivateStateStoreOptions = {
|
||||
rootDir: string;
|
||||
};
|
||||
|
||||
export type PrivateStateStore = {
|
||||
rootDir: string;
|
||||
path(relativePath: string): string;
|
||||
export type PrivateStateStore = Omit<FileStore, "readText" | "readJson" | "writeText" | "writeJson"> & {
|
||||
readText(relativePath: string, options?: { maxBytes?: number }): Promise<string | null>;
|
||||
readJson<T = unknown>(relativePath: string, options?: { maxBytes?: number }): Promise<T | null>;
|
||||
writeText(relativePath: string, content: string | Uint8Array): Promise<void>;
|
||||
writeJson(relativePath: string, value: unknown, options?: { trailingNewline?: boolean }): Promise<void>;
|
||||
writeText(relativePath: string, content: string | Uint8Array): Promise<string>;
|
||||
writeJson(relativePath: string, value: unknown, options?: { trailingNewline?: boolean }): Promise<string>;
|
||||
};
|
||||
|
||||
function resolvePrivateStorePath(rootDir: string, relativePath: string): string {
|
||||
@ -237,36 +236,46 @@ export function writePrivateJsonAtomicSync(params: {
|
||||
}
|
||||
|
||||
export function privateStateStore(options: PrivateStateStoreOptions): PrivateStateStore {
|
||||
const root = path.resolve(options.rootDir);
|
||||
const rootDir = path.resolve(options.rootDir);
|
||||
const store = fileStore({ rootDir, private: true });
|
||||
return {
|
||||
rootDir: root,
|
||||
path: (relativePath) => resolvePrivateStorePath(root, relativePath),
|
||||
readText: async (relativePath, options) =>
|
||||
await readPrivateText({
|
||||
rootDir: root,
|
||||
filePath: resolvePrivateStorePath(root, relativePath),
|
||||
...store,
|
||||
rootDir,
|
||||
path: (relativePath) => resolvePrivateStorePath(rootDir, relativePath),
|
||||
readText: async (relativePath, options) => {
|
||||
const safePath = resolvePrivateStorePath(rootDir, relativePath);
|
||||
return await readPrivateText({
|
||||
rootDir,
|
||||
filePath: safePath,
|
||||
maxBytes: options?.maxBytes,
|
||||
}),
|
||||
readJson: async (relativePath, options) =>
|
||||
await readPrivateJson({
|
||||
rootDir: root,
|
||||
filePath: resolvePrivateStorePath(root, relativePath),
|
||||
maxBytes: options?.maxBytes,
|
||||
}),
|
||||
writeText: async (relativePath, content) => {
|
||||
await writePrivateTextAtomic({
|
||||
rootDir: root,
|
||||
filePath: resolvePrivateStorePath(root, relativePath),
|
||||
content,
|
||||
});
|
||||
},
|
||||
readJson: async <T = unknown>(relativePath: string, options?: { maxBytes?: number }) => {
|
||||
const safePath = resolvePrivateStorePath(rootDir, relativePath);
|
||||
return await readPrivateJson<T>({
|
||||
rootDir,
|
||||
filePath: safePath,
|
||||
maxBytes: options?.maxBytes,
|
||||
});
|
||||
},
|
||||
writeText: async (relativePath, content) => {
|
||||
const safePath = resolvePrivateStorePath(rootDir, relativePath);
|
||||
await writePrivateTextAtomic({
|
||||
rootDir,
|
||||
filePath: safePath,
|
||||
content,
|
||||
});
|
||||
return safePath;
|
||||
},
|
||||
writeJson: async (relativePath, value, options) => {
|
||||
const safePath = resolvePrivateStorePath(rootDir, relativePath);
|
||||
await writePrivateJsonAtomic({
|
||||
rootDir: root,
|
||||
filePath: resolvePrivateStorePath(root, relativePath),
|
||||
rootDir,
|
||||
filePath: safePath,
|
||||
value,
|
||||
trailingNewline: options?.trailingNewline,
|
||||
});
|
||||
return safePath;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -741,12 +741,17 @@ describe("file stores and private stores", () => {
|
||||
const sourceRoot = await tempRoot("fs-safe-store-source-");
|
||||
const source = path.join(sourceRoot, "source.txt");
|
||||
await fs.writeFile(source, "copy", "utf8");
|
||||
const store = fileStore({ rootDir: root, maxBytes: 16 });
|
||||
const store = fileStore({ rootDir: root, maxBytes: 64 });
|
||||
|
||||
expect(store.path("a/b.txt")).toBe(path.join(root, "a", "b.txt"));
|
||||
await expect(store.write("a/b.txt", "data")).resolves.toBe(path.join(root, "a", "b.txt"));
|
||||
await expect(store.readBytes("a/b.txt")).resolves.toEqual(Buffer.from("data"));
|
||||
await expect(store.write("too-large.txt", Buffer.alloc(17))).rejects.toMatchObject({
|
||||
await expect(store.readText("a/b.txt")).resolves.toBe("data");
|
||||
await expect(store.writeJson("state.json", { ok: true })).resolves.toBe(
|
||||
path.join(root, "state.json"),
|
||||
);
|
||||
await expect(store.readJson("state.json")).resolves.toEqual({ ok: true });
|
||||
await expect(store.write("too-large.txt", Buffer.alloc(65))).rejects.toMatchObject({
|
||||
code: "too-large",
|
||||
});
|
||||
await store.writeStream("stream.txt", Readable.from(["hello"]));
|
||||
@ -779,9 +784,13 @@ describe("file stores and private stores", () => {
|
||||
await expect(store.readText("nested/value.txt")).resolves.toBe("secret");
|
||||
await store.writeJson("nested/value.json", { ok: true }, { trailingNewline: true });
|
||||
await expect(store.readJson("nested/value.json")).resolves.toEqual({ ok: true });
|
||||
await expect(store.exists("nested/value.json")).resolves.toBe(true);
|
||||
await expect(store.readBytes("nested/value.txt")).resolves.toEqual(Buffer.from("secret"));
|
||||
expect(store.path("nested/value.txt")).toBe(path.join(root, "nested", "value.txt"));
|
||||
expect(() => store.path("../escape.txt")).toThrow("stay under");
|
||||
await expect(store.readText("missing.txt")).resolves.toBeNull();
|
||||
await store.remove("nested/value.json");
|
||||
await expect(store.exists("nested/value.json")).resolves.toBe(false);
|
||||
|
||||
const syncText = path.join(root, "sync", "value.txt");
|
||||
writePrivateTextAtomicSync({ rootDir: root, filePath: syncText, content: "sync" });
|
||||
|
||||
@ -127,6 +127,9 @@ describe("file store", () => {
|
||||
const store = fileStore({ rootDir: root, maxBytes: 1024 });
|
||||
await store.write("media/a.txt", "hello");
|
||||
await expect(store.readBytes("media/a.txt")).resolves.toEqual(Buffer.from("hello"));
|
||||
await expect(store.readText("media/a.txt")).resolves.toBe("hello");
|
||||
await store.writeJson("media/state.json", { ok: true });
|
||||
await expect(store.readJson("media/state.json")).resolves.toEqual({ ok: true });
|
||||
|
||||
const source = path.join(root, "source.bin");
|
||||
await fs.writeFile(source, "source", "utf8");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user