fix: harden filesystem write fallbacks
This commit is contained in:
parent
8383b8b707
commit
c73a672d37
@ -29,7 +29,7 @@ await extractArchive({
|
||||
```ts
|
||||
type ExtractArchiveParams = {
|
||||
archivePath: string; // absolute path to the archive
|
||||
destDir: string; // absolute path; created if missing
|
||||
destDir: string; // absolute destination directory; must already exist
|
||||
timeoutMs: number; // wall-clock cap; throws on overrun
|
||||
kind?: ArchiveKind; // "zip" | "tar"; inferred from filename when omitted
|
||||
stripComponents?: number; // strip N leading dirs from entry paths
|
||||
@ -96,9 +96,9 @@ The archive subpath also exports the helpers `extractArchive` is built on. Most
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `withStagedArchiveDestination(opts)` | Creates a private staging dir, calls your `run(stagingDir)`, merges into the destination. |
|
||||
| `withStagedArchiveDestination(opts)` | Creates a private staging dir outside the destination, calls your `run(stagingDir)`, then cleans it up. |
|
||||
| `mergeExtractedTreeIntoDestination(opts)` | The merge step alone — staged tree → destination through boundary checks. |
|
||||
| `prepareArchiveDestinationDir(destDir)` | Canonicalizes and asserts the destination, creates if missing. |
|
||||
| `prepareArchiveDestinationDir(destDir)` | Canonicalizes and asserts the destination directory. |
|
||||
| `prepareArchiveOutputPath(opts)` | Resolves a single entry's output path against the staging dir. |
|
||||
| `loadZipArchiveWithPreflight(opts)` | Loads a JSZip with size/entry-count preflight before unzipping. |
|
||||
| `readZipCentralDirectoryEntryCount(path)` | Returns the entry count from a ZIP's central directory without reading any payloads. |
|
||||
@ -164,26 +164,25 @@ await extractArchive({ archivePath, destDir, kind, timeoutMs: 10_000 });
|
||||
### Stage to private dir, then commit as a directory
|
||||
|
||||
```ts
|
||||
import { withPrivateTempWorkspace } from "@openclaw/fs-safe/temp";
|
||||
import { replaceDirectoryStaged } from "@openclaw/fs-safe/atomic";
|
||||
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
|
||||
import { replaceDirectoryAtomic } from "@openclaw/fs-safe/atomic";
|
||||
|
||||
await withPrivateTempWorkspace({ prefix: "extract-" }, async (ws) => {
|
||||
await withTempWorkspace({ rootDir: "/srv/site/tmp", prefix: "extract-" }, async (ws) => {
|
||||
await extractArchive({
|
||||
archivePath: upload.path,
|
||||
destDir: ws.dir,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
await replaceDirectoryStaged({
|
||||
sourceDir: ws.dir,
|
||||
await replaceDirectoryAtomic({
|
||||
stagedDir: ws.dir,
|
||||
targetDir: "/srv/site/plugin",
|
||||
backupDir: "/srv/site/plugin.prev",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [Atomic writes](atomic.md) — `replaceDirectoryStaged` for swapping directory trees.
|
||||
- [Atomic writes](atomic.md) — `replaceDirectoryAtomic` for staged directory replacement.
|
||||
- [Temp workspaces](temp.md) — extract into a private workspace and commit as one step.
|
||||
- [Errors](errors.md) — `FsSafeError` codes the underlying writes can raise.
|
||||
- [`extractArchive` source](https://github.com/openclaw/fs-safe/blob/main/src/archive.ts).
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
import {
|
||||
replaceFileAtomic,
|
||||
replaceFileAtomicSync,
|
||||
replaceDirectoryStaged,
|
||||
replaceDirectoryAtomic,
|
||||
movePathWithCopyFallback,
|
||||
} from "@openclaw/fs-safe/atomic";
|
||||
```
|
||||
@ -21,7 +21,7 @@ import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
|
||||
await replaceFileAtomic({
|
||||
filePath: "/srv/workspace/state.json",
|
||||
content: JSON.stringify(state, null, 2),
|
||||
fileMode: 0o600,
|
||||
mode: 0o600,
|
||||
syncTempFile: true,
|
||||
syncParentDir: true,
|
||||
});
|
||||
@ -32,13 +32,17 @@ await replaceFileAtomic({
|
||||
```ts
|
||||
type ReplaceFileAtomicOptions = {
|
||||
filePath: string; // destination
|
||||
content: string | Buffer;
|
||||
encoding?: BufferEncoding; // applied when content is a string; default utf8
|
||||
fileMode?: number; // explicit mode for the new file (e.g. 0o600)
|
||||
preserveMode?: boolean; // copy mode from existing destination, when present
|
||||
content: string | Uint8Array;
|
||||
dirMode?: number; // mode for parent dirs created by the helper
|
||||
mode?: number; // explicit mode for the new file (e.g. 0o600)
|
||||
preserveExistingMode?: boolean; // copy mode from existing destination, when present
|
||||
tempPrefix?: string;
|
||||
renameMaxRetries?: number;
|
||||
renameRetryBaseDelayMs?: number;
|
||||
copyFallbackOnPermissionError?: boolean;
|
||||
syncTempFile?: boolean; // fsync(temp) before rename
|
||||
syncParentDir?: boolean; // fsync(parent) after rename (POSIX only)
|
||||
beforeRename?: (tempPath: string) => Promise<void> | void;
|
||||
beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
|
||||
fileSystem?: ReplaceFileAtomicFileSystem; // injectable fs for tests
|
||||
};
|
||||
```
|
||||
@ -51,7 +55,7 @@ Runs after the temp file is fully written and before the rename. Use it to take
|
||||
await replaceFileAtomic({
|
||||
filePath: "/srv/workspace/config.toml",
|
||||
content: rendered,
|
||||
beforeRename: async (tempPath) => {
|
||||
beforeRename: async ({ filePath }) => {
|
||||
await fs.copyFile(filePath, `${filePath}.bak`); // snapshot existing
|
||||
},
|
||||
});
|
||||
@ -61,33 +65,28 @@ If `beforeRename` throws, the rename is skipped and the temp file is removed —
|
||||
|
||||
### `EPERM` and copy fallback
|
||||
|
||||
On systems where `rename` across mount boundaries (or under restrictive permissions) fails with `EPERM`, the helper falls back to a copy + unlink + close sequence that preserves atomicity at the destination. You don't have to do anything to opt in.
|
||||
On systems where `rename` fails with `EPERM`/`EEXIST`, pass `copyFallbackOnPermissionError: true` to fall back to copy + unlink. The fallback refuses symlink destinations before copying so it does not write through a replaced destination link.
|
||||
|
||||
### Sync variant
|
||||
|
||||
`replaceFileAtomicSync` accepts the same options shape, with the obvious removal of the async-only hooks. Use it inside synchronous boot paths or test setup code.
|
||||
|
||||
## `replaceDirectoryStaged`
|
||||
## `replaceDirectoryAtomic`
|
||||
|
||||
Atomically swap one directory's *contents* with another, with the previous contents preserved at a backup path on success.
|
||||
Atomically swap one directory's contents with another, using a temporary backup during the swap.
|
||||
|
||||
```ts
|
||||
import { replaceDirectoryStaged } from "@openclaw/fs-safe/atomic";
|
||||
import { replaceDirectoryAtomic } from "@openclaw/fs-safe/atomic";
|
||||
|
||||
await replaceDirectoryStaged({
|
||||
sourceDir: "/srv/workspace/staging/snapshot-2026-05-05",
|
||||
await replaceDirectoryAtomic({
|
||||
stagedDir: "/srv/workspace/staging/snapshot-2026-05-05",
|
||||
targetDir: "/srv/workspace/snapshot",
|
||||
backupDir: "/srv/workspace/snapshot.prev",
|
||||
});
|
||||
```
|
||||
|
||||
The helper renames `targetDir → backupDir`, then `sourceDir → targetDir`. On failure mid-swap it tries to restore the backup. The end state is one of:
|
||||
The helper renames `targetDir` to a generated backup path, renames `stagedDir → targetDir`, then removes the backup. If the second rename fails, it tries to restore the original target before rethrowing.
|
||||
|
||||
- `targetDir` holds the new tree, `backupDir` holds the old tree (success).
|
||||
- `targetDir` holds the old tree, `backupDir` is gone (rename failed before the second step).
|
||||
- Either both exist (after a failed restore) or `targetDir` is missing (rare, hard-failure case) — both are surfaced as `FsSafeError`.
|
||||
|
||||
Use it when callers must see *the whole new tree or none of it*. For single-file replacement, `replaceFileAtomic` is the right tool.
|
||||
Use it when callers must see a whole staged tree at the target path. For single-file replacement, `replaceFileAtomic` is the right tool.
|
||||
|
||||
## `movePathWithCopyFallback`
|
||||
|
||||
@ -126,9 +125,11 @@ await replaceFileAtomic({
|
||||
filePath: "/tmp/x",
|
||||
content: "hi",
|
||||
fileSystem: {
|
||||
...realFs,
|
||||
writeFile: async (...args) => { ops.push("write"); return realFs.writeFile(...args); },
|
||||
rename: async (...args) => { ops.push("rename"); return realFs.rename(...args); },
|
||||
promises: {
|
||||
...realFs,
|
||||
writeFile: async (...args) => { ops.push("write"); return realFs.writeFile(...args); },
|
||||
rename: async (...args) => { ops.push("rename"); return realFs.rename(...args); },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
@ -136,6 +137,6 @@ await replaceFileAtomic({
|
||||
## See also
|
||||
|
||||
- [`root()`](root.md) — when you want method-style writes with the boundary baked in.
|
||||
- [JSON files](json.md) — `writeJsonAtomic` is `replaceFileAtomic` with `JSON.stringify`.
|
||||
- [JSON files](json.md) — JSON/text helpers built on sibling-temp replacement.
|
||||
- [Temp workspaces](temp.md) — for staging-then-swap directory builds.
|
||||
- [Errors](errors.md) — code union for failures.
|
||||
|
||||
@ -50,7 +50,7 @@ type FsSafeErrorCode =
|
||||
| `helper-failed` | Internal POSIX helper (Python-based fd-relative ops, sidecar lock acquire) failed. | Inspect `cause` for the underlying error. |
|
||||
| `helper-unavailable` | Helper could not be spawned at all. | Python missing in PATH; restricted sandbox. Library falls back to Node-only path where possible. |
|
||||
| `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows. |
|
||||
| `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryStaged` or remove children first. |
|
||||
| `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
|
||||
| `not-file` | Read or copy targeted a non-regular file. | Target was a directory, FIFO, socket, device. |
|
||||
| `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
|
||||
| `not-removable` | `remove()` couldn't `unlink`/`rmdir` for a reason other than non-empty. | Permissions, device busy, immutable bit. |
|
||||
@ -120,7 +120,7 @@ A common pattern is to wrap your domain code in a single try/catch that maps bot
|
||||
|
||||
A handful of helpers throw their own typed errors instead of `FsSafeError`:
|
||||
|
||||
- `JsonFileReadError` — thrown by [`readJsonFileStrict`](json.md). Carries `cause` so you can distinguish missing (`ENOENT`) from invalid (`SyntaxError`).
|
||||
- `JsonFileReadError` — thrown by [`readJson`](json.md). Carries `cause` so you can distinguish missing (`ENOENT`) from invalid (`SyntaxError`).
|
||||
- `ArchiveLimitError` — thrown by [`extractArchive`](archive.md) when an archive size, entry count, or extracted-byte budget is exceeded. The `code` field uses `ARCHIVE_LIMIT_ERROR_CODE` constants (e.g. `"ARCHIVE_SIZE_EXCEEDS_LIMIT"`).
|
||||
- `ArchiveSecurityError` — thrown by extraction when an entry path violates safety rules (traversal, drive prefix, blocked link type). The `code` field uses `ArchiveSecurityErrorCode` values.
|
||||
|
||||
|
||||
@ -49,8 +49,8 @@ await fs.remove("notes/archive/today.txt");
|
||||
| [`root()`](root.md) | One boundary for read/write/move/remove inside a trusted directory. |
|
||||
| [`pathScope()`](path-scope.md) | Same boundary semantics over an absolute path you already trust. |
|
||||
| [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
|
||||
| [`writeJsonAtomic` / `readJsonFile*`](json.md) | JSON state files with strict and lenient read variants. |
|
||||
| [`createPrivateTempWorkspace`](temp.md) | 0700 scratch dir with auto-cleanup. |
|
||||
| [`writeJson` / `readJson*`](json.md) | JSON state files with strict and lenient read variants. |
|
||||
| [`tempWorkspace`](temp.md) | 0700 scratch dir with auto-cleanup. |
|
||||
| [`extractArchive`](archive.md) | ZIP/TAR extraction with size, count, link, and traversal limits. |
|
||||
| [Secret files](secret-file.md) | Mode-0600 credentials with size and TOCTOU defense. |
|
||||
| [`createSidecarLockManager`](sidecar-lock.md) | Cross-process file lock with retry and stale-lock recovery. |
|
||||
|
||||
@ -37,7 +37,7 @@ Types ship with the package — no `@types/openclaw__fs-safe` needed. The `expor
|
||||
|
||||
```ts
|
||||
import { root, FsSafeError } from "@openclaw/fs-safe";
|
||||
import { writeJsonAtomic } from "@openclaw/fs-safe/json";
|
||||
import { writeJson } from "@openclaw/fs-safe/json";
|
||||
import { extractArchive } from "@openclaw/fs-safe/archive";
|
||||
```
|
||||
|
||||
@ -65,10 +65,10 @@ Use the main entry for the common surface, or the focused subpaths when you want
|
||||
| `@openclaw/fs-safe` | The full surface. Re-exports everything below. |
|
||||
| `@openclaw/fs-safe/root` | `root()`, `Root`, `RootDefaults`, related types. |
|
||||
| `@openclaw/fs-safe/path` | `isPathInside`, `safeRealpathSync`, `isWithinDir`, error helpers. |
|
||||
| `@openclaw/fs-safe/json` | `readJsonFile`, `readJsonFileStrict`, `writeJsonAtomic`, `writeTextAtomic`. |
|
||||
| `@openclaw/fs-safe/json` | `tryReadJson`, `readJson`, `readJsonIfExists`, `writeJson`, `writeText`. |
|
||||
| `@openclaw/fs-safe/regular-file` | `readRegularFile`, `appendRegularFile`, regular-file stat helpers. |
|
||||
| `@openclaw/fs-safe/atomic` | `replaceFileAtomic`, `replaceDirectoryStaged`, `movePathWithCopyFallback`. |
|
||||
| `@openclaw/fs-safe/temp` | `createPrivateTempWorkspace`, `createTempFileTarget`, `writeSiblingTempFile`. |
|
||||
| `@openclaw/fs-safe/atomic` | `replaceFileAtomic`, `replaceDirectoryAtomic`, `movePathWithCopyFallback`. |
|
||||
| `@openclaw/fs-safe/temp` | `tempWorkspace`, `withTempWorkspace`, `tempFile`, `writeSiblingTempFile`. |
|
||||
| `@openclaw/fs-safe/archive` | `extractArchive`, `resolveArchiveKind`, limits, preflight helpers. |
|
||||
| `@openclaw/fs-safe/fs` | `pathExists`, `pathExistsSync`. |
|
||||
| `@openclaw/fs-safe/timing` | `withTimeout`. |
|
||||
|
||||
97
docs/json.md
97
docs/json.md
@ -4,13 +4,14 @@
|
||||
|
||||
```ts
|
||||
import {
|
||||
readJsonFile,
|
||||
readJsonFileStrict,
|
||||
writeJsonAtomic,
|
||||
writeTextAtomic,
|
||||
loadJsonFile,
|
||||
saveJsonFile,
|
||||
readDurableJsonFile,
|
||||
tryReadJson,
|
||||
readJson,
|
||||
readJsonIfExists,
|
||||
readJsonSync,
|
||||
tryReadJsonSync,
|
||||
writeJson,
|
||||
writeText,
|
||||
writeJsonSync,
|
||||
createAsyncLock,
|
||||
JsonFileReadError,
|
||||
} from "@openclaw/fs-safe/json";
|
||||
@ -21,17 +22,17 @@ import {
|
||||
Two read helpers, same input, different failure shape:
|
||||
|
||||
```ts
|
||||
await readJsonFile<T>("./config.json"); // returns null on missing or invalid
|
||||
await readJsonFileStrict<T>("./manifest.json"); // throws JsonFileReadError on missing or invalid
|
||||
await tryReadJson<T>("./config.json"); // returns null on missing or invalid
|
||||
await readJson<T>("./manifest.json"); // throws JsonFileReadError on missing or invalid
|
||||
```
|
||||
|
||||
Use `readJsonFile` when "absent or unreadable" is a normal outcome (first run, optional caches). Use `readJsonFileStrict` when missing or malformed JSON is a programmer error you want to surface immediately.
|
||||
Use `tryReadJson` when "absent or unreadable" is a normal outcome (first run, optional caches). Use `readJson` when missing or malformed JSON is a programmer error you want to surface immediately.
|
||||
|
||||
`JsonFileReadError` carries `cause` so you can inspect whether it was an `ENOENT`, a `SyntaxError`, or something else.
|
||||
|
||||
## Reading
|
||||
|
||||
### `readJsonFile<T>(filePath)`
|
||||
### `tryReadJson<T>(filePath)`
|
||||
|
||||
Async. Returns `Promise<T | null>`:
|
||||
|
||||
@ -40,64 +41,59 @@ Async. Returns `Promise<T | null>`:
|
||||
- file present and valid → parsed value cast to `T`
|
||||
|
||||
```ts
|
||||
const cache = (await readJsonFile<Cache>("./cache.json")) ?? defaultCache();
|
||||
const cache = (await tryReadJson<Cache>("./cache.json")) ?? defaultCache();
|
||||
```
|
||||
|
||||
### `readJsonFileStrict<T>(filePath)`
|
||||
### `readJson<T>(filePath)`
|
||||
|
||||
Async. Returns `Promise<T>`. Throws `JsonFileReadError` on missing-or-invalid. The cast is unchecked — validate the shape with your own schema (zod, valibot, …) if it came from an untrusted source.
|
||||
|
||||
### `readJsonFileSync(filePath)`
|
||||
### `readJsonSync(filePath)`
|
||||
|
||||
Synchronous variant. Returns `unknown` (parse with caution).
|
||||
Synchronous strict-ish variant. Returns `unknown` for valid JSON and `null` for missing or invalid input.
|
||||
|
||||
### `readDurableJsonFile<T>(filePath)`
|
||||
### `tryReadJsonSync<T>(pathname)`
|
||||
|
||||
Async. Returns `Promise<T | null>`. Behaves like `readJsonFile` but tolerates a brief window where the file is being atomically replaced — if the read returns `null` because the file is momentarily missing during a `rename`, it retries once before giving up.
|
||||
Synchronous lenient variant. Returns `T | null`; missing or invalid input returns `null`.
|
||||
|
||||
Use this when many readers concurrently read a file that one writer atomically rewrites.
|
||||
### `readJsonIfExists<T>(filePath)`
|
||||
|
||||
### `loadJsonFile<T>(pathname)`
|
||||
|
||||
Synchronous. Returns `T | undefined`. The "load with no fuss" sibling of `readJsonFile`. Same lenient semantics; missing or invalid → `undefined`.
|
||||
Async. Returns `Promise<T | null>`. Missing files return `null`; invalid JSON throws `JsonFileReadError`.
|
||||
|
||||
## Writing
|
||||
|
||||
### `writeJsonAtomic(filePath, value, options?)`
|
||||
### `writeJson(filePath, value, options?)`
|
||||
|
||||
Async. `JSON.stringify(value, replacer, space)` + [`replaceFileAtomic`](atomic.md#replacefileatomic-replacefileatomicsync) under the hood.
|
||||
Async. `JSON.stringify(value, null, 2)` + sibling-temp rename under the hood.
|
||||
|
||||
```ts
|
||||
await writeJsonAtomic("./state.json", state, { space: 2 });
|
||||
await writeJson("./state.json", state, { trailingNewline: true });
|
||||
```
|
||||
|
||||
Options pass through to `replaceFileAtomic`:
|
||||
Options:
|
||||
|
||||
```ts
|
||||
type WriteJsonAtomicOptions = {
|
||||
fileMode?: number;
|
||||
syncTempFile?: boolean;
|
||||
syncParentDir?: boolean;
|
||||
replacer?: Parameters<typeof JSON.stringify>[1];
|
||||
space?: Parameters<typeof JSON.stringify>[2];
|
||||
trailingNewline?: boolean; // default true
|
||||
type WriteJsonOptions = {
|
||||
mode?: number;
|
||||
ensureDirMode?: number;
|
||||
trailingNewline?: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
### `writeTextAtomic(filePath, content, options?)`
|
||||
### `writeText(filePath, content, options?)`
|
||||
|
||||
Async. Atomic text write. Same options as `writeJsonAtomic` (minus `replacer`/`space`/`trailingNewline`).
|
||||
Async. Atomic text write. Same options as `writeJson`, with `appendTrailingNewline` instead of `trailingNewline`.
|
||||
|
||||
```ts
|
||||
await writeTextAtomic("./README.md", rendered);
|
||||
await writeText("./README.md", rendered);
|
||||
```
|
||||
|
||||
### `saveJsonFile(pathname, data)`
|
||||
### `writeJsonSync(pathname, data)`
|
||||
|
||||
Synchronous, lenient. Convenience wrapper that calls the sync atomic write with sensible defaults.
|
||||
Synchronous convenience wrapper that writes formatted JSON with mode `0o600`. Existing symlink leaves are replaced, not followed.
|
||||
|
||||
```ts
|
||||
saveJsonFile("./prefs.json", { theme: "dark" });
|
||||
writeJsonSync("./prefs.json", { theme: "dark" });
|
||||
```
|
||||
|
||||
## Concurrency: `createAsyncLock()`
|
||||
@ -111,9 +107,9 @@ const lock = createAsyncLock();
|
||||
|
||||
async function bumpCounter() {
|
||||
return lock(async () => {
|
||||
const state = (await readJsonFile<{ count: number }>("./counter.json")) ?? { count: 0 };
|
||||
const state = (await tryReadJson<{ count: number }>("./counter.json")) ?? { count: 0 };
|
||||
state.count += 1;
|
||||
await writeJsonAtomic("./counter.json", state);
|
||||
await writeJson("./counter.json", state);
|
||||
return state.count;
|
||||
});
|
||||
}
|
||||
@ -126,9 +122,9 @@ The lock is *in-process only* — it does nothing for cross-process coordination
|
||||
### Read-modify-write
|
||||
|
||||
```ts
|
||||
const state = (await readJsonFile<State>("./state.json")) ?? initialState();
|
||||
const state = (await tryReadJson<State>("./state.json")) ?? initialState();
|
||||
state.lastRun = Date.now();
|
||||
await writeJsonAtomic("./state.json", state, { space: 2, fileMode: 0o600 });
|
||||
await writeJson("./state.json", state, { mode: 0o600 });
|
||||
```
|
||||
|
||||
### Atomic with secure mode
|
||||
@ -136,11 +132,7 @@ await writeJsonAtomic("./state.json", state, { space: 2, fileMode: 0o600 });
|
||||
For credentials or other sensitive JSON, write at mode `0o600`:
|
||||
|
||||
```ts
|
||||
await writeJsonAtomic("./auth.json", token, {
|
||||
fileMode: 0o600,
|
||||
syncTempFile: true,
|
||||
syncParentDir: true,
|
||||
});
|
||||
await writeJson("./auth.json", token, { mode: 0o600, ensureDirMode: 0o700 });
|
||||
```
|
||||
|
||||
For higher-assurance secrets, prefer the dedicated [secret-file helpers](secret-file.md) — they create the parent directory at `0o700` if missing.
|
||||
@ -150,7 +142,7 @@ For higher-assurance secrets, prefer the dedicated [secret-file helpers](secret-
|
||||
```ts
|
||||
let manifest: Manifest;
|
||||
try {
|
||||
manifest = await readJsonFileStrict<Manifest>("./manifest.json");
|
||||
manifest = await readJson<Manifest>("./manifest.json");
|
||||
} catch (err) {
|
||||
if (err instanceof JsonFileReadError) {
|
||||
console.error("manifest unreadable:", err.cause);
|
||||
@ -163,8 +155,8 @@ try {
|
||||
### Concurrent readers, single writer
|
||||
|
||||
```ts
|
||||
const state = await readDurableJsonFile<State>("./state.json");
|
||||
// during a writer's atomic rename, the unlucky read returns null -> retried once
|
||||
const state = await readJsonIfExists<State>("./state.json");
|
||||
// missing returns null; malformed JSON still throws
|
||||
```
|
||||
|
||||
## Error reference
|
||||
@ -172,13 +164,12 @@ const state = await readDurableJsonFile<State>("./state.json");
|
||||
| Throw / return | When |
|
||||
|---|---|
|
||||
| `null` (lenient reads) | File missing or contents are not valid JSON. |
|
||||
| `JsonFileReadError` | `readJsonFileStrict` saw missing or invalid input. Inspect `cause`. |
|
||||
| `FsSafeError` | Atomic-write helpers can throw the standard codes via `replaceFileAtomic`. |
|
||||
| `JsonFileReadError` | `readJson` or `readJsonIfExists` saw unreadable or invalid input. Inspect `cause`. |
|
||||
| Native `NodeJS.ErrnoException` | Lower-level fs errors not wrapped. |
|
||||
|
||||
## See also
|
||||
|
||||
- [Atomic writes](atomic.md) — `writeJsonAtomic` builds on `replaceFileAtomic`.
|
||||
- [Atomic writes](atomic.md) — lower-level sibling-temp replacement helpers.
|
||||
- [Secret files](secret-file.md) — JSON-or-text writes with mode 0600 in mode 0700 dirs.
|
||||
- [Private file store](private-file-store.md) — root-bounded JSON+text helpers.
|
||||
- [Sidecar lock](sidecar-lock.md) — cross-process coordination.
|
||||
|
||||
@ -54,7 +54,7 @@ await fs.move("notes/today.txt", "notes/archive/today.txt", { overwrite: true })
|
||||
await fs.remove("notes/archive/today.txt");
|
||||
```
|
||||
|
||||
`move()` defaults to no clobber. Pass `{ overwrite: true }` when replacing the target is intentional. `remove()` works on files and empty directories. For non-empty directories, list and remove children first or use [`replaceDirectoryStaged`](atomic.md#replacedirectorystaged).
|
||||
`move()` defaults to no clobber. Pass `{ overwrite: true }` when replacing the target is intentional. `remove()` works on files and empty directories. For non-empty directories, list and remove children first or use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
|
||||
|
||||
## 5. Inspect
|
||||
|
||||
@ -93,7 +93,7 @@ import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
|
||||
await replaceFileAtomic({
|
||||
filePath: "/srv/jobs/incoming/state/config.json",
|
||||
content: JSON.stringify(state, null, 2),
|
||||
fileMode: 0o600,
|
||||
mode: 0o600,
|
||||
syncTempFile: true,
|
||||
syncParentDir: true,
|
||||
});
|
||||
@ -128,9 +128,9 @@ Extraction stages into a private dir and merges through the same boundary used b
|
||||
## 9. Get a private scratch directory
|
||||
|
||||
```ts
|
||||
import { withPrivateTempWorkspace } from "@openclaw/fs-safe/temp";
|
||||
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
|
||||
|
||||
await withPrivateTempWorkspace({ prefix: "build-" }, async (workspace) => {
|
||||
await withTempWorkspace({ rootDir: "/srv/jobs/tmp", prefix: "build-" }, async (workspace) => {
|
||||
await fs.copyIn("input.bin", "/tmp/source.bin");
|
||||
// ...do work in workspace.dir; auto-cleaned on exit
|
||||
});
|
||||
|
||||
@ -58,7 +58,7 @@ type Config = { tokens: string[] };
|
||||
const config = await fs.readJson<Config>("config.json");
|
||||
```
|
||||
|
||||
For tighter control over malformed-or-missing JSON, use the standalone helpers in [`@openclaw/fs-safe/json`](json.md): `readJsonFile` (returns `null` on missing/invalid) vs `readJsonFileStrict` (throws).
|
||||
For tighter control over malformed-or-missing JSON, use the standalone helpers in [`@openclaw/fs-safe/json`](json.md): `tryReadJson` (returns `null` on missing/invalid) vs `readJson` (throws).
|
||||
|
||||
### `fs.open(rel, options?)`
|
||||
|
||||
|
||||
@ -144,7 +144,7 @@ If your call site already trusts the path (it came from your own config, not a c
|
||||
```ts
|
||||
const r = await readRegularFile({ filePath: "/etc/app/config.json", maxBytes: 64 * 1024 });
|
||||
if (r.missing) {
|
||||
await writeJsonAtomic("/etc/app/config.json", defaultConfig);
|
||||
await writeJson("/etc/app/config.json", defaultConfig);
|
||||
} else if (r.regular) {
|
||||
applyConfig(JSON.parse(r.buffer.toString("utf8")));
|
||||
} else {
|
||||
|
||||
@ -74,7 +74,7 @@ fs.mkdir(rel) // mkdir -p (creates missing parents)
|
||||
fs.ensureRoot() // accepts "" / "." as the root itself
|
||||
```
|
||||
|
||||
`write`, `create`, `append`, `writeJson`, and `createJson` accept `fileMode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
|
||||
`write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
|
||||
|
||||
`copyIn` is a one-shot ingest from a trusted absolute source path: it streams the source through the boundary, atomically renames into the root, and respects `maxBytes`.
|
||||
|
||||
|
||||
@ -14,9 +14,9 @@ import {
|
||||
} from "@openclaw/fs-safe";
|
||||
```
|
||||
|
||||
## When to use these vs `writeJsonAtomic`
|
||||
## When to use these vs `writeJson`
|
||||
|
||||
| Use these when | Use `writeJsonAtomic` when |
|
||||
| Use these when | Use `writeJson` when |
|
||||
|---|---|
|
||||
| The file is a credential (token, key, password). | The file is application state. |
|
||||
| You want the parent directory created at `0o700` if missing. | You don't care about the parent directory mode. |
|
||||
@ -160,6 +160,6 @@ await withTimeout(
|
||||
|
||||
## See also
|
||||
|
||||
- [JSON files](json.md) — `writeJsonAtomic` accepts `fileMode: 0o600` for non-secret JSON state.
|
||||
- [JSON files](json.md) — `writeJson` accepts `mode: 0o600` for non-secret JSON state.
|
||||
- [Atomic writes](atomic.md) — the lower-level `replaceFileAtomic` used by these helpers.
|
||||
- [Private file store](private-file-store.md) — root-bounded JSON+text helpers without secret-file mode policy.
|
||||
|
||||
111
docs/temp.md
111
docs/temp.md
@ -5,12 +5,11 @@
|
||||
```ts
|
||||
import {
|
||||
tempWorkspace,
|
||||
createPrivateTempWorkspace,
|
||||
withPrivateTempWorkspace,
|
||||
createPrivateTempWorkspaceSync,
|
||||
withPrivateTempWorkspaceSync,
|
||||
createTempFileTarget,
|
||||
withTempFileTarget,
|
||||
withTempWorkspace,
|
||||
tempWorkspaceSync,
|
||||
withTempWorkspaceSync,
|
||||
tempFile,
|
||||
withTempFile,
|
||||
writeSiblingTempFile,
|
||||
writeViaSiblingTempPath,
|
||||
resolveSecureTempRoot,
|
||||
@ -33,14 +32,14 @@ const inputPath = await workspace.writePrivate("input.txt", "data");
|
||||
await runBuild(workspace.dir, inputPath);
|
||||
```
|
||||
|
||||
### `withPrivateTempWorkspace`
|
||||
### `withTempWorkspace`
|
||||
|
||||
The recommended shape. Auto-cleanup on every exit path:
|
||||
|
||||
```ts
|
||||
import { withPrivateTempWorkspace } from "@openclaw/fs-safe/temp";
|
||||
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
|
||||
|
||||
const result = await withPrivateTempWorkspace({ rootDir: "/tmp/my-app", prefix: "build-" }, async (workspace) => {
|
||||
const result = await withTempWorkspace({ rootDir: "/tmp/my-app", prefix: "build-" }, async (workspace) => {
|
||||
await workspace.writePrivate("input.txt", "data");
|
||||
return await runBuild(workspace.dir);
|
||||
});
|
||||
@ -48,12 +47,12 @@ const result = await withPrivateTempWorkspace({ rootDir: "/tmp/my-app", prefix:
|
||||
|
||||
The callback receives the same workspace shape as `tempWorkspace()`. Cleanup is wired to run after the callback resolves or rejects.
|
||||
|
||||
### `createPrivateTempWorkspace`
|
||||
### Manual lifetime
|
||||
|
||||
Lower-level. You manage the lifetime:
|
||||
|
||||
```ts
|
||||
const workspace = await createPrivateTempWorkspace({ rootDir: "/tmp/my-app", prefix: "scan-" });
|
||||
const workspace = await tempWorkspace({ rootDir: "/tmp/my-app", prefix: "scan-" });
|
||||
try {
|
||||
// …work in workspace.dir…
|
||||
} finally {
|
||||
@ -63,7 +62,7 @@ try {
|
||||
|
||||
### Sync variants
|
||||
|
||||
`createPrivateTempWorkspaceSync` and `withPrivateTempWorkspaceSync` are the synchronous siblings. Useful for setup code in tests or boot paths that have not entered async land yet.
|
||||
`tempWorkspaceSync` and `withTempWorkspaceSync` are the synchronous siblings. Useful for setup code in tests or boot paths that have not entered async land yet.
|
||||
|
||||
### Options
|
||||
|
||||
@ -72,25 +71,25 @@ type PrivateTempWorkspaceOptions = {
|
||||
rootDir: string; // parent directory for workspaces
|
||||
prefix: string; // dir prefix (sanitized)
|
||||
dirMode?: number; // dir mode; default 0o700
|
||||
fileMode?: number; // writePrivate file mode; default 0o600
|
||||
mode?: number; // writePrivate file mode; default 0o600
|
||||
};
|
||||
```
|
||||
|
||||
## Temp file targets
|
||||
|
||||
When you don't need a whole directory — just one temp file path under your control — use the file-target helpers. They produce a path inside a private workspace and clean up either the file (`createTempFileTarget`) or the entire enclosing directory (`withTempFileTarget`).
|
||||
When you don't need a whole directory — just one temp file path under your control — use the file-target helpers. They produce a path inside a private workspace and clean up the enclosing directory.
|
||||
|
||||
### `createTempFileTarget`
|
||||
### `tempFile`
|
||||
|
||||
```ts
|
||||
import { createTempFileTarget } from "@openclaw/fs-safe/temp";
|
||||
import { tempFile } from "@openclaw/fs-safe/temp";
|
||||
|
||||
const target = await createTempFileTarget({ fileName: "report.pdf", prefix: "render-" });
|
||||
const target = await tempFile({ fileName: "report.pdf", prefix: "render-" });
|
||||
try {
|
||||
await render(target.filePath);
|
||||
await fs.copyFile(target.filePath, "/srv/workspace/reports/today.pdf");
|
||||
await render(target.path);
|
||||
await fs.copyFile(target.path, "/srv/workspace/reports/today.pdf");
|
||||
} finally {
|
||||
await target.dispose();
|
||||
await target.cleanup();
|
||||
}
|
||||
```
|
||||
|
||||
@ -98,22 +97,23 @@ Returns:
|
||||
|
||||
```ts
|
||||
type TempFileTarget = {
|
||||
filePath: string; // absolute path; safe to write to
|
||||
dirPath: string; // the enclosing private workspace dir
|
||||
dispose(): Promise<void>; // removes filePath if present, then dirPath
|
||||
path: string; // absolute path; safe to write to
|
||||
dir: string; // the enclosing private workspace dir
|
||||
file(name: string): string;
|
||||
cleanup(): Promise<void>; // removes the private workspace dir
|
||||
};
|
||||
```
|
||||
|
||||
### `withTempFileTarget`
|
||||
### `withTempFile`
|
||||
|
||||
Same shape with auto-cleanup:
|
||||
|
||||
```ts
|
||||
import { withTempFileTarget } from "@openclaw/fs-safe/temp";
|
||||
import { withTempFile } from "@openclaw/fs-safe/temp";
|
||||
|
||||
await withTempFileTarget({ fileName: "out.zip", prefix: "pack-" }, async (t) => {
|
||||
await pack(t.filePath);
|
||||
await uploadAndForget(t.filePath);
|
||||
await withTempFile({ fileName: "out.zip", prefix: "pack-" }, async (filePath) => {
|
||||
await pack(filePath);
|
||||
await uploadAndForget(filePath);
|
||||
});
|
||||
```
|
||||
|
||||
@ -127,17 +127,18 @@ When you want to write to a temp file in **the same directory** as a future dest
|
||||
import { writeSiblingTempFile } from "@openclaw/fs-safe/temp";
|
||||
|
||||
const result = await writeSiblingTempFile<string>({
|
||||
destinationFilePath: "/srv/workspace/state.json",
|
||||
fileMode: 0o600,
|
||||
write: async (tempPath) => {
|
||||
dir: "/srv/workspace",
|
||||
mode: 0o600,
|
||||
writeTemp: async (tempPath) => {
|
||||
await fs.writeFile(tempPath, JSON.stringify(state));
|
||||
return tempPath;
|
||||
return "state.json";
|
||||
},
|
||||
resolveFinalPath: (fileName) => path.join("/srv/workspace", fileName),
|
||||
});
|
||||
// result.tempPath, result.value (returned by write()), result.dispose
|
||||
// result.filePath, result.result (returned by writeTemp)
|
||||
```
|
||||
|
||||
`writeSiblingTempFile` chooses a random sibling name in the destination's parent directory, calls your `write()` callback, and lets you decide what to do with it next. The result includes a `dispose()` to remove the temp file if you didn't rename it into place.
|
||||
`writeSiblingTempFile` chooses a random sibling name in `dir`, calls your `writeTemp()` callback, validates that `resolveFinalPath(result)` is still inside that same directory, and renames the temp file there.
|
||||
|
||||
### `writeViaSiblingTempPath`
|
||||
|
||||
@ -147,9 +148,11 @@ A higher-level convenience — write content + rename in one call:
|
||||
import { writeViaSiblingTempPath } from "@openclaw/fs-safe/temp";
|
||||
|
||||
await writeViaSiblingTempPath({
|
||||
destinationFilePath: "/srv/workspace/state.json",
|
||||
content: JSON.stringify(state),
|
||||
fileMode: 0o600,
|
||||
rootDir: "/srv/workspace",
|
||||
targetPath: "/srv/workspace/state.json",
|
||||
writeTemp: async (tempPath) => {
|
||||
await fs.writeFile(tempPath, JSON.stringify(state));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@ -162,17 +165,17 @@ The `resolveSecureTempRoot()` helper picks a per-user directory under the system
|
||||
```ts
|
||||
import { resolveSecureTempRoot } from "@openclaw/fs-safe/temp";
|
||||
|
||||
const tempRoot = resolveSecureTempRoot({ namespace: "my-app" });
|
||||
// e.g. /tmp/fs-safe-501-my-app-9af7
|
||||
const tempRoot = resolveSecureTempRoot({ fallbackPrefix: "my-app" });
|
||||
// e.g. /tmp/my-app-501
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```ts
|
||||
type ResolveSecureTempRootOptions = {
|
||||
namespace?: string; // appended to the default name
|
||||
parentDir?: string; // override os.tmpdir()
|
||||
mode?: number; // default 0o700
|
||||
fallbackPrefix: string; // base name for the per-user fallback dir
|
||||
preferredDir?: string; // optional preferred secure temp root
|
||||
tmpdir?: () => string; // override os.tmpdir()
|
||||
};
|
||||
```
|
||||
|
||||
@ -183,12 +186,13 @@ The directory name embeds the user's UID (POSIX) or username so multi-user syste
|
||||
### Build something, atomically place it
|
||||
|
||||
```ts
|
||||
await withPrivateTempWorkspace({ prefix: "build-" }, async (ws) => {
|
||||
import { replaceDirectoryAtomic } from "@openclaw/fs-safe/atomic";
|
||||
|
||||
await withTempWorkspace({ rootDir: "/srv/site/tmp", prefix: "build-" }, async (ws) => {
|
||||
await runCompiler({ outDir: ws.dir });
|
||||
await replaceDirectoryStaged({
|
||||
sourceDir: ws.dir,
|
||||
await replaceDirectoryAtomic({
|
||||
stagedDir: ws.dir,
|
||||
targetDir: "/srv/site/public",
|
||||
backupDir: "/srv/site/public.prev",
|
||||
});
|
||||
});
|
||||
```
|
||||
@ -200,28 +204,29 @@ import { writeSiblingTempFile } from "@openclaw/fs-safe/temp";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const r = await writeSiblingTempFile({
|
||||
destinationFilePath: "/srv/cache/blob.bin",
|
||||
write: async (tempPath) => {
|
||||
dir: "/srv/cache",
|
||||
writeTemp: async (tempPath) => {
|
||||
const handle = await fs.open(tempPath, "w");
|
||||
try {
|
||||
await pipeline(downloadStream, handle.createWriteStream());
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return tempPath;
|
||||
return "blob.bin";
|
||||
},
|
||||
resolveFinalPath: (fileName) => path.join("/srv/cache", fileName),
|
||||
});
|
||||
|
||||
await fs.rename(r.tempPath, "/srv/cache/blob.bin");
|
||||
console.log(`downloaded ${r.filePath}`);
|
||||
```
|
||||
|
||||
### Per-call private scratch in a test
|
||||
|
||||
```ts
|
||||
import { withPrivateTempWorkspace } from "@openclaw/fs-safe/temp";
|
||||
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
|
||||
|
||||
it("processes a fixture", async () => {
|
||||
await withPrivateTempWorkspace({ prefix: "test-" }, async (ws) => {
|
||||
await withTempWorkspace({ rootDir: "/tmp/my-tests", prefix: "test-" }, async (ws) => {
|
||||
await fs.writeFile(path.join(ws.dir, "input.txt"), fixture);
|
||||
const out = await processFile(path.join(ws.dir, "input.txt"));
|
||||
expect(out).toEqual(expected);
|
||||
@ -231,6 +236,6 @@ it("processes a fixture", async () => {
|
||||
|
||||
## See also
|
||||
|
||||
- [Atomic writes](atomic.md) — `replaceDirectoryStaged` for whole-directory swaps.
|
||||
- [Atomic writes](atomic.md) — `replaceDirectoryAtomic` for whole-directory swaps.
|
||||
- [`root()`](root.md) — `fs.copyIn(rel, sourceAbs)` for moving files from a temp into a `Root`.
|
||||
- [Sidecar lock](sidecar-lock.md) — when many processes share a temp tree.
|
||||
|
||||
@ -142,10 +142,10 @@ it("writes and reads through the boundary", async () => {
|
||||
});
|
||||
```
|
||||
|
||||
For tests that need a private temp workspace, [`withPrivateTempWorkspace`](temp.md) makes the setup-and-teardown story trivial.
|
||||
For tests that need a private temp workspace, [`withTempWorkspace`](temp.md) makes the setup-and-teardown story trivial.
|
||||
|
||||
## See also
|
||||
|
||||
- [Security model](security-model.md) — what the boundary is supposed to defend; design tests around the same threats.
|
||||
- [`root()`](root.md) — the surface most tests will exercise.
|
||||
- [Temp workspaces](temp.md) — `withPrivateTempWorkspace` for cleanup-on-exit test directories.
|
||||
- [Temp workspaces](temp.md) — `withTempWorkspace` for cleanup-on-exit test directories.
|
||||
|
||||
@ -108,7 +108,7 @@ Both `from` and `to` are bounded; `..` in either is rejected.
|
||||
|
||||
### `fs.remove(rel)`
|
||||
|
||||
Unlink a file or `rmdir` an empty directory. Non-empty directories throw `not-empty`. For atomic directory replacement, use [`replaceDirectoryStaged`](atomic.md#replacedirectorystaged).
|
||||
Unlink a file or `rmdir` an empty directory. Non-empty directories throw `not-empty`. For atomic directory replacement, use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
|
||||
|
||||
```ts
|
||||
await fs.remove("logs/yesterday.log");
|
||||
@ -200,7 +200,7 @@ for (const file of files) await fs.write(`${stagingDir}/${file.name}`, file.body
|
||||
await fs.move(stagingDir, "snapshots/2026-05-05", { overwrite: true });
|
||||
```
|
||||
|
||||
For a true commit-or-rollback over a *directory*, use [`replaceDirectoryStaged`](atomic.md#replacedirectorystaged).
|
||||
For a true commit-or-rollback over a *directory*, use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
|
||||
|
||||
### Rotate logs
|
||||
|
||||
|
||||
@ -2,8 +2,10 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { root } from "./root.js";
|
||||
import { isNotFoundPathError, isPathInside } from "./path.js";
|
||||
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
||||
|
||||
const ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK = "archive entry traverses symlink in destination";
|
||||
const ARCHIVE_STAGING_MODE = 0o700;
|
||||
|
||||
export type ArchiveSecurityErrorCode =
|
||||
| "destination-not-directory"
|
||||
@ -139,10 +141,19 @@ export async function withStagedArchiveDestination<T>(params: {
|
||||
stagingDirPrefix?: string;
|
||||
run: (stagingDir: string) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const stagingRoot = resolveSecureTempRoot({
|
||||
fallbackPrefix: "fs-safe-archive",
|
||||
unsafeFallbackLabel: "archive staging temp dir",
|
||||
warn: () => undefined,
|
||||
});
|
||||
if (isPathInside(params.destinationRealDir, stagingRoot)) {
|
||||
throw new Error(`archive staging root must be outside destination: ${stagingRoot}`);
|
||||
}
|
||||
const stagingDir = await fs.mkdtemp(
|
||||
path.join(params.destinationRealDir, params.stagingDirPrefix ?? ".fs-safe-archive-"),
|
||||
path.join(stagingRoot, params.stagingDirPrefix ?? "fs-safe-archive-"),
|
||||
);
|
||||
try {
|
||||
await fs.chmod(stagingDir, ARCHIVE_STAGING_MODE).catch(() => undefined);
|
||||
return await params.run(stagingDir);
|
||||
} finally {
|
||||
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
|
||||
@ -112,6 +112,16 @@ type ZipEntry = {
|
||||
|
||||
type ZipExtractBudget = ReturnType<typeof createByteBudgetTracker>;
|
||||
|
||||
const ZIP_UNIX_FILE_TYPE_MASK = 0o170000;
|
||||
const ZIP_UNIX_SYMLINK_TYPE = 0o120000;
|
||||
|
||||
function isZipSymlinkEntry(entry: ZipEntry): boolean {
|
||||
return (
|
||||
typeof entry.unixPermissions === "number" &&
|
||||
(entry.unixPermissions & ZIP_UNIX_FILE_TYPE_MASK) === ZIP_UNIX_SYMLINK_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
async function readZipEntryStream(entry: ZipEntry): Promise<NodeJS.ReadableStream> {
|
||||
if (typeof entry.nodeStream === "function") {
|
||||
return entry.nodeStream();
|
||||
@ -255,6 +265,9 @@ async function extractZip(params: {
|
||||
if (entry.dir) {
|
||||
continue;
|
||||
}
|
||||
if (isZipSymlinkEntry(entry)) {
|
||||
throw new Error(`zip entry is a link: ${entry.name}`);
|
||||
}
|
||||
|
||||
await writeZipFileEntry({
|
||||
entry,
|
||||
|
||||
79
src/json.ts
79
src/json.ts
@ -6,16 +6,31 @@ import { readRegularFile, readRegularFileSync } from "./regular-file.js";
|
||||
|
||||
const JSON_FILE_MODE = 0o600;
|
||||
const JSON_DIR_MODE = 0o700;
|
||||
const SUPPORTS_SYNC_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants;
|
||||
|
||||
function getErrorCode(err: unknown): string | undefined {
|
||||
return err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined;
|
||||
}
|
||||
|
||||
function trySetSecureMode(pathname: string) {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fsSync.chmodSync(pathname, JSON_FILE_MODE);
|
||||
fd = fsSync.openSync(
|
||||
pathname,
|
||||
fsSync.constants.O_RDONLY |
|
||||
(SUPPORTS_SYNC_NOFOLLOW ? fsSync.constants.O_NOFOLLOW : 0),
|
||||
);
|
||||
fsSync.fchmodSync(fd, JSON_FILE_MODE);
|
||||
} catch {
|
||||
// best-effort on platforms without chmod support
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
fsSync.closeSync(fd);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,45 +52,6 @@ function trySyncDirectory(pathname: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function readSymlinkTargetPath(linkPath: string): string {
|
||||
const target = fsSync.readlinkSync(linkPath);
|
||||
return path.resolve(path.dirname(linkPath), target);
|
||||
}
|
||||
|
||||
function resolveJsonWriteTarget(pathname: string): { targetPath: string; followsSymlink: boolean } {
|
||||
let currentPath = pathname;
|
||||
const visited = new Set<string>();
|
||||
let followsSymlink = false;
|
||||
|
||||
for (;;) {
|
||||
let stat: fsSync.Stats;
|
||||
try {
|
||||
stat = fsSync.lstatSync(currentPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
return { targetPath: currentPath, followsSymlink };
|
||||
}
|
||||
|
||||
if (!stat.isSymbolicLink()) {
|
||||
return { targetPath: currentPath, followsSymlink };
|
||||
}
|
||||
|
||||
if (visited.has(currentPath)) {
|
||||
const err = new Error(
|
||||
`Too many symlink levels while resolving ${pathname}`,
|
||||
) as NodeJS.ErrnoException;
|
||||
err.code = "ELOOP";
|
||||
throw err;
|
||||
}
|
||||
|
||||
visited.add(currentPath);
|
||||
followsSymlink = true;
|
||||
currentPath = readSymlinkTargetPath(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
function renameJsonFileWithFallback(tmpPath: string, pathname: string) {
|
||||
try {
|
||||
fsSync.renameSync(tmpPath, pathname);
|
||||
@ -83,6 +59,21 @@ function renameJsonFileWithFallback(tmpPath: string, pathname: string) {
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "EPERM" || code === "EEXIST") {
|
||||
const existing = (() => {
|
||||
try {
|
||||
return fsSync.lstatSync(pathname);
|
||||
} catch (lstatError) {
|
||||
if ((lstatError as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw lstatError;
|
||||
}
|
||||
})();
|
||||
if (existing?.isSymbolicLink()) {
|
||||
fsSync.rmSync(pathname, { force: true });
|
||||
fsSync.renameSync(tmpPath, pathname);
|
||||
return;
|
||||
}
|
||||
fsSync.copyFileSync(tmpPath, pathname);
|
||||
fsSync.rmSync(tmpPath, { force: true });
|
||||
return;
|
||||
@ -92,7 +83,7 @@ function renameJsonFileWithFallback(tmpPath: string, pathname: string) {
|
||||
}
|
||||
|
||||
function writeTempJsonFile(pathname: string, payload: string) {
|
||||
const fd = fsSync.openSync(pathname, "w", JSON_FILE_MODE);
|
||||
const fd = fsSync.openSync(pathname, "wx", JSON_FILE_MODE);
|
||||
try {
|
||||
fsSync.writeFileSync(fd, payload, "utf8");
|
||||
fsSync.fsyncSync(fd);
|
||||
@ -111,13 +102,11 @@ export function tryReadJsonSync<T = unknown>(pathname: string): T | null {
|
||||
}
|
||||
|
||||
export function writeJsonSync(pathname: string, data: unknown) {
|
||||
const { targetPath, followsSymlink } = resolveJsonWriteTarget(pathname);
|
||||
const targetPath = pathname;
|
||||
const tmpPath = `${targetPath}.${randomUUID()}.tmp`;
|
||||
const payload = `${JSON.stringify(data, null, 2)}\n`;
|
||||
|
||||
if (!followsSymlink) {
|
||||
fsSync.mkdirSync(path.dirname(targetPath), { recursive: true, mode: JSON_DIR_MODE });
|
||||
}
|
||||
fsSync.mkdirSync(path.dirname(targetPath), { recursive: true, mode: JSON_DIR_MODE });
|
||||
try {
|
||||
writeTempJsonFile(tmpPath, payload);
|
||||
trySetSecureMode(tmpPath);
|
||||
|
||||
@ -16,6 +16,7 @@ export type ReplaceFileAtomicFileSystem = {
|
||||
| "rm"
|
||||
| "open"
|
||||
| "stat"
|
||||
| "lstat"
|
||||
>;
|
||||
};
|
||||
|
||||
@ -32,6 +33,7 @@ export type ReplaceFileAtomicSyncFileSystem = Pick<
|
||||
| "fsyncSync"
|
||||
| "closeSync"
|
||||
| "statSync"
|
||||
| "lstatSync"
|
||||
>;
|
||||
|
||||
type ReplaceFileAtomicBaseOptions = {
|
||||
@ -94,6 +96,15 @@ async function renameWithRetry(params: {
|
||||
continue;
|
||||
}
|
||||
if (params.copyFallbackOnPermissionError && isPermissionRenameError(error)) {
|
||||
const stat = await params.fsModule.lstat(params.dest).catch((lstatError) => {
|
||||
if ((lstatError as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw lstatError;
|
||||
});
|
||||
if (stat?.isSymbolicLink()) {
|
||||
throw new Error(`Refusing copy fallback through symlink destination: ${params.dest}`);
|
||||
}
|
||||
await params.fsModule.copyFile(params.src, params.dest);
|
||||
await params.fsModule.unlink(params.src).catch(() => undefined);
|
||||
return { method: "copy-fallback" };
|
||||
@ -129,6 +140,17 @@ function renameWithRetrySync(params: {
|
||||
continue;
|
||||
}
|
||||
if (params.copyFallbackOnPermissionError && isPermissionRenameError(error)) {
|
||||
let stat: Stats | null = null;
|
||||
try {
|
||||
stat = params.fsModule.lstatSync(params.dest);
|
||||
} catch (lstatError) {
|
||||
if ((lstatError as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw lstatError;
|
||||
}
|
||||
}
|
||||
if (stat?.isSymbolicLink()) {
|
||||
throw new Error(`Refusing copy fallback through symlink destination: ${params.dest}`);
|
||||
}
|
||||
params.fsModule.copyFileSync(params.src, params.dest);
|
||||
try {
|
||||
params.fsModule.unlinkSync(params.src);
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
prepareArchiveOutputPath,
|
||||
withStagedArchiveDestination,
|
||||
} from "../src/archive-staging.js";
|
||||
import { isPathInside } from "../src/path.js";
|
||||
|
||||
const directorySymlinkType = process.platform === "win32" ? "junction" : undefined;
|
||||
const tempDirs = new Set<string>();
|
||||
@ -126,6 +127,7 @@ describe("archive-staging helpers", () => {
|
||||
destinationRealDir,
|
||||
run: async (stagingDir) => {
|
||||
successStage = stagingDir;
|
||||
expect(isPathInside(destinationRealDir, stagingDir)).toBe(false);
|
||||
await fs.writeFile(path.join(stagingDir, "payload.txt"), "ok", "utf8");
|
||||
},
|
||||
});
|
||||
@ -137,6 +139,7 @@ describe("archive-staging helpers", () => {
|
||||
destinationRealDir,
|
||||
run: async (stagingDir) => {
|
||||
failureStage = stagingDir;
|
||||
expect(isPathInside(destinationRealDir, stagingDir)).toBe(false);
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
|
||||
@ -69,6 +69,28 @@ describe("archive extraction", () => {
|
||||
"old-content",
|
||||
);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("rejects zip symlink entries", async () => {
|
||||
const root = await tempRoot("fs-safe-archive-link-");
|
||||
const archivePath = path.join(root, "pkg.zip");
|
||||
const destDir = path.join(root, "dest");
|
||||
const outsidePath = path.join(root, "outside.txt");
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
await fs.writeFile(outsidePath, "outside", "utf8");
|
||||
|
||||
const zip = new JSZip();
|
||||
zip.file("link.txt", outsidePath, { unixPermissions: 0o120777 });
|
||||
await fs.writeFile(
|
||||
archivePath,
|
||||
await zip.generateAsync({ type: "nodebuffer", platform: "UNIX" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
extractArchive({ archivePath, destDir, kind: "zip", timeoutMs: 15_000 }),
|
||||
).rejects.toThrow("zip entry is a link: link.txt");
|
||||
await expect(fs.readdir(destDir)).resolves.toEqual([]);
|
||||
await expect(fs.readFile(outsidePath, "utf8")).resolves.toBe("outside");
|
||||
});
|
||||
});
|
||||
|
||||
describe("temp file targets", () => {
|
||||
|
||||
@ -68,6 +68,72 @@ describe("atomic helpers", () => {
|
||||
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("new");
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"does not copy fallback through destination symlinks",
|
||||
async () => {
|
||||
const root = await tempRoot("fs-safe-atomic-link-");
|
||||
const filePath = path.join(root, "state.txt");
|
||||
const outsidePath = path.join(root, "outside.txt");
|
||||
await fs.writeFile(outsidePath, "outside", "utf8");
|
||||
await fs.symlink(outsidePath, filePath);
|
||||
|
||||
await expect(
|
||||
replaceFileAtomic({
|
||||
filePath,
|
||||
content: "new",
|
||||
copyFallbackOnPermissionError: true,
|
||||
fileSystem: {
|
||||
promises: {
|
||||
...fs,
|
||||
rename: async () => {
|
||||
const error = new Error("rename denied") as NodeJS.ErrnoException;
|
||||
error.code = "EPERM";
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Refusing copy fallback through symlink destination");
|
||||
|
||||
await expect(fs.readFile(outsidePath, "utf8")).resolves.toBe("outside");
|
||||
expect((await fs.lstat(filePath)).isSymbolicLink()).toBe(true);
|
||||
expect((await fs.readdir(root)).filter((entry) => entry.startsWith(".fs-safe-replace")))
|
||||
.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"does not sync-copy fallback through destination symlinks",
|
||||
async () => {
|
||||
const root = await tempRoot("fs-safe-atomic-link-sync-");
|
||||
const filePath = path.join(root, "state.txt");
|
||||
const outsidePath = path.join(root, "outside.txt");
|
||||
await fs.writeFile(outsidePath, "outside", "utf8");
|
||||
await fs.symlink(outsidePath, filePath);
|
||||
|
||||
expect(() =>
|
||||
replaceFileAtomicSync({
|
||||
filePath,
|
||||
content: "new",
|
||||
copyFallbackOnPermissionError: true,
|
||||
fileSystem: {
|
||||
...fsSync,
|
||||
renameSync: () => {
|
||||
const error = new Error("rename denied") as NodeJS.ErrnoException;
|
||||
error.code = "EPERM";
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow("Refusing copy fallback through symlink destination");
|
||||
|
||||
await expect(fs.readFile(outsidePath, "utf8")).resolves.toBe("outside");
|
||||
expect((await fs.lstat(filePath)).isSymbolicLink()).toBe(true);
|
||||
expect((await fs.readdir(root)).filter((entry) => entry.startsWith(".fs-safe-replace")))
|
||||
.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("supports the synchronous replace variant", async () => {
|
||||
const root = await tempRoot("fs-safe-atomic-");
|
||||
const filePath = path.join(root, "sync", "state.txt");
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
tryReadJson,
|
||||
writeJson,
|
||||
writeText,
|
||||
writeJsonSync,
|
||||
} from "../src/json.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@ -100,6 +101,20 @@ describe("json file helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("replaces symlink leaves on sync writes", async () => {
|
||||
const root = await tempRoot("fs-safe-json-link-");
|
||||
const outsidePath = path.join(root, "outside.json");
|
||||
const linkPath = path.join(root, "state.json");
|
||||
await fs.writeFile(outsidePath, "{\"secret\":true}\n", "utf8");
|
||||
await fs.symlink(outsidePath, linkPath);
|
||||
|
||||
writeJsonSync(linkPath, { ok: true });
|
||||
|
||||
await expect(fs.readFile(outsidePath, "utf8")).resolves.toBe("{\"secret\":true}\n");
|
||||
await expect(fs.readFile(linkPath, "utf8")).resolves.toBe("{\n \"ok\": true\n}\n");
|
||||
expect((await fs.lstat(linkPath)).isSymbolicLink()).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes work through createAsyncLock", async () => {
|
||||
const lock = createAsyncLock();
|
||||
const events: string[] = [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user