fix: harden filesystem write fallbacks

This commit is contained in:
Peter Steinberger 2026-05-05 19:18:55 +01:00
parent 8383b8b707
commit c73a672d37
No known key found for this signature in database
22 changed files with 346 additions and 209 deletions

View File

@ -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).

View File

@ -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.

View File

@ -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.

View File

@ -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. |

View File

@ -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`. |

View File

@ -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.

View File

@ -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
});

View File

@ -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?)`

View File

@ -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 {

View File

@ -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`.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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);

View File

@ -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,

View File

@ -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);

View File

@ -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);

View File

@ -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");
},
}),

View File

@ -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", () => {

View File

@ -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");

View File

@ -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[] = [];