Compare commits
31 Commits
codex/exte
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
393f980ad3 | ||
|
|
d3f37bc700 | ||
|
|
66a4dfba0e | ||
|
|
638999ca7c | ||
|
|
b132712635 | ||
|
|
b5ad5e9244 | ||
|
|
edca51be7c | ||
|
|
36dd0888a3 | ||
|
|
ecefc5bd53 | ||
|
|
4a1e5d8b72 | ||
|
|
3be7ba6ee3 | ||
|
|
c7ccb99d30 | ||
|
|
12e617ae50 | ||
|
|
a81a2c78e3 | ||
|
|
a431bfc3b8 | ||
|
|
fb06663ac6 | ||
|
|
f9e3d30d2d | ||
|
|
aa02b4fa42 | ||
|
|
509076b3a2 | ||
|
|
e91134e92f | ||
|
|
d1c1988174 | ||
|
|
e335490a5b | ||
|
|
7ca0af4bac | ||
|
|
71ec9f4c10 | ||
|
|
354ba8e4c9 | ||
|
|
c382eafdb2 | ||
|
|
02897e6879 | ||
|
|
ce4137f028 | ||
|
|
b57002a6a1 | ||
|
|
cfda97c828 | ||
|
|
f4a7bb1a65 |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@ -11,10 +11,10 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Node 22 check
|
||||
lint-workflows:
|
||||
name: Lint workflows
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@ -22,6 +22,24 @@ jobs:
|
||||
- name: Lint workflows
|
||||
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
|
||||
|
||||
check:
|
||||
name: Node ${{ matrix.node }} check (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node:
|
||||
- 22
|
||||
- 24
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5
|
||||
with:
|
||||
@ -31,7 +49,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,3 +2,5 @@ dist/
|
||||
node_modules/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.deepsec/
|
||||
.vscode
|
||||
|
||||
60
CHANGELOG.md
60
CHANGELOG.md
@ -1,22 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.2.1 - 2026-05-08
|
||||
|
||||
### Fixes
|
||||
|
||||
- Align POSIX and Windows handling for literal `..`-prefixed write targets, preserve whitespace in direct home-relative path inputs, and run the check suite on Windows CI. (#14; thanks @sjf)
|
||||
- Keep source prepack builds isolated from parent monorepo ambient type packages such as Bun typings. (#13; thanks @Kaspre)
|
||||
- Let secret-file reads follow symlink paths through the pinned real target unless callers opt into `rejectSymlink: true`.
|
||||
|
||||
## 0.2.0 - 2026-05-07
|
||||
|
||||
### Features
|
||||
|
||||
- Add `writeExternalFileWithinRoot()` for libraries that require an output path while preserving caller-provided destination names. (#7; thanks @jesse-merhi)
|
||||
- Add root JSON helpers and durable JSON queue helpers for file-backed work queues with pending, delivered, failed, and acknowledgement flows.
|
||||
- Add `ensureAbsoluteDirectory()` for creating trusted absolute directory paths one segment at a time while rejecting symlink and non-directory components. (#12; thanks @jesse-merhi)
|
||||
- Add a `durable: false` option to async atomic text and JSON writes so callers can preserve replace semantics while skipping temp-file and parent-directory fsync. (#9; thanks @sallyom)
|
||||
- Add process-wide sidecar lock defaults while keeping JSON store locking opt-in per resource.
|
||||
|
||||
### Security and Correctness
|
||||
|
||||
- Harden Root fallback mutators, archive merges, private store reads/writes, durable queue ids, JSON fallback writes, sibling temp writes, temp filename sanitization, and trash moves against symlink-swap and path traversal edge cases.
|
||||
- Centralize safe path segment validation, directory identity guards, guarded mkdir, and guarded mutation wrappers so filesystem helpers reuse the same race-resistant checks.
|
||||
- Route archive ZIP staging, temp workspace sync reads, secret-file commits, and atomic move/replace fallbacks through shared pinned-read or guarded-write primitives without applying private-directory modes to public paths.
|
||||
- Close guarded fallback write handles without following path names if post-write directory verification fails, avoiding descriptor leaks and unsafe cleanup in symlink-swap races.
|
||||
- Harden temp filename prefixes, local-root reads, private store imports, durable queue reads, and regular-file byte caps against Deepsec-reported path traversal, symlink, and oversized-read races.
|
||||
- Harden sidecar lock cleanup and stale-lock handling so stale third-party locks fail closed instead of being deleted by path.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Make cross-device move fallbacks reject source changes during staged copies and clean up only the source entries copied into the staged destination, preserving concurrent source additions or replacements instead of recursively deleting them.
|
||||
- Preserve directory modes during cross-device directory moves.
|
||||
- Preserve empty-directory pruning and broken-symlink trash moves across guarded fallback paths.
|
||||
- Preserve sync file-store read policy errors for directory and hardlink validation failures.
|
||||
- Preserve existing temp workspace leaf filename behavior for names such as `.env` and filenames containing spaces.
|
||||
- Preserve public parent-directory modes when writing JSON, moving files across devices, and extracting archives.
|
||||
- Make `prepack` portable on Windows and add the missing pnpm workspace `packages` field so package preparation succeeds consistently.
|
||||
|
||||
### Tests
|
||||
|
||||
- Added regression coverage for the filesystem race and traversal findings fixed in this release.
|
||||
- Added Deepsec regression coverage for unsafe temp tokens, dangling symlinks, default read caps, private `copyIn()` races, symlinked queue entries, oversized queue entries, and fresh sidecar lock preservation.
|
||||
- Added regression coverage for external-output traversal rejection, guarded cleanup, sidecar lock stale handling, move fallback cleanup, durable queue validation, sync read policy failures, and absolute-directory validation.
|
||||
- Added a static filesystem-boundary primitive check that blocks reintroducing known raw copy/read/guard patterns.
|
||||
|
||||
### Docs and Tooling
|
||||
|
||||
- Added docs for external output writers, durable JSON queue helpers, sidecar lock defaults, boundary guardrails, and absolute-directory creation.
|
||||
- Enable ClawSweeper dispatch for pull-request review automation.
|
||||
|
||||
## 0.1.2 - 2026-05-06
|
||||
|
||||
### Fixes
|
||||
|
||||
- Reject `fileStore()` and `fileStoreSync()` writes through symlinked parent directories so store commits cannot escape the configured root.
|
||||
- Harden Root fallback mutators, archive merges, private store reads/writes, durable queue ids, JSON fallback writes, sibling temp writes, temp filename sanitization, and trash moves against symlink-swap and path traversal edge cases.
|
||||
- Centralize safe path segment validation, directory identity guards, and guarded mutation wrappers so future filesystem helpers reuse the same race-resistant checks.
|
||||
- Route archive ZIP staging, temp workspace sync reads, secret-file commits, and atomic move/replace fallbacks through shared pinned-read or guarded-write primitives without applying private-directory modes to public paths.
|
||||
- Close guarded fallback write handles without following path names if post-write directory verification fails, avoiding descriptor leaks and unsafe cleanup in symlink-swap races.
|
||||
- Preserve empty-directory pruning and broken-symlink trash moves across guarded fallback paths.
|
||||
- Preserve sync file-store read policy errors for directory and hardlink validation failures.
|
||||
- Guard fallback mkdir component creation and skip archive destination cleanup after pre-commit races.
|
||||
|
||||
### Tests
|
||||
|
||||
- Added regression coverage for the filesystem race and traversal findings fixed in this release.
|
||||
- Added a static filesystem-boundary primitive check that blocks reintroducing known raw copy/read/guard patterns.
|
||||
- Increased filesystem edge coverage around secure temp fallback handling, sibling-temp cleanup, local-root resolution, file locks, and file identity checks.
|
||||
- Prevented POSIX test runs from leaving Windows-style secure-temp fallback paths in the repository root.
|
||||
|
||||
|
||||
31
README.md
31
README.md
@ -24,7 +24,7 @@ Full docs and reference at **[fs-safe.io](https://fs-safe.io)**.
|
||||
|
||||
## Contents
|
||||
|
||||
[Why this exists](#why-this-exists) · [Not a sandbox](#not-a-sandbox) · [Install](#install) · [Quick start](#quick-start) · [Reading](#reading) · [Subpaths](#subpaths) · [Failure semantics](#failure-semantics-in-the-name) · [Atomic writes](#atomic-writes) · [Stores](#stores) · [Secure absolute reads](#secure-absolute-file-reads) · [Walking](#directory-walking) · [Archive extraction](#archive-extraction) · [Path scopes](#advanced-path-scopes) · [Errors](#errors) · [Safety model](#safety-model) · [Limitations](#limitations)
|
||||
[Why this exists](#why-this-exists) · [Not a sandbox](#not-a-sandbox) · [Install](#install) · [Quick start](#quick-start) · [Reading](#reading) · [Subpaths](#subpaths) · [Failure semantics](#failure-semantics-in-the-name) · [Atomic writes](#atomic-writes) · [External outputs](#external-outputs) · [Stores](#stores) · [Secure absolute reads](#secure-absolute-file-reads) · [Walking](#directory-walking) · [Archive extraction](#archive-extraction) · [Path scopes](#advanced-path-scopes) · [Errors](#errors) · [Safety model](#safety-model) · [Limitations](#limitations)
|
||||
|
||||
## Why this exists
|
||||
|
||||
@ -172,6 +172,7 @@ that OpenClaw needs to compose higher-level APIs are grouped under
|
||||
| `@openclaw/fs-safe/config` | process-global Python helper configuration |
|
||||
| `@openclaw/fs-safe/path` | canonical path checks: `isPathInside`, `safeRealpathSync`, `isNotFoundPathError`, `isSymlinkOpenError` |
|
||||
| `@openclaw/fs-safe/json` | `tryReadJson`, `readJson`, `readJsonIfExists`, `writeJson`, sync variants |
|
||||
| `@openclaw/fs-safe/output` | `writeExternalFileWithinRoot` for external libraries that need a temp output path |
|
||||
| `@openclaw/fs-safe/store` | `fileStore`, `fileStoreSync`, and `jsonStore` |
|
||||
| `@openclaw/fs-safe/secret` | strict and try-style secret file read/write helpers |
|
||||
| `@openclaw/fs-safe/atomic` | `replaceFileAtomic`, `replaceFileAtomicSync`, `replaceDirectoryAtomic`, `movePathWithCopyFallback` |
|
||||
@ -204,7 +205,7 @@ JSON5-backed plugin manifests.
|
||||
|
||||
## Atomic writes
|
||||
|
||||
`replaceFileAtomic()` writes a sibling temp file, optionally fsyncs it, and renames it over the destination. Mode preservation, rename retry / copy fallback on `EPERM`, parent-directory fsync, and a `beforeRename` hook for backup or observer flows are all opt-in.
|
||||
`replaceFileAtomic()` writes a sibling temp file, optionally fsyncs it, and renames it over the destination. Mode preservation, rename retry / copy fallback on `EPERM`, parent-directory fsync, and a `beforeRename` hook for backup or observer flows are all opt-in. `movePathWithCopyFallback()` stages cross-device moves before commit and removes only the copied source entries, so concurrent source additions or replacements are preserved.
|
||||
|
||||
```ts
|
||||
import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
|
||||
@ -220,6 +221,32 @@ await replaceFileAtomic({
|
||||
|
||||
`replaceFileAtomicSync()` covers the synchronous case with the same options shape. Both accept an injectable `fileSystem` for tests.
|
||||
|
||||
## External outputs
|
||||
|
||||
Use `writeExternalFileWithinRoot()` when a browser download, renderer, media
|
||||
tool, or native library needs an absolute path to write to:
|
||||
|
||||
```ts
|
||||
import { writeExternalFileWithinRoot } from "@openclaw/fs-safe/output";
|
||||
|
||||
await writeExternalFileWithinRoot({
|
||||
rootDir: "/safe/workspace/downloads",
|
||||
path: "reports/today.pdf",
|
||||
write: async (filePath) => {
|
||||
await download.saveAs(filePath);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The callback receives a private temp file path, not the final destination. After
|
||||
the callback returns, fs-safe finalizes the staged file with `Root.copyIn()`,
|
||||
creating missing parents by default and rejecting traversal, symlink parent
|
||||
escapes, hardlinked final targets, and size-limit violations.
|
||||
|
||||
Use it when the final filename is known before the external writer runs. If the
|
||||
filename depends on sniffing the produced bytes, write to a private temp
|
||||
workspace first, then finalize through the normal root APIs after validation.
|
||||
|
||||
## Stores
|
||||
|
||||
Use `fileStore().json()` for small state files that need explicit fallback
|
||||
|
||||
@ -38,9 +38,19 @@ The exports group into a handful of themes. Each documented helper has its own p
|
||||
| Export | Page | Notes |
|
||||
|---|---|---|
|
||||
| `assertAbsolutePathInput` | – | Validate a caller-supplied absolute path string. |
|
||||
| `ensureAbsoluteDirectory`, `EnsureAbsoluteDirectoryOptions`, `EnsureAbsoluteDirectoryResult` | – | Create a trusted absolute directory path one segment at a time, rejecting symlink or non-directory segments. |
|
||||
| `canonicalPathFromExistingAncestor`, `findExistingAncestor` | – | Canonicalize without requiring the leaf to exist. |
|
||||
| `resolveAbsolutePathForRead`, `resolveAbsolutePathForWrite`, `ResolvedAbsolutePath`, `ResolvedWritableAbsolutePath`, `AbsolutePathSymlinkPolicy` | – | Validate an absolute path against a symlink policy before opening. |
|
||||
|
||||
`ensureAbsoluteDirectory()` is for paths you already intend to trust as absolute
|
||||
locations, such as a configured output root. It does not enforce a root boundary;
|
||||
use `pathScope().ensureDir()` or `ensureDirectoryWithinRoot()` when the caller
|
||||
supplies a path that must stay under a root.
|
||||
|
||||
The helper returns `{ ok: false, code, error }` for path-policy failures such as
|
||||
relative paths, symlinks, non-directories, or directory swaps during creation.
|
||||
Operational filesystem failures such as permissions or I/O errors are rethrown.
|
||||
|
||||
### Files and identity
|
||||
|
||||
| Export | Page | Notes |
|
||||
|
||||
@ -114,23 +114,44 @@ await writeTextAtomic("/srv/workspace/rendered.md", rendered, {
|
||||
});
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
```ts
|
||||
type WriteTextAtomicOptions = {
|
||||
mode?: number; // file mode (default 0o600)
|
||||
dirMode?: number; // mode for parent dirs created on demand
|
||||
trailingNewline?: boolean; // append "\n" if missing
|
||||
durable?: boolean; // default true; false skips temp/parent fsync
|
||||
};
|
||||
```
|
||||
|
||||
`durable: false` keeps the sibling-temp replace/rename behavior but skips the
|
||||
temp-file and parent-directory `fsync` calls. Use it only for reconstructible
|
||||
metadata where lower latency matters more than crash-durability.
|
||||
|
||||
## `movePathWithCopyFallback`
|
||||
|
||||
Rename a path. If the rename fails with `EXDEV` (cross-device), fall back to
|
||||
copying into a staged sibling path, renaming that staged path into place, and
|
||||
then removing the source. The fallback avoids buffering regular files into
|
||||
memory and does not tighten the destination parent directory mode.
|
||||
then removing only the source entries that were copied. The fallback avoids
|
||||
buffering regular files into memory and does not tighten the destination parent
|
||||
directory mode.
|
||||
|
||||
```ts
|
||||
import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
|
||||
|
||||
await movePathWithCopyFallback({
|
||||
from: "/srv/cache/blob.bin",
|
||||
sourceHardlinks: "reject",
|
||||
to: "/srv/persistent/blob.bin",
|
||||
});
|
||||
```
|
||||
|
||||
Use it when source and destination might live on different filesystems (containers, tmpfs, separate volumes).
|
||||
If another writer changes source entries during the fallback, the staged copy
|
||||
throws `ESTALE` before commit when possible. If the destination has already
|
||||
been committed, cleanup still preserves the changed source entries and throws
|
||||
`ESTALE`.
|
||||
|
||||
## Difference from `root()`
|
||||
|
||||
|
||||
@ -1,22 +1,25 @@
|
||||
---
|
||||
title: Config
|
||||
description: "Process-global configuration for the optional Python helper used by fs-safe on POSIX."
|
||||
description: "Process-global defaults for optional fs-safe helpers."
|
||||
---
|
||||
|
||||
# `@openclaw/fs-safe/config`
|
||||
|
||||
Process-global configuration knobs for the optional persistent Python helper that backs POSIX fd-relative operations in `root()`. The whole helper policy is described in the [Python helper policy](python-helper.md); this page is the API reference.
|
||||
Process-global configuration knobs for optional fs-safe helpers. The Python helper policy is described in the [Python helper policy](python-helper.md); this page is the API reference.
|
||||
|
||||
```ts
|
||||
import {
|
||||
configureFsSafePython,
|
||||
configureFsSafeLocks,
|
||||
getFsSafePythonConfig,
|
||||
getFsSafeLockConfig,
|
||||
type FsSafeLockConfig,
|
||||
type FsSafePythonConfig,
|
||||
type FsSafePythonMode,
|
||||
} from "@openclaw/fs-safe/config";
|
||||
```
|
||||
|
||||
`configureFsSafePython` is also re-exported from the main entry point, so `import { configureFsSafePython } from "@openclaw/fs-safe"` works too. Prefer the subpath when you only need helper configuration and want the smallest import surface.
|
||||
These functions are also re-exported from the main entry point. Prefer the subpath when you only need helper configuration and want the smallest import surface.
|
||||
|
||||
## `configureFsSafePython(config)`
|
||||
|
||||
@ -47,6 +50,31 @@ function getFsSafePythonConfig(): FsSafePythonConfig;
|
||||
|
||||
Return the effective configuration: programmatic overrides win, then env vars, then the package default (`auto`).
|
||||
|
||||
## `configureFsSafeLocks(config)`
|
||||
|
||||
```ts
|
||||
function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
|
||||
|
||||
type FsSafeLockConfig = {
|
||||
staleRecovery: "fail-closed";
|
||||
staleMs?: number;
|
||||
timeoutMs?: number;
|
||||
retry?: FileLockRetryOptions;
|
||||
};
|
||||
```
|
||||
|
||||
Set process-wide defaults for sidecar lock options. This does **not** turn locking on globally; callers still need to pass `lock: true` or a lock options object for the specific JSON store/resource that needs cross-process coordination.
|
||||
|
||||
`staleRecovery` currently supports `"fail-closed"` only. Stale third-party sidecars are not deleted by path because Node cannot atomically bind that deletion to the file that was inspected.
|
||||
|
||||
## `getFsSafeLockConfig()`
|
||||
|
||||
```ts
|
||||
function getFsSafeLockConfig(): FsSafeLockConfig;
|
||||
```
|
||||
|
||||
Return the current sidecar lock defaults.
|
||||
|
||||
## Environment variables
|
||||
|
||||
The same policy can be set without code:
|
||||
@ -61,5 +89,6 @@ OpenClaw compatibility aliases are accepted: `OPENCLAW_FS_SAFE_PYTHON_MODE`, `OP
|
||||
## Related pages
|
||||
|
||||
- [Python helper policy](python-helper.md) — when to pick `auto`, `off`, or `require`, and what each mode protects.
|
||||
- [File lock](sidecar-lock.md) — the per-resource lock API that consumes lock defaults.
|
||||
- [Root API](root.md) — the API whose POSIX hardening the helper backs.
|
||||
- [Errors](errors.md) — `helper-unavailable` and `helper-failed`.
|
||||
|
||||
@ -49,9 +49,10 @@ await fs.remove("notes/archive/today.txt");
|
||||
| Surface | Use it for |
|
||||
|---|---|
|
||||
| [`root()`](root.md) | One boundary for read/write/move/remove inside a trusted directory. |
|
||||
| [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper configuration (`configureFsSafePython`, `getFsSafePythonConfig`). |
|
||||
| [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper and lock-option defaults. |
|
||||
| [Python helper policy](python-helper.md) | Choose `auto`, `off`, or `require` for POSIX fd-relative hardening. |
|
||||
| [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
|
||||
| [`writeExternalFileWithinRoot`](output.md) | Stage external-library file output in private temp storage, then finalize under a root. |
|
||||
| [`writeJson` / `readJson*`](json.md) | JSON state files with strict and lenient read variants. |
|
||||
| [`@openclaw/fs-safe/store`](store.md) | Overview of `fileStore`, `fileStoreSync`, and `jsonStore`. |
|
||||
| [`jsonStore`](json-store.md) | Single JSON state file with explicit fallback, atomic writes, and optional locking. |
|
||||
@ -63,7 +64,7 @@ await fs.remove("notes/archive/today.txt");
|
||||
| [`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. |
|
||||
| [Permissions](permissions.md) | POSIX mode and Windows ACL inspection/remediation helpers. |
|
||||
| [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and stale-lock recovery. |
|
||||
| [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
|
||||
| [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
|
||||
| [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
|
||||
| [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
|
||||
|
||||
@ -53,6 +53,7 @@ type JsonStoreLockOptions = {
|
||||
staleMs?: number; // default 30_000
|
||||
timeoutMs?: number; // default 30_000
|
||||
retry?: FileLockRetryOptions;
|
||||
staleRecovery?: "fail-closed";
|
||||
managerKey?: string; // default `fs-safe.json-store:<filePath>`
|
||||
};
|
||||
|
||||
@ -130,6 +131,7 @@ const counter = jsonStore<{ count: number }>({
|
||||
lock: {
|
||||
staleMs: 60_000,
|
||||
timeoutMs: 10_000,
|
||||
staleRecovery: "fail-closed",
|
||||
retry: { retries: 30, minTimeout: 100, maxTimeout: 5_000, randomize: true },
|
||||
},
|
||||
});
|
||||
@ -137,6 +139,8 @@ const counter = jsonStore<{ count: number }>({
|
||||
|
||||
When `lock` is falsy, `read` / `write` / `update` are unlocked. The `update` shape is still useful — it gives you a single function for the read-modify-write pattern — but it offers no concurrency guarantees if other processes also write to the file.
|
||||
|
||||
Process-wide lock defaults from `configureFsSafeLocks()` apply only after locking is explicitly enabled. They do not make JSON stores lock by default.
|
||||
|
||||
The default `managerKey` namespaces the in-process `FileLockManager` per absolute file path, so two `jsonStore` calls on the same file share lock state automatically.
|
||||
|
||||
## Common patterns
|
||||
|
||||
@ -115,9 +115,14 @@ type WriteJsonOptions = {
|
||||
mode?: number; // file mode (default 0o600)
|
||||
dirMode?: number; // mode for parent dirs created on demand
|
||||
trailingNewline?: boolean; // append "\n" if missing (default false)
|
||||
durable?: boolean; // default true; false skips temp/parent fsync
|
||||
};
|
||||
```
|
||||
|
||||
`durable: false` preserves atomic temp-file replacement but skips the temp-file
|
||||
and parent-directory `fsync` calls. Use it only for reconstructible JSON state
|
||||
where lower latency matters more than crash-durability.
|
||||
|
||||
### `writeJsonSync(pathname, data)`
|
||||
|
||||
Synchronous variant. Convenience wrapper that uses the sync atomic-write path with sensible defaults.
|
||||
|
||||
92
docs/output.md
Normal file
92
docs/output.md
Normal file
@ -0,0 +1,92 @@
|
||||
# External outputs
|
||||
|
||||
`@openclaw/fs-safe/output` covers the case where another library insists on
|
||||
writing to an absolute path you give it. Browser downloads, renderers, media
|
||||
tools, and native libraries often have this shape:
|
||||
|
||||
```ts
|
||||
import { writeExternalFileWithinRoot } from "@openclaw/fs-safe/output";
|
||||
|
||||
await writeExternalFileWithinRoot({
|
||||
rootDir: "/srv/workspace/downloads",
|
||||
path: "reports/today.pdf",
|
||||
write: async (filePath) => {
|
||||
await download.saveAs(filePath);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The external writer never receives the final destination path. It receives a
|
||||
private temp file path instead. After the callback returns, fs-safe copies that
|
||||
staged file into the requested target through the same root boundary used by
|
||||
`Root.copyIn()`.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function writeExternalFileWithinRoot<T = void>(
|
||||
options: ExternalFileWriteOptions<T>,
|
||||
): Promise<ExternalFileWriteResult<T>>;
|
||||
|
||||
type ExternalFileWriteOptions<T = void> = {
|
||||
rootDir: string;
|
||||
path: string; // relative or absolute, but must stay under rootDir
|
||||
write: (filePath: string) => Promise<T>;
|
||||
maxBytes?: number;
|
||||
mode?: number;
|
||||
};
|
||||
|
||||
type ExternalFileWriteResult<T = void> = {
|
||||
path: string; // final absolute path under the canonical root
|
||||
result: T; // value returned by write()
|
||||
};
|
||||
```
|
||||
|
||||
The requested `path` must name a file. Missing destination parents are created
|
||||
by the helper because the operation is "produce this output file under the
|
||||
root"; callers should choose the filename before calling this API.
|
||||
|
||||
Use `maxBytes` when the external producer can create arbitrarily large files.
|
||||
Use `mode` when the finalized file needs a specific POSIX mode. Both are
|
||||
enforced during the `Root.copyIn()` finalization step, after the external writer
|
||||
has produced the staged file and before the final target is committed.
|
||||
|
||||
## Why not pass the final path to the library?
|
||||
|
||||
If a target parent can be swapped after validation, handing an external library
|
||||
the final path can make the library write outside the intended root before
|
||||
fs-safe has a chance to finalize or reject the operation. This helper stages in
|
||||
a private temp workspace first, then finalizes with `Root.copyIn()`. That keeps
|
||||
the trust-boundary write inside fs-safe's root-aware copy/atomic-write path.
|
||||
|
||||
## Browser download example
|
||||
|
||||
```ts
|
||||
const outputPath = requestedOutputPath || sanitizeBrowserSuggestedName(suggestedFilename);
|
||||
|
||||
await writeExternalFileWithinRoot({
|
||||
rootDir: downloadsRoot,
|
||||
path: outputPath,
|
||||
maxBytes: 512 * 1024 * 1024,
|
||||
write: async (filePath) => {
|
||||
await download.saveAs(filePath);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The chosen path may be absolute if it is already inside `downloadsRoot`, or
|
||||
relative to `downloadsRoot`. Traversal, symlink parent escapes, hardlinked final
|
||||
targets, over-large staged files, and missing temp files surface as
|
||||
`FsSafeError`s.
|
||||
|
||||
This helper is not the right fit when the final filename depends on inspecting
|
||||
the produced bytes. In that case, write to a private temp workspace, sniff or
|
||||
validate the file, choose the final name, then copy or write into the root with
|
||||
the normal root APIs.
|
||||
|
||||
## See also
|
||||
|
||||
- [Root writes](writing.md) — `write`, `copyIn`, `move`, and `mkdir`.
|
||||
- [Temp workspaces](temp.md) — private scratch directories for longer workflows.
|
||||
- [`pathScope()`](path-scope.md) — validation-only helper when you must pass an
|
||||
absolute path directly to another library.
|
||||
@ -1,6 +1,6 @@
|
||||
# File lock
|
||||
|
||||
`acquireFileLock()` and `withFileLock()` provide a cross-process file lock with retry, stale-lock reclaim, and process-exit cleanup. The lock is implemented as a sidecar file (e.g. `state.json` ↔ `state.json.lock`) — only one acquirer can create the sidecar with `O_CREAT | O_EXCL` at a time.
|
||||
`acquireFileLock()` and `withFileLock()` provide a cross-process file lock with retry and process-exit cleanup. The lock is implemented as a sidecar file (e.g. `state.json` ↔ `state.json.lock`) — only one acquirer can create the sidecar with `O_CREAT | O_EXCL` at a time.
|
||||
|
||||
```ts
|
||||
import { acquireFileLock } from "@openclaw/fs-safe/file-lock";
|
||||
@ -19,9 +19,9 @@ try {
|
||||
|
||||
## Why sidecar?
|
||||
|
||||
The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via `shouldReclaim` (defaulting to "is the lock older than `staleMs`?") — whether to take it over.
|
||||
The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via `shouldReclaim` (defaulting to "is the lock older than `staleMs`?") — whether it should keep waiting or fail.
|
||||
|
||||
The library installs a `process.on("exit")` handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars. Crashes still need the reclaim path.
|
||||
The library installs a `process.on("exit")` handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars. Crashed holders leave their sidecar behind; remove those through an application-owned recovery path after you have proved the holder cannot still be writing.
|
||||
|
||||
## API
|
||||
|
||||
@ -48,9 +48,10 @@ function createFileLockManager(key: string): FileLockManager;
|
||||
type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
||||
managerKey?: string; // optional in-process manager namespace
|
||||
lockPath?: string; // override; defaults to `${targetPath}.lock`
|
||||
staleMs: number; // how long until a held lock is considered stale
|
||||
staleMs?: number; // default 30_000
|
||||
timeoutMs?: number; // overall acquire deadline; default unbounded
|
||||
retry?: FileLockRetryOptions;
|
||||
staleRecovery?: "fail-closed"; // default
|
||||
allowReentrant?: boolean; // if this process already holds it, increment a count instead of failing
|
||||
payload: () => TPayload | Promise<TPayload>;
|
||||
shouldReclaim?: (params: {
|
||||
@ -100,7 +101,7 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
If your process dies before `release()` runs and skips the exit handler, the next acquirer reclaims the lock once `staleMs` elapses (or your `shouldReclaim` returns true).
|
||||
If your process dies before `release()` runs and skips the exit handler, the sidecar remains. Once `staleMs` elapses (or your `shouldReclaim` returns true), acquisition fails closed instead of deleting by path, because Node cannot atomically bind that deletion to the file that was inspected.
|
||||
|
||||
## `withFileLock` — common shape made one-liner
|
||||
|
||||
@ -139,9 +140,9 @@ await handle.release();
|
||||
await locks.drain();
|
||||
```
|
||||
|
||||
## Reclaim policy: `shouldReclaim`
|
||||
## Stale policy: `shouldReclaim`
|
||||
|
||||
The default policy reclaims locks whose `acquiredAt` is older than `staleMs`. Pass a custom callback when you want a richer notion of "is the holder still alive":
|
||||
The default policy treats locks whose `createdAt` is older than `staleMs` as stale. Pass a custom callback when you want a richer notion of "is the holder still alive":
|
||||
|
||||
```ts
|
||||
import { kill } from "node:process";
|
||||
@ -155,26 +156,26 @@ const handle = await acquireFileLock(targetPath, {
|
||||
if (!Number.isFinite(pid)) return true;
|
||||
try {
|
||||
kill(pid, 0);
|
||||
return false; // process still alive — don't reclaim
|
||||
return false; // process still alive — keep waiting
|
||||
} catch {
|
||||
return true; // process gone — reclaim
|
||||
return true; // process gone — fail closed for recovery
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case).
|
||||
`heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case). A `true` result does not delete the sidecar; it lets the acquire loop stop waiting once the retry/timeout policy says to give up.
|
||||
|
||||
## What sidecar locks defend against
|
||||
|
||||
- **Two processes writing the same file at once.** `acquire` serializes the critical section.
|
||||
- **A crashed holder leaving a stale lock.** `staleMs` plus optional `shouldReclaim` recovers it.
|
||||
- **Accidentally deleting a fresh lock during stale recovery.** Stale third-party locks fail closed because safe compare-and-unlink is not available through Node's path APIs.
|
||||
- **Race between simultaneous acquire attempts.** `O_CREAT | O_EXCL` ensures one wins.
|
||||
|
||||
## What they do **not** defend against
|
||||
|
||||
- **Misbehaving holders that ignore the lock.** Locks are advisory — only callers that go through `acquire` are bound.
|
||||
- **Holders that never call `release` and have no liveness check.** Without a real `shouldReclaim`, the lock relies on `staleMs` alone — pick a deadline that is comfortably longer than your real work but short enough to recover from crashes.
|
||||
- **Automatic stale lock deletion.** If a process crashes, use the payload and your own supervisor/process table to decide when to remove the sidecar.
|
||||
- **Multi-host coordination over network filesystems.** Behavior depends on the underlying filesystem's `O_EXCL` semantics; treat as best-effort.
|
||||
|
||||
## Common patterns
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/fs-safe",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.1",
|
||||
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@ -36,6 +36,10 @@
|
||||
"types": "./dist/path.d.ts",
|
||||
"default": "./dist/path.js"
|
||||
},
|
||||
"./output": {
|
||||
"types": "./dist/output.d.ts",
|
||||
"default": "./dist/output.js"
|
||||
},
|
||||
"./advanced": {
|
||||
"types": "./dist/advanced.d.ts",
|
||||
"default": "./dist/advanced.js"
|
||||
|
||||
@ -28,7 +28,7 @@ const installCmd = "pnpm add @openclaw/fs-safe";
|
||||
const sections = [
|
||||
["Start", ["index.md", "install.md", "quickstart.md", "security-model.md", "python-helper.md", "config.md"]],
|
||||
["Root API", ["root.md", "reading.md", "writing.md", "path-scope.md"]],
|
||||
["Atomic & temp", ["atomic.md", "json.md", "temp.md", "archive.md"]],
|
||||
["Atomic & temp", ["atomic.md", "output.md", "json.md", "temp.md", "archive.md"]],
|
||||
["Stores", ["store.md", "json-store.md", "file-store.md", "private-file-store.md"]],
|
||||
["Specialized", ["secret-file.md", "regular-file.md", "sidecar-lock.md", "pinned-open.md", "local-roots.md"]],
|
||||
["Path & filename", ["path.md", "filename.md", "install-path.md"]],
|
||||
|
||||
@ -6,10 +6,10 @@ const LINE_BUDGETS = new Map([
|
||||
["src/file-store.ts", 580],
|
||||
["src/permissions.ts", 566],
|
||||
["src/pinned-python.ts", 655],
|
||||
["src/root-impl.ts", 1744],
|
||||
["src/root-impl.ts", 1750],
|
||||
["src/root-path.ts", 862],
|
||||
["test/api-coverage.test.ts", 982],
|
||||
["test/new-primitives.test.ts", 998],
|
||||
["test/api-coverage.test.ts", 983],
|
||||
["test/new-primitives.test.ts", 1500],
|
||||
]);
|
||||
|
||||
function walk(dir) {
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import type { Stats } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { FsSafeError } from "./errors.js";
|
||||
import {
|
||||
assertAsyncDirectoryGuard,
|
||||
type AsyncDirectoryGuard,
|
||||
createAsyncDirectoryGuard,
|
||||
} from "./directory-guard.js";
|
||||
import { FsSafeError, type FsSafeErrorCode } from "./errors.js";
|
||||
|
||||
export type AbsolutePathSymlinkPolicy = "reject" | "follow";
|
||||
|
||||
@ -14,6 +20,192 @@ export type ResolvedWritableAbsolutePath = ResolvedAbsolutePath & {
|
||||
parentExists: boolean;
|
||||
};
|
||||
|
||||
export type EnsureAbsoluteDirectoryOptions = {
|
||||
scopeLabel?: string;
|
||||
mode?: number;
|
||||
};
|
||||
|
||||
export type EnsureAbsoluteDirectoryResult =
|
||||
| { ok: true; path: string }
|
||||
| { ok: false; code: FsSafeErrorCode; error: FsSafeError };
|
||||
|
||||
type EnsureAbsoluteDirectoryFailure = Extract<EnsureAbsoluteDirectoryResult, { ok: false }>;
|
||||
type DirectoryGuardCheckResult = { ok: true } | EnsureAbsoluteDirectoryFailure;
|
||||
type DirectoryGuardCreateResult =
|
||||
| { ok: true; guard: AsyncDirectoryGuard }
|
||||
| EnsureAbsoluteDirectoryFailure;
|
||||
type DirectoryPrefixResult =
|
||||
| {
|
||||
ok: true;
|
||||
ancestorPath: string;
|
||||
missingSegments: string[];
|
||||
}
|
||||
| EnsureAbsoluteDirectoryFailure;
|
||||
|
||||
function ensureDirectoryFailure(
|
||||
code: FsSafeErrorCode,
|
||||
message: string,
|
||||
cause?: unknown,
|
||||
): EnsureAbsoluteDirectoryFailure {
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
error: new FsSafeError(code, message, { cause }),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertGuardResult(
|
||||
guard: AsyncDirectoryGuard,
|
||||
scopeLabel: string,
|
||||
): Promise<DirectoryGuardCheckResult> {
|
||||
try {
|
||||
await assertAsyncDirectoryGuard(guard);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof FsSafeError) {
|
||||
return await directoryGuardFailure(err, guard.dir, scopeLabel);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDirectoryGuardResult(
|
||||
dir: string,
|
||||
scopeLabel: string,
|
||||
): Promise<DirectoryGuardCreateResult> {
|
||||
try {
|
||||
return { ok: true, guard: await createAsyncDirectoryGuard(dir) };
|
||||
} catch (err) {
|
||||
if (err instanceof FsSafeError) {
|
||||
return await directoryGuardFailure(err, dir, scopeLabel);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function classifyDirectoryLookupError(
|
||||
err: unknown,
|
||||
scopeLabel: string,
|
||||
): EnsureAbsoluteDirectoryFailure | null {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
return ensureDirectoryFailure(
|
||||
"not-found",
|
||||
`directory path must have a real existing ancestor within ${scopeLabel}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
if (code === "ENOTDIR") {
|
||||
return ensureDirectoryFailure(
|
||||
"not-file",
|
||||
`path must be a real directory within ${scopeLabel}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyExistingDirectorySegment(
|
||||
stat: Stats,
|
||||
scopeLabel: string,
|
||||
): EnsureAbsoluteDirectoryFailure | null {
|
||||
if (stat.isSymbolicLink()) {
|
||||
return ensureDirectoryFailure(
|
||||
"symlink",
|
||||
`directory path traverses a symlink within ${scopeLabel}`,
|
||||
);
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return ensureDirectoryFailure("not-file", `path must be a real directory within ${scopeLabel}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function directoryGuardFailure(
|
||||
err: FsSafeError,
|
||||
dir: string,
|
||||
scopeLabel: string,
|
||||
): Promise<EnsureAbsoluteDirectoryFailure> {
|
||||
if (err.code !== "not-file") {
|
||||
return { ok: false, code: err.code, error: err };
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.lstat(dir);
|
||||
const failure = classifyExistingDirectorySegment(stat, scopeLabel);
|
||||
if (failure) {
|
||||
return failure;
|
||||
}
|
||||
} catch (lookupErr) {
|
||||
const failure = classifyDirectoryLookupError(lookupErr, scopeLabel);
|
||||
if (failure) {
|
||||
return failure;
|
||||
}
|
||||
throw lookupErr;
|
||||
}
|
||||
return { ok: false, code: err.code, error: err };
|
||||
}
|
||||
|
||||
async function resolveTrustedDirectoryPrefix(
|
||||
targetPath: string,
|
||||
scopeLabel: string,
|
||||
): Promise<DirectoryPrefixResult> {
|
||||
const root = path.parse(targetPath).root;
|
||||
let current = root;
|
||||
let currentStat: Stats;
|
||||
try {
|
||||
currentStat = await fs.lstat(current);
|
||||
} catch (err) {
|
||||
const failure = classifyDirectoryLookupError(err, scopeLabel);
|
||||
if (failure) {
|
||||
return failure;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const rootFailure = classifyExistingDirectorySegment(currentStat, scopeLabel);
|
||||
if (rootFailure) {
|
||||
return rootFailure;
|
||||
}
|
||||
|
||||
// Walk forward with lstat. Looking backward for the "nearest existing
|
||||
// ancestor" can cross an existing suffix through a symlinked parent before
|
||||
// this helper gets a chance to reject that parent.
|
||||
const segments = path.relative(root, targetPath).split(path.sep).filter(Boolean);
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const segment = segments[index];
|
||||
if (!segment) {
|
||||
continue;
|
||||
}
|
||||
const next = path.join(current, segment);
|
||||
try {
|
||||
const nextStat = await fs.lstat(next);
|
||||
const segmentFailure = classifyExistingDirectorySegment(nextStat, scopeLabel);
|
||||
if (segmentFailure) {
|
||||
return segmentFailure;
|
||||
}
|
||||
current = next;
|
||||
currentStat = nextStat;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
return {
|
||||
ok: true,
|
||||
ancestorPath: current,
|
||||
missingSegments: segments.slice(index),
|
||||
};
|
||||
}
|
||||
const failure = classifyDirectoryLookupError(err, scopeLabel);
|
||||
if (failure) {
|
||||
return failure;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, ancestorPath: current, missingSegments: [] };
|
||||
}
|
||||
|
||||
export function assertAbsolutePathInput(filePath: string): string {
|
||||
if (!filePath) {
|
||||
throw new FsSafeError("invalid-path", "path is required");
|
||||
@ -37,11 +229,17 @@ async function pathExists(filePath: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function findExistingAncestor(filePath: string): Promise<string | null> {
|
||||
return (await findExistingAncestorWithStat(filePath))?.path ?? null;
|
||||
}
|
||||
|
||||
async function findExistingAncestorWithStat(filePath: string): Promise<{
|
||||
path: string;
|
||||
stat: Stats;
|
||||
} | null> {
|
||||
let current = path.resolve(filePath);
|
||||
while (true) {
|
||||
try {
|
||||
await fs.lstat(current);
|
||||
return current;
|
||||
return { path: current, stat: await fs.lstat(current) };
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
@ -55,6 +253,90 @@ export async function findExistingAncestor(filePath: string): Promise<string | n
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAbsoluteDirectory(
|
||||
dirPath: string,
|
||||
options: EnsureAbsoluteDirectoryOptions = {},
|
||||
): Promise<EnsureAbsoluteDirectoryResult> {
|
||||
const scopeLabel = options.scopeLabel ?? "directory";
|
||||
let targetPath: string;
|
||||
try {
|
||||
targetPath = assertAbsolutePathInput(dirPath);
|
||||
} catch (err) {
|
||||
if (err instanceof FsSafeError) {
|
||||
return { ok: false, code: err.code, error: err };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const prefix = await resolveTrustedDirectoryPrefix(targetPath, scopeLabel);
|
||||
if (!prefix.ok) {
|
||||
return prefix;
|
||||
}
|
||||
|
||||
let current = prefix.ancestorPath;
|
||||
const initialGuard = await createDirectoryGuardResult(prefix.ancestorPath, scopeLabel);
|
||||
if (!initialGuard.ok) {
|
||||
return initialGuard;
|
||||
}
|
||||
let currentGuard: AsyncDirectoryGuard = initialGuard.guard;
|
||||
for (const segment of prefix.missingSegments) {
|
||||
current = path.join(current, segment);
|
||||
while (true) {
|
||||
const guardResult = await assertGuardResult(currentGuard, scopeLabel);
|
||||
if (!guardResult.ok) {
|
||||
return guardResult;
|
||||
}
|
||||
try {
|
||||
const stat = await fs.lstat(current);
|
||||
if (stat.isSymbolicLink()) {
|
||||
return ensureDirectoryFailure(
|
||||
"symlink",
|
||||
`directory path traverses a symlink within ${scopeLabel}`,
|
||||
);
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return ensureDirectoryFailure(
|
||||
"not-file",
|
||||
`path must be a real directory within ${scopeLabel}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
const parentStillValid = await assertGuardResult(currentGuard, scopeLabel);
|
||||
if (!parentStillValid.ok) {
|
||||
return parentStillValid;
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(current, { mode: options.mode });
|
||||
} catch (mkdirErr) {
|
||||
if ((mkdirErr as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
continue;
|
||||
}
|
||||
throw mkdirErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
const nextGuard = await createDirectoryGuardResult(current, scopeLabel);
|
||||
if (!nextGuard.ok) {
|
||||
return nextGuard;
|
||||
}
|
||||
const previousGuardStillValid = await assertGuardResult(currentGuard, scopeLabel);
|
||||
if (!previousGuardStillValid.ok) {
|
||||
return previousGuardStillValid;
|
||||
}
|
||||
currentGuard = nextGuard.guard;
|
||||
}
|
||||
|
||||
const finalGuardResult = await assertGuardResult(currentGuard, scopeLabel);
|
||||
if (!finalGuardResult.ok) {
|
||||
return finalGuardResult;
|
||||
}
|
||||
return { ok: true, path: targetPath };
|
||||
}
|
||||
|
||||
export async function canonicalPathFromExistingAncestor(filePath: string): Promise<string> {
|
||||
const ancestor = await findExistingAncestor(filePath);
|
||||
if (!ancestor) {
|
||||
|
||||
@ -5,10 +5,13 @@ export { createAsyncLock } from "./async-lock.js";
|
||||
export {
|
||||
assertAbsolutePathInput,
|
||||
canonicalPathFromExistingAncestor,
|
||||
ensureAbsoluteDirectory,
|
||||
findExistingAncestor,
|
||||
resolveAbsolutePathForRead,
|
||||
resolveAbsolutePathForWrite,
|
||||
type AbsolutePathSymlinkPolicy,
|
||||
type EnsureAbsoluteDirectoryOptions,
|
||||
type EnsureAbsoluteDirectoryResult,
|
||||
type ResolvedAbsolutePath,
|
||||
type ResolvedWritableAbsolutePath,
|
||||
} from "./absolute-path.js";
|
||||
@ -34,6 +37,11 @@ export {
|
||||
trySafeFileURLToPath,
|
||||
} from "./local-file-access.js";
|
||||
export { formatPosixMode } from "./mode.js";
|
||||
export {
|
||||
configureFsSafeLocks,
|
||||
getFsSafeLockConfig,
|
||||
type FsSafeLockConfig,
|
||||
} from "./lock-config.js";
|
||||
export {
|
||||
assertNoHardlinkedFinalPath,
|
||||
assertNoPathAliasEscape,
|
||||
|
||||
@ -4,3 +4,8 @@ export {
|
||||
type FsSafePythonConfig,
|
||||
type FsSafePythonMode,
|
||||
} from "./pinned-python-config.js";
|
||||
export {
|
||||
configureFsSafeLocks,
|
||||
getFsSafeLockConfig,
|
||||
type FsSafeLockConfig,
|
||||
} from "./lock-config.js";
|
||||
|
||||
@ -4,15 +4,19 @@ import {
|
||||
type SidecarLockHandle,
|
||||
type SidecarLockHeldEntry,
|
||||
type SidecarLockRetryOptions,
|
||||
type SidecarLockStaleRecovery,
|
||||
} from "./sidecar-lock.js";
|
||||
import { getFsSafeLockConfig } from "./lock-config.js";
|
||||
|
||||
export type FileLockRetryOptions = SidecarLockRetryOptions;
|
||||
export type FileLockStaleRecovery = SidecarLockStaleRecovery;
|
||||
|
||||
export type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = Omit<
|
||||
SidecarLockAcquireOptions<TPayload>,
|
||||
"targetPath"
|
||||
"targetPath" | "staleMs"
|
||||
> & {
|
||||
managerKey?: string;
|
||||
staleMs?: number;
|
||||
};
|
||||
|
||||
export type FileLockHandle = SidecarLockHandle;
|
||||
@ -37,6 +41,19 @@ function resolveFileLockManagerKey(targetPath: string, managerKey?: string): str
|
||||
return managerKey ?? `fs-safe.file-lock:${targetPath}`;
|
||||
}
|
||||
|
||||
function withLockDefaults<TPayload extends Record<string, unknown>>(
|
||||
options: FileLockAcquireOptions<TPayload>,
|
||||
): Omit<SidecarLockAcquireOptions<TPayload>, "targetPath"> {
|
||||
const defaults = getFsSafeLockConfig();
|
||||
return {
|
||||
...options,
|
||||
retry: options.retry ?? defaults.retry,
|
||||
staleMs: options.staleMs ?? defaults.staleMs ?? 30_000,
|
||||
staleRecovery: options.staleRecovery ?? defaults.staleRecovery,
|
||||
timeoutMs: options.timeoutMs ?? defaults.timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function acquireFileLock<TPayload extends Record<string, unknown>>(
|
||||
targetPath: string,
|
||||
options: FileLockAcquireOptions<TPayload>,
|
||||
@ -59,11 +76,11 @@ export function createFileLockManager(key: string): FileLockManager {
|
||||
return {
|
||||
acquire: async (targetPath, options) => {
|
||||
const { managerKey: _managerKey, ...acquireOptions } = options;
|
||||
return await manager.acquire({ ...acquireOptions, targetPath });
|
||||
return await manager.acquire({ ...withLockDefaults(acquireOptions), targetPath });
|
||||
},
|
||||
withLock: async (targetPath, options, fn) => {
|
||||
const { managerKey: _managerKey, ...acquireOptions } = options;
|
||||
return await manager.withLock({ ...acquireOptions, targetPath }, fn);
|
||||
return await manager.withLock({ ...withLockDefaults(acquireOptions), targetPath }, fn);
|
||||
},
|
||||
drain: manager.drain,
|
||||
reset: manager.reset,
|
||||
|
||||
33
src/file-store-source.ts
Normal file
33
src/file-store-source.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { FsSafeError } from "./errors.js";
|
||||
import { readRegularFile } from "./regular-file.js";
|
||||
|
||||
export async function readFileStoreCopySource(params: {
|
||||
sourcePath: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<Buffer> {
|
||||
const sourceStat = await fs.lstat(params.sourcePath);
|
||||
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
|
||||
throw new FsSafeError("not-file", "source path is not a file");
|
||||
}
|
||||
if (params.maxBytes !== undefined && sourceStat.size > params.maxBytes) {
|
||||
throw new FsSafeError("too-large", `file exceeds maximum size of ${params.maxBytes} bytes`);
|
||||
}
|
||||
try {
|
||||
return (await readRegularFile({ filePath: params.sourcePath, maxBytes: params.maxBytes }))
|
||||
.buffer;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("regular file") || message.includes("not a regular file")) {
|
||||
throw new FsSafeError("not-file", "source path is not a file", {
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (params.maxBytes !== undefined && message.includes(`exceeds ${params.maxBytes} bytes`)) {
|
||||
throw new FsSafeError("too-large", `file exceeds maximum size of ${params.maxBytes} bytes`, {
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -18,9 +18,11 @@ import {
|
||||
type SyncParentGuard,
|
||||
writeStreamToTempSource,
|
||||
} from "./file-store-boundary.js";
|
||||
import { readFileStoreCopySource } from "./file-store-source.js";
|
||||
import { createJsonStore, type JsonFileStoreOptions, type JsonStore } from "./json-document-store.js";
|
||||
import { isPathInside, resolveSafeRelativePath } from "./path.js";
|
||||
import { root, type OpenResult, type ReadResult, type Root, type RootReadOptions } from "./root.js";
|
||||
import { DEFAULT_ROOT_MAX_BYTES } from "./root-impl.js";
|
||||
import { matchRootFileOpenFailure, openRootFileSync, type RootFileOpenFailure } from "./root-file.js";
|
||||
import { writeSecretFileAtomic } from "./secret-file.js";
|
||||
import { getFsSafeTestHooks } from "./test-hooks.js";
|
||||
@ -121,7 +123,6 @@ function assertStoreFilePath(rootDir: string, filePath: string): void {
|
||||
throw new FsSafeError("outside-workspace", "file path escapes store root");
|
||||
}
|
||||
}
|
||||
|
||||
function assertMaxBytes(size: number, maxBytes?: number): void {
|
||||
if (maxBytes !== undefined && size > maxBytes) {
|
||||
throw new FsSafeError("too-large", `file exceeds maximum size of ${maxBytes} bytes`);
|
||||
@ -246,7 +247,7 @@ export function fileStore(options: FileStoreOptions): FileStore {
|
||||
writeStream: async (relativePath, stream, writeOptions) => {
|
||||
const safeRelativePath = assertRelativePath(relativePath);
|
||||
const destination = resolveStorePath(rootDir, safeRelativePath);
|
||||
const limit = writeOptions?.maxBytes ?? maxBytes;
|
||||
const limit = writeOptions?.maxBytes ?? maxBytes ?? (privateMode ? DEFAULT_ROOT_MAX_BYTES : undefined);
|
||||
if (privateMode) {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
@ -289,12 +290,11 @@ export function fileStore(options: FileStoreOptions): FileStore {
|
||||
copyIn: async (relativePath, sourcePath, writeOptions) =>
|
||||
privateMode
|
||||
? await (async () => {
|
||||
const sourceStat = await fs.lstat(sourcePath);
|
||||
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
|
||||
throw new FsSafeError("not-file", "source path is not a file");
|
||||
}
|
||||
assertMaxBytes(sourceStat.size, writeOptions?.maxBytes ?? maxBytes);
|
||||
return await write(relativePath, await fs.readFile(sourcePath), writeOptions);
|
||||
const buffer = await readFileStoreCopySource({
|
||||
sourcePath,
|
||||
maxBytes: writeOptions?.maxBytes ?? maxBytes ?? DEFAULT_ROOT_MAX_BYTES,
|
||||
});
|
||||
return await write(relativePath, buffer, writeOptions);
|
||||
})()
|
||||
: await copyIntoRoot({
|
||||
rootDir,
|
||||
|
||||
@ -31,18 +31,21 @@ export function resolveOsHomeDir(
|
||||
|
||||
function resolveRawHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined {
|
||||
const explicitHome = normalize(env.OPENCLAW_HOME);
|
||||
if (explicitHome) {
|
||||
if (explicitHome === "~" || explicitHome.startsWith("~/") || explicitHome.startsWith("~\\")) {
|
||||
const fallbackHome = resolveRawOsHomeDir(env, homedir);
|
||||
if (fallbackHome) {
|
||||
return explicitHome.replace(/^~(?=$|[\\/])/, fallbackHome);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!explicitHome) {
|
||||
return resolveRawOsHomeDir(env, homedir);
|
||||
}
|
||||
const segments = path.normalize(explicitHome).split(path.sep);
|
||||
if (segments[0] !== "~") {
|
||||
return explicitHome;
|
||||
}
|
||||
|
||||
return resolveRawOsHomeDir(env, homedir);
|
||||
// OPENCLAW_HOME starts with "~"; expand against the os home dir. Fall
|
||||
// back to undefined when there is no os home to expand against rather
|
||||
// than returning a raw "~"-prefixed path the caller cannot use.
|
||||
const fallbackHome = resolveRawOsHomeDir(env, homedir);
|
||||
if (!fallbackHome) {
|
||||
return undefined;
|
||||
}
|
||||
return expandHomePrefix(explicitHome, { home: fallbackHome });
|
||||
}
|
||||
|
||||
function resolveRawOsHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined {
|
||||
@ -87,7 +90,8 @@ export function expandHomePrefix(
|
||||
homedir?: () => string;
|
||||
},
|
||||
): string {
|
||||
if (!input.startsWith("~")) {
|
||||
const segments = path.normalize(input).split(path.sep);
|
||||
if (segments[0] !== "~") {
|
||||
return input;
|
||||
}
|
||||
const home =
|
||||
@ -96,7 +100,7 @@ export function expandHomePrefix(
|
||||
if (!home) {
|
||||
return input;
|
||||
}
|
||||
return input.replace(/^~(?=$|[\\/])/, home);
|
||||
return path.join(home, ...segments.slice(1));
|
||||
}
|
||||
|
||||
export function resolveHomeRelativePath(
|
||||
@ -106,19 +110,19 @@ export function resolveHomeRelativePath(
|
||||
homedir?: () => string;
|
||||
},
|
||||
): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
if (!input) {
|
||||
return input;
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = expandHomePrefix(trimmed, {
|
||||
home: resolveRequiredHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir),
|
||||
env: opts?.env,
|
||||
homedir: opts?.homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
const segments = path.normalize(input).split(path.sep)
|
||||
if (segments[0] !== "~") {
|
||||
return path.resolve(input);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
const expanded = expandHomePrefix(input, {
|
||||
home: resolveRequiredHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir),
|
||||
env: opts?.env,
|
||||
homedir: opts?.homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
export function resolveUserPath(
|
||||
@ -145,17 +149,17 @@ export function resolveOsHomeRelativePath(
|
||||
homedir?: () => string;
|
||||
},
|
||||
): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
if (!input) {
|
||||
return input;
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = expandHomePrefix(trimmed, {
|
||||
home: resolveRequiredOsHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir),
|
||||
env: opts?.env,
|
||||
homedir: opts?.homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
const segments = path.normalize(input).split(path.sep);
|
||||
if (segments[0] !== "~") {
|
||||
return path.resolve(input);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
const expanded = expandHomePrefix(input, {
|
||||
home: resolveRequiredOsHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir),
|
||||
env: opts?.env,
|
||||
homedir: opts?.homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
10
src/index.ts
10
src/index.ts
@ -32,3 +32,13 @@ export {
|
||||
type FsSafePythonConfig,
|
||||
type FsSafePythonMode,
|
||||
} from "./pinned-python-config.js";
|
||||
export {
|
||||
writeExternalFileWithinRoot,
|
||||
type ExternalFileWriteOptions,
|
||||
type ExternalFileWriteResult,
|
||||
} from "./output.js";
|
||||
export {
|
||||
configureFsSafeLocks,
|
||||
getFsSafeLockConfig,
|
||||
type FsSafeLockConfig,
|
||||
} from "./lock-config.js";
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import type { FileLockRetryOptions } from "./file-lock.js";
|
||||
import { getFsSafeLockConfig } from "./lock-config.js";
|
||||
import { createSidecarLockManager } from "./sidecar-lock.js";
|
||||
import type { SidecarLockStaleRecovery } from "./sidecar-lock.js";
|
||||
|
||||
export type JsonStoreLockOptions = {
|
||||
staleMs?: number;
|
||||
timeoutMs?: number;
|
||||
retry?: FileLockRetryOptions;
|
||||
staleRecovery?: SidecarLockStaleRecovery;
|
||||
managerKey?: string;
|
||||
};
|
||||
|
||||
@ -45,11 +48,13 @@ function resolveLockOptions(
|
||||
return null;
|
||||
}
|
||||
const lockOptions = options.lock === true ? {} : options.lock;
|
||||
const defaults = getFsSafeLockConfig();
|
||||
return {
|
||||
managerKey: lockOptions.managerKey ?? `fs-safe.json-store:${filePath}`,
|
||||
retry: lockOptions.retry ?? {},
|
||||
staleMs: lockOptions.staleMs ?? 30_000,
|
||||
timeoutMs: lockOptions.timeoutMs ?? 30_000,
|
||||
retry: lockOptions.retry ?? defaults.retry ?? {},
|
||||
staleMs: lockOptions.staleMs ?? defaults.staleMs ?? 30_000,
|
||||
staleRecovery: lockOptions.staleRecovery ?? defaults.staleRecovery,
|
||||
timeoutMs: lockOptions.timeoutMs ?? defaults.timeoutMs ?? 30_000,
|
||||
};
|
||||
}
|
||||
|
||||
@ -84,6 +89,7 @@ export function createJsonStore<T>(
|
||||
staleMs: lockOptions.staleMs,
|
||||
timeoutMs: lockOptions.timeoutMs,
|
||||
retry: lockOptions.retry,
|
||||
staleRecovery: lockOptions.staleRecovery,
|
||||
allowReentrant: true,
|
||||
payload: () => ({ pid: process.pid, createdAt: new Date().toISOString() }),
|
||||
},
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { sameFileIdentity } from "./file-identity.js";
|
||||
import { replaceFileAtomic } from "./replace-file.js";
|
||||
import { assertSafePathSegment } from "./safe-path-segment.js";
|
||||
|
||||
@ -18,8 +19,11 @@ export type JsonDurableQueueLoadOptions<T> = {
|
||||
tempPrefix: string;
|
||||
read?: (entry: T, filePath: string) => Promise<JsonDurableQueueReadResult<T>>;
|
||||
cleanupTmpMaxAgeMs?: number;
|
||||
maxBytes?: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_JSON_DURABLE_QUEUE_ENTRY_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function getErrnoCode(error: unknown): string | null {
|
||||
return error && typeof error === "object" && "code" in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
@ -36,7 +40,7 @@ export async function unlinkBestEffort(filePath: string): Promise<void> {
|
||||
|
||||
export async function jsonDurableQueueEntryExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
const stat = await fs.promises.lstat(filePath);
|
||||
return stat.isFile();
|
||||
} catch (error) {
|
||||
if (getErrnoCode(error) === "ENOENT") {
|
||||
@ -95,8 +99,63 @@ export async function writeJsonDurableQueueEntry(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function readJsonDurableQueueEntry<T>(filePath: string): Promise<T> {
|
||||
return JSON.parse(await fs.promises.readFile(filePath, "utf8")) as T;
|
||||
async function readBoundedUtf8File(params: {
|
||||
filePath: string;
|
||||
maxBytes: number;
|
||||
}): Promise<string> {
|
||||
const initialStat = await fs.promises.lstat(params.filePath);
|
||||
if (initialStat.isSymbolicLink() || !initialStat.isFile()) {
|
||||
throw new Error("queue entry is not a regular file");
|
||||
}
|
||||
if (initialStat.size > params.maxBytes) {
|
||||
throw new Error(`queue entry exceeds ${params.maxBytes} bytes`);
|
||||
}
|
||||
const noFollow =
|
||||
typeof fs.constants.O_NOFOLLOW === "number" && process.platform !== "win32"
|
||||
? fs.constants.O_NOFOLLOW
|
||||
: 0;
|
||||
const handle = await fs.promises.open(params.filePath, fs.constants.O_RDONLY | noFollow);
|
||||
try {
|
||||
const openedStat = await handle.stat();
|
||||
const pathStat = await fs.promises.lstat(params.filePath);
|
||||
if (
|
||||
!openedStat.isFile() ||
|
||||
pathStat.isSymbolicLink() ||
|
||||
!pathStat.isFile() ||
|
||||
!sameFileIdentity(initialStat, openedStat) ||
|
||||
!sameFileIdentity(pathStat, openedStat)
|
||||
) {
|
||||
throw new Error("queue entry changed during read");
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
const scratch = Buffer.allocUnsafe(Math.min(64 * 1024, params.maxBytes + 1));
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { bytesRead } = await handle.read(scratch, 0, scratch.length, null);
|
||||
if (bytesRead === 0) {
|
||||
return Buffer.concat(chunks, total).toString("utf8");
|
||||
}
|
||||
total += bytesRead;
|
||||
if (total > params.maxBytes) {
|
||||
throw new Error(`queue entry exceeds ${params.maxBytes} bytes`);
|
||||
}
|
||||
chunks.push(Buffer.from(scratch.subarray(0, bytesRead)));
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJsonDurableQueueEntry<T>(
|
||||
filePath: string,
|
||||
options: { maxBytes?: number } = {},
|
||||
): Promise<T> {
|
||||
return JSON.parse(
|
||||
await readBoundedUtf8File({
|
||||
filePath,
|
||||
maxBytes: options.maxBytes ?? DEFAULT_JSON_DURABLE_QUEUE_ENTRY_MAX_BYTES,
|
||||
}),
|
||||
) as T;
|
||||
}
|
||||
|
||||
export async function ackJsonDurableQueueEntry(paths: JsonDurableQueueEntryPaths): Promise<void> {
|
||||
@ -116,13 +175,16 @@ export async function loadJsonDurableQueueEntry<T>(params: {
|
||||
paths: JsonDurableQueueEntryPaths;
|
||||
tempPrefix: string;
|
||||
read?: (entry: T, filePath: string) => Promise<JsonDurableQueueReadResult<T>>;
|
||||
maxBytes?: number;
|
||||
}): Promise<T | null> {
|
||||
try {
|
||||
const stat = await fs.promises.stat(params.paths.jsonPath);
|
||||
const stat = await fs.promises.lstat(params.paths.jsonPath);
|
||||
if (!stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
const raw = await readJsonDurableQueueEntry<T>(params.paths.jsonPath);
|
||||
const raw = await readJsonDurableQueueEntry<T>(params.paths.jsonPath, {
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
const result = params.read ? await params.read(raw, params.paths.jsonPath) : { entry: raw };
|
||||
if (result.migrated) {
|
||||
await writeJsonDurableQueueEntry({
|
||||
@ -173,11 +235,11 @@ export async function loadPendingJsonDurableQueueEntries<T>(
|
||||
}
|
||||
const filePath = path.join(options.queueDir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
const stat = await fs.promises.lstat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const raw = await readJsonDurableQueueEntry<T>(filePath);
|
||||
const raw = await readJsonDurableQueueEntry<T>(filePath, { maxBytes: options.maxBytes });
|
||||
const result = options.read ? await options.read(raw, filePath) : { entry: raw };
|
||||
if (result.migrated) {
|
||||
await writeJsonDurableQueueEntry({
|
||||
|
||||
10
src/json.ts
10
src/json.ts
@ -3,7 +3,7 @@ import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readRegularFile, readRegularFileSync } from "./regular-file.js";
|
||||
import { openRootFileSync, type RootFileOpenFailure } from "./root-file.js";
|
||||
import { writeTextAtomic } from "./text-atomic.js";
|
||||
import { writeTextAtomic, type WriteTextAtomicOptions } from "./text-atomic.js";
|
||||
|
||||
const JSON_FILE_MODE = 0o600;
|
||||
const JSON_DIR_MODE = 0o700;
|
||||
@ -289,15 +289,21 @@ export function readJsonSync<T = unknown>(filePath: string): T {
|
||||
}
|
||||
}
|
||||
|
||||
export type WriteJsonOptions = Pick<
|
||||
WriteTextAtomicOptions,
|
||||
"dirMode" | "durable" | "mode" | "trailingNewline"
|
||||
>;
|
||||
|
||||
export async function writeJson(
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
options?: { mode?: number; trailingNewline?: boolean; dirMode?: number },
|
||||
options?: WriteJsonOptions,
|
||||
) {
|
||||
const text = JSON.stringify(value, null, 2);
|
||||
await writeTextAtomic(filePath, text, {
|
||||
mode: options?.mode,
|
||||
dirMode: options?.dirMode,
|
||||
trailingNewline: options?.trailingNewline,
|
||||
durable: options?.durable,
|
||||
});
|
||||
}
|
||||
|
||||
@ -84,8 +84,10 @@ function resolveRootRealSync(rootDir: string): string | null {
|
||||
function resolveCandidateCanonicalSync(
|
||||
filePath: string,
|
||||
): { exists: true; canonicalPath: string; isFile: boolean } | { exists: false; canonicalPath: string } {
|
||||
let sawExistingLeaf = false;
|
||||
try {
|
||||
const stat = fsSync.lstatSync(filePath);
|
||||
sawExistingLeaf = true;
|
||||
return {
|
||||
exists: true,
|
||||
canonicalPath: fsSync.realpathSync(filePath),
|
||||
@ -96,6 +98,11 @@ function resolveCandidateCanonicalSync(
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (sawExistingLeaf) {
|
||||
// lstat succeeded but realpath failed: this is an existing dangling
|
||||
// symlink, not a missing path callers may safely create through.
|
||||
throw new FsSafeError("symlink", "local roots candidate is a dangling symlink");
|
||||
}
|
||||
|
||||
let cursor = filePath;
|
||||
const missingSegments: string[] = [];
|
||||
@ -115,6 +122,8 @@ function resolveCandidateCanonicalSync(
|
||||
};
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
// Existing ancestors that cannot be canonicalized are symlink/error
|
||||
// terrain; do not reconstruct a trusted missing path through them.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@ -175,12 +184,17 @@ export async function readLocalFileFromRoots(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await scopedRoot.read(relativePath, {
|
||||
const readOptions: Parameters<typeof scopedRoot.read>[1] = {
|
||||
hardlinks: options.hardlinks,
|
||||
maxBytes: options.maxBytes,
|
||||
nonBlockingRead: options.nonBlockingRead,
|
||||
symlinks: options.symlinks,
|
||||
});
|
||||
};
|
||||
// Leave maxBytes absent when the caller omits it so Root's own default
|
||||
// cap remains in force instead of being overwritten by undefined.
|
||||
if (options.maxBytes !== undefined) {
|
||||
readOptions.maxBytes = options.maxBytes;
|
||||
}
|
||||
const result = await scopedRoot.read(relativePath, readOptions);
|
||||
return { ...result, root: scopedRoot.rootReal };
|
||||
} catch {
|
||||
continue;
|
||||
|
||||
25
src/lock-config.ts
Normal file
25
src/lock-config.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { SidecarLockRetryOptions, SidecarLockStaleRecovery } from "./sidecar-lock.js";
|
||||
|
||||
export type FsSafeLockConfig = {
|
||||
staleRecovery: SidecarLockStaleRecovery;
|
||||
staleMs?: number;
|
||||
timeoutMs?: number;
|
||||
retry?: SidecarLockRetryOptions;
|
||||
};
|
||||
|
||||
const DEFAULT_LOCK_CONFIG: FsSafeLockConfig = {
|
||||
staleRecovery: "fail-closed",
|
||||
};
|
||||
|
||||
let lockConfig: FsSafeLockConfig = { ...DEFAULT_LOCK_CONFIG };
|
||||
|
||||
export function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void {
|
||||
// Process defaults only fill lock options after a caller explicitly enables
|
||||
// locking for a resource; this must never turn sidecar locks on globally.
|
||||
lockConfig = { ...lockConfig, ...config };
|
||||
}
|
||||
|
||||
export function getFsSafeLockConfig(): FsSafeLockConfig {
|
||||
return { ...lockConfig, retry: lockConfig.retry ? { ...lockConfig.retry } : undefined };
|
||||
}
|
||||
|
||||
260
src/move-path.ts
260
src/move-path.ts
@ -1,13 +1,259 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { guardedRename, guardedRm } from "./guarded-mutation.js";
|
||||
import { guardedRename } from "./guarded-mutation.js";
|
||||
|
||||
export type MovePathWithCopyFallbackOptions = {
|
||||
from: string;
|
||||
sourceHardlinks?: "allow" | "reject";
|
||||
to: string;
|
||||
};
|
||||
|
||||
type EntryIdentity = {
|
||||
ctimeMs: number;
|
||||
dev: number;
|
||||
ino: number;
|
||||
mode: number;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type CopiedEntryManifest =
|
||||
| (EntryIdentity & {
|
||||
children: Array<{ name: string; manifest: CopiedEntryManifest }>;
|
||||
kind: "directory";
|
||||
})
|
||||
| (EntryIdentity & { kind: "leaf" });
|
||||
|
||||
type CleanupCopiedEntryResult = "removed" | "stale";
|
||||
|
||||
function entryIdentity(stat: {
|
||||
ctimeMs: number;
|
||||
dev: number;
|
||||
ino: number;
|
||||
mode: number;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
}): EntryIdentity {
|
||||
return {
|
||||
ctimeMs: stat.ctimeMs,
|
||||
dev: stat.dev,
|
||||
ino: stat.ino,
|
||||
mode: stat.mode,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
};
|
||||
}
|
||||
|
||||
function sameIdentity(a: EntryIdentity, b: EntryIdentity): boolean {
|
||||
return (
|
||||
a.dev === b.dev &&
|
||||
a.ino === b.ino &&
|
||||
a.mode === b.mode &&
|
||||
a.size === b.size &&
|
||||
a.mtimeMs === b.mtimeMs &&
|
||||
a.ctimeMs === b.ctimeMs
|
||||
);
|
||||
}
|
||||
|
||||
function sameDirectoryNode(a: EntryIdentity, b: EntryIdentity): boolean {
|
||||
return a.dev === b.dev && a.ino === b.ino;
|
||||
}
|
||||
|
||||
function modeBits(mode: number): number {
|
||||
return mode & 0o777;
|
||||
}
|
||||
|
||||
function sourceChangedError(sourcePath: string): Error {
|
||||
return Object.assign(new Error(`Source changed during move fallback: ${sourcePath}`), {
|
||||
code: "ESTALE",
|
||||
});
|
||||
}
|
||||
|
||||
async function assertSourceStillMatches(
|
||||
sourcePath: string,
|
||||
identity: EntryIdentity,
|
||||
): Promise<void> {
|
||||
if (!sameIdentity(identity, entryIdentity(await fs.lstat(sourcePath)))) {
|
||||
throw sourceChangedError(sourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
function regularReadFlags(): number {
|
||||
return (
|
||||
fsConstants.O_RDONLY |
|
||||
(typeof fsConstants.O_NOFOLLOW === "number" && process.platform !== "win32"
|
||||
? fsConstants.O_NOFOLLOW
|
||||
: 0)
|
||||
);
|
||||
}
|
||||
|
||||
async function writeAll(handle: FileHandle, buffer: Buffer, bytesRead: number): Promise<void> {
|
||||
let offset = 0;
|
||||
while (offset < bytesRead) {
|
||||
const { bytesWritten } = await handle.write(buffer, offset, bytesRead - offset);
|
||||
offset += bytesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRegularFilePinned(params: {
|
||||
from: string;
|
||||
identity: EntryIdentity;
|
||||
mode: number;
|
||||
to: string;
|
||||
}): Promise<void> {
|
||||
let destinationCreated = false;
|
||||
let sourceHandle: FileHandle;
|
||||
try {
|
||||
sourceHandle = await fs.open(params.from, regularReadFlags());
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code;
|
||||
if (code === "ELOOP" || code === "ENOENT" || code === "ENOTDIR") {
|
||||
throw sourceChangedError(params.from);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const openedStat = await sourceHandle.stat();
|
||||
if (!openedStat.isFile() || !sameIdentity(params.identity, entryIdentity(openedStat))) {
|
||||
throw sourceChangedError(params.from);
|
||||
}
|
||||
|
||||
const destinationHandle = await fs.open(
|
||||
params.to,
|
||||
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL,
|
||||
modeBits(params.mode) || 0o666,
|
||||
);
|
||||
destinationCreated = true;
|
||||
try {
|
||||
const scratch = Buffer.allocUnsafe(64 * 1024);
|
||||
while (true) {
|
||||
const { bytesRead } = await sourceHandle.read(scratch, 0, scratch.length, null);
|
||||
if (bytesRead === 0) {
|
||||
break;
|
||||
}
|
||||
await writeAll(destinationHandle, scratch, bytesRead);
|
||||
}
|
||||
} finally {
|
||||
await destinationHandle.close();
|
||||
}
|
||||
|
||||
// Re-check the opened handle before the staged tree can be committed. If
|
||||
// the source changed while we copied, the caller should retry the move.
|
||||
if (!sameIdentity(params.identity, entryIdentity(await sourceHandle.stat()))) {
|
||||
throw sourceChangedError(params.from);
|
||||
}
|
||||
await fs.chmod(params.to, modeBits(params.mode)).catch(() => undefined);
|
||||
} catch (error) {
|
||||
if (destinationCreated) {
|
||||
await fs.rm(params.to, { force: true }).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await sourceHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyEntryWithManifest(
|
||||
from: string,
|
||||
to: string,
|
||||
options: { sourceHardlinks: "allow" | "reject" },
|
||||
): Promise<CopiedEntryManifest> {
|
||||
const sourceStat = await fs.lstat(from);
|
||||
const identity = entryIdentity(sourceStat);
|
||||
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
await fs.symlink(await fs.readlink(from), to);
|
||||
// readlink() is path-based; verify the symlink we copied is still the one
|
||||
// we inspected before letting the staged destination become visible.
|
||||
await assertSourceStillMatches(from, identity);
|
||||
return { ...identity, kind: "leaf" };
|
||||
}
|
||||
|
||||
if (sourceStat.isDirectory()) {
|
||||
await fs.mkdir(to, { mode: modeBits(sourceStat.mode) || 0o755 });
|
||||
const children: Array<{ name: string; manifest: CopiedEntryManifest }> = [];
|
||||
for (const child of await fs.readdir(from)) {
|
||||
children.push({
|
||||
name: child,
|
||||
manifest: await copyEntryWithManifest(path.join(from, child), path.join(to, child), options),
|
||||
});
|
||||
}
|
||||
// Directory traversal is path-based in Node. Treat a changed parent as a
|
||||
// stale move before committing so swapped-in outside trees are not imported.
|
||||
await assertSourceStillMatches(from, identity);
|
||||
// mkdir() honors process umask. Restore the source mode before commit so
|
||||
// EXDEV fallback preserves directory permissions like fs.cp did.
|
||||
await fs.chmod(to, modeBits(sourceStat.mode));
|
||||
return { ...identity, children, kind: "directory" };
|
||||
}
|
||||
|
||||
if (!sourceStat.isFile()) {
|
||||
throw new Error(`Refusing to move non-file path with copy fallback: ${from}`);
|
||||
}
|
||||
if (options.sourceHardlinks === "reject" && sourceStat.nlink > 1) {
|
||||
throw new Error(`Refusing to move hardlinked file with copy fallback: ${from}`);
|
||||
}
|
||||
|
||||
await copyRegularFilePinned({ from, identity, mode: sourceStat.mode, to });
|
||||
return { ...identity, kind: "leaf" };
|
||||
}
|
||||
|
||||
function mergeCleanupResults(
|
||||
a: CleanupCopiedEntryResult,
|
||||
b: CleanupCopiedEntryResult,
|
||||
): CleanupCopiedEntryResult {
|
||||
return a === "stale" || b === "stale" ? "stale" : "removed";
|
||||
}
|
||||
|
||||
async function cleanupCopiedEntry(
|
||||
sourcePath: string,
|
||||
manifest: CopiedEntryManifest,
|
||||
): Promise<CleanupCopiedEntryResult> {
|
||||
let currentStat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
currentStat = await fs.lstat(sourcePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") {
|
||||
return "removed";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (manifest.kind === "directory") {
|
||||
if (!currentStat.isDirectory() || !sameDirectoryNode(manifest, entryIdentity(currentStat))) {
|
||||
return "stale";
|
||||
}
|
||||
// A same-inode directory can gain unrelated children after commit. Still
|
||||
// clean manifest children so the fallback does not duplicate copied files.
|
||||
let result: CleanupCopiedEntryResult = "removed";
|
||||
for (const child of manifest.children) {
|
||||
result = mergeCleanupResults(
|
||||
result,
|
||||
await cleanupCopiedEntry(path.join(sourcePath, child.name), child.manifest),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await fs.rmdir(sourcePath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code;
|
||||
if (code === "ENOTEMPTY" || code === "EEXIST") {
|
||||
return "stale";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!sameIdentity(manifest, entryIdentity(currentStat))) {
|
||||
return "stale";
|
||||
}
|
||||
await fs.unlink(sourcePath);
|
||||
return "removed";
|
||||
}
|
||||
|
||||
export async function movePathWithCopyFallback(
|
||||
options: MovePathWithCopyFallbackOptions,
|
||||
): Promise<void> {
|
||||
@ -22,14 +268,14 @@ export async function movePathWithCopyFallback(
|
||||
const targetDir = path.dirname(path.resolve(options.to));
|
||||
const staged = path.join(targetDir, `.fs-safe-move-${process.pid}-${randomUUID()}.tmp`);
|
||||
try {
|
||||
await fs.cp(options.from, staged, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
dereference: false,
|
||||
const manifest = await copyEntryWithManifest(options.from, staged, {
|
||||
sourceHardlinks: options.sourceHardlinks ?? "allow",
|
||||
});
|
||||
await guardedRename({ from: staged, to: options.to });
|
||||
await guardedRm({ target: options.from, recursive: true, force: true, verifyAfter: false });
|
||||
const cleanupResult = await cleanupCopiedEntry(options.from, manifest);
|
||||
if (cleanupResult === "stale") {
|
||||
throw sourceChangedError(options.from);
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(staged, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
96
src/output.ts
Normal file
96
src/output.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import path from "node:path";
|
||||
import { FsSafeError } from "./errors.js";
|
||||
import { sanitizeUntrustedFileName } from "./filename.js";
|
||||
import { isPathInside } from "./path.js";
|
||||
import { root } from "./root.js";
|
||||
import { tempFile } from "./temp-target.js";
|
||||
|
||||
export type ExternalFileWriteOptions<T = void> = {
|
||||
rootDir: string;
|
||||
path: string;
|
||||
write: (filePath: string) => Promise<T>;
|
||||
maxBytes?: number;
|
||||
mode?: number;
|
||||
};
|
||||
|
||||
export type ExternalFileWriteResult<T = void> = {
|
||||
path: string;
|
||||
result: T;
|
||||
};
|
||||
|
||||
function tempFileNameForTarget(targetPath: string): string {
|
||||
return sanitizeUntrustedFileName(path.basename(targetPath), "output.bin");
|
||||
}
|
||||
|
||||
function ensureTrailingSep(value: string): string {
|
||||
return value.endsWith(path.sep) ? value : `${value}${path.sep}`;
|
||||
}
|
||||
|
||||
function toRootPathInput(params: {
|
||||
rootDir: string;
|
||||
rootReal: string;
|
||||
targetPath: string;
|
||||
}): string {
|
||||
if (!path.isAbsolute(params.targetPath)) {
|
||||
return params.targetPath;
|
||||
}
|
||||
|
||||
const absoluteTarget = path.resolve(params.targetPath);
|
||||
const rootDir = path.resolve(params.rootDir);
|
||||
if (isPathInside(ensureTrailingSep(rootDir), absoluteTarget)) {
|
||||
return path.relative(rootDir, absoluteTarget);
|
||||
}
|
||||
if (isPathInside(ensureTrailingSep(params.rootReal), absoluteTarget)) {
|
||||
return path.relative(params.rootReal, absoluteTarget);
|
||||
}
|
||||
return params.targetPath;
|
||||
}
|
||||
|
||||
function assertFileTargetPath(targetPath: string): void {
|
||||
const basename = path.basename(targetPath);
|
||||
if (
|
||||
!targetPath ||
|
||||
targetPath === "." ||
|
||||
targetPath.endsWith("/") ||
|
||||
targetPath.endsWith("\\") ||
|
||||
!basename ||
|
||||
basename === "." ||
|
||||
basename === ".."
|
||||
) {
|
||||
throw new FsSafeError("invalid-path", "target path must name a file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeExternalFileWithinRoot<T = void>(
|
||||
options: ExternalFileWriteOptions<T>,
|
||||
): Promise<ExternalFileWriteResult<T>> {
|
||||
const targetRoot = await root(options.rootDir);
|
||||
const requestedTargetPath = options.path;
|
||||
if (requestedTargetPath.length === 0) {
|
||||
throw new FsSafeError("invalid-path", "target path is required");
|
||||
}
|
||||
const targetPath = toRootPathInput({
|
||||
rootDir: targetRoot.rootDir,
|
||||
rootReal: targetRoot.rootReal,
|
||||
targetPath: requestedTargetPath,
|
||||
});
|
||||
assertFileTargetPath(targetPath);
|
||||
const finalPath = await targetRoot.resolve(targetPath);
|
||||
const staged = await tempFile({
|
||||
prefix: "fs-safe-output",
|
||||
fileName: tempFileNameForTarget(targetPath),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await options.write(staged.path);
|
||||
await targetRoot.copyIn(targetPath, staged.path, {
|
||||
maxBytes: options.maxBytes,
|
||||
mode: options.mode,
|
||||
mkdir: true,
|
||||
sourceHardlinks: "reject",
|
||||
});
|
||||
return { path: finalPath, result };
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,6 @@ import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
|
||||
|
||||
const NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]);
|
||||
const SYMLINK_OPEN_CODES = new Set(["ELOOP", "EINVAL", "ENOTSUP"]);
|
||||
const PARENT_SEGMENT_PREFIX = /^\.\.(?:[\\/]|$)/u;
|
||||
const POSIX_SEPARATOR_CHAR_CODE = 0x2f;
|
||||
|
||||
export function normalizeWindowsPathForComparison(input: string): string {
|
||||
@ -49,8 +48,9 @@ export function isPathInside(root: string, target: string): boolean {
|
||||
const rootForCompare = normalizeWindowsPathForComparison(path.win32.resolve(root));
|
||||
const targetForCompare = normalizeWindowsPathForComparison(path.win32.resolve(target));
|
||||
const relative = path.win32.relative(rootForCompare, targetForCompare);
|
||||
const firstSegment = relative.split(path.win32.sep)[0];
|
||||
return (
|
||||
relative === "" || (!PARENT_SEGMENT_PREFIX.test(relative) && !path.win32.isAbsolute(relative))
|
||||
relative === "" || (firstSegment !== ".." && !path.win32.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
@ -69,7 +69,8 @@ export function isPathInside(root: string, target: string): boolean {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const resolvedTarget = path.resolve(target);
|
||||
const relative = path.relative(resolvedRoot, resolvedTarget);
|
||||
return relative === "" || (!PARENT_SEGMENT_PREFIX.test(relative) && !path.isAbsolute(relative));
|
||||
const firstSegment = relative.split(path.posix.sep)[0];
|
||||
return relative === "" || (firstSegment !== ".." && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export function resolveSafeBaseDir(rootDir: string): string {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Stats } from "node:fs";
|
||||
import fsSync from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sameFileIdentity } from "./file-identity.js";
|
||||
@ -44,6 +45,54 @@ function resolveRegularFileReadFlags(): number {
|
||||
);
|
||||
}
|
||||
|
||||
async function readFileHandleBounded(params: {
|
||||
handle: FileHandle;
|
||||
filePath: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<Buffer> {
|
||||
if (params.maxBytes === undefined) {
|
||||
return await params.handle.readFile();
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
const scratch = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, params.maxBytes + 1)));
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { bytesRead } = await params.handle.read(scratch, 0, scratch.length, null);
|
||||
if (bytesRead === 0) {
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
total += bytesRead;
|
||||
if (total > params.maxBytes) {
|
||||
throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
|
||||
}
|
||||
chunks.push(Buffer.from(scratch.subarray(0, bytesRead)));
|
||||
}
|
||||
}
|
||||
|
||||
function readFileDescriptorBounded(params: {
|
||||
fd: number;
|
||||
filePath: string;
|
||||
maxBytes?: number;
|
||||
}): Buffer {
|
||||
if (params.maxBytes === undefined) {
|
||||
return fsSync.readFileSync(params.fd);
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
const scratch = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, params.maxBytes + 1)));
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const bytesRead = fsSync.readSync(params.fd, scratch, 0, scratch.length, null);
|
||||
if (bytesRead === 0) {
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
total += bytesRead;
|
||||
if (total > params.maxBytes) {
|
||||
throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
|
||||
}
|
||||
chunks.push(Buffer.from(scratch.subarray(0, bytesRead)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function statRegularFile(filePath: string): Promise<RegularFileStatResult> {
|
||||
let stat: Stats;
|
||||
try {
|
||||
@ -100,10 +149,13 @@ export async function readRegularFile(params: {
|
||||
if (params.maxBytes !== undefined && stat.size > params.maxBytes) {
|
||||
throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
|
||||
}
|
||||
const buffer = await handle.readFile();
|
||||
if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) {
|
||||
throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
|
||||
}
|
||||
// With a byte cap, avoid readFile(): a raced file growth would allocate
|
||||
// the oversized content before the post-read check could reject it.
|
||||
const buffer = await readFileHandleBounded({
|
||||
handle,
|
||||
filePath: params.filePath,
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
return { buffer, stat };
|
||||
} finally {
|
||||
await handle.close();
|
||||
@ -143,10 +195,13 @@ function readOpenedRegularFileSync(params: {
|
||||
if (params.maxBytes !== undefined && stat.size > params.maxBytes) {
|
||||
throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
|
||||
}
|
||||
const buffer = fsSync.readFileSync(params.fd);
|
||||
if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) {
|
||||
throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
|
||||
}
|
||||
// Keep capped sync reads incremental for the same reason as async reads:
|
||||
// readFileSync(fd) would buffer a raced oversized file before throwing.
|
||||
const buffer = readFileDescriptorBounded({
|
||||
fd: params.fd,
|
||||
filePath: params.filePath,
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
return { buffer, stat };
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import syncFs from "node:fs";
|
||||
import type { Stats } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { assertSafePathPrefix } from "./safe-path-segment.js";
|
||||
import { registerTempPathForExit } from "./temp-cleanup.js";
|
||||
import { serializePathWrite } from "./write-queue.js";
|
||||
|
||||
@ -249,7 +250,8 @@ function validateReplaceFilePath(filePath: string): void {
|
||||
|
||||
function buildReplaceTempPath(filePath: string, tempPrefix?: string): string {
|
||||
const dir = path.dirname(filePath);
|
||||
return path.join(dir, `${tempPrefix ?? ".fs-safe-replace"}.${process.pid}.${randomUUID()}.tmp`);
|
||||
const safePrefix = assertSafePathPrefix(tempPrefix ?? ".fs-safe-replace", { label: "atomic replace temp prefix" });
|
||||
return path.join(dir, `${safePrefix}.${process.pid}.${randomUUID()}.tmp`);
|
||||
}
|
||||
|
||||
async function resolveMode(options: ReplaceFileAtomicOptions): Promise<number> {
|
||||
|
||||
@ -1250,8 +1250,10 @@ async function resolvePinnedWriteTargetInRoot(
|
||||
throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
|
||||
}
|
||||
|
||||
// resolvePathInRoot already enforces isPathInside, so any actual escape
|
||||
// is rejected upstream.
|
||||
const relativeResolved = path.relative(rootReal, resolved);
|
||||
if (relativeResolved.startsWith("..") || path.isAbsolute(relativeResolved)) {
|
||||
if (path.isAbsolute(relativeResolved)) {
|
||||
throw new FsSafeError("outside-workspace", "file is outside workspace root");
|
||||
}
|
||||
const relativePosix = relativeResolved
|
||||
@ -1332,10 +1334,11 @@ async function resolvePinnedOperationPathInRoot(
|
||||
if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
|
||||
return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
|
||||
}
|
||||
const firstSegment = relativeResolved.split(path.sep)[0];
|
||||
if (
|
||||
relativeResolved === "" ||
|
||||
relativeResolved === "." ||
|
||||
relativeResolved.startsWith("..") ||
|
||||
firstSegment === ".." ||
|
||||
path.isAbsolute(relativeResolved)
|
||||
) {
|
||||
throw new FsSafeError("outside-workspace", "file is outside workspace root");
|
||||
|
||||
@ -57,3 +57,23 @@ export function sanitizeSafePathSegment(
|
||||
}
|
||||
return assertSafePathSegment(fallback, { ...options, label: "fallback path segment" });
|
||||
}
|
||||
|
||||
export function assertSafePathPrefix(
|
||||
prefix: string,
|
||||
options: SafePathSegmentOptions = {},
|
||||
): string {
|
||||
// Prefixes are often derived from safe filenames. Normalize harmless
|
||||
// filename characters first, but still reject real path-control bytes.
|
||||
if (prefix.includes("/") || prefix.includes("\\") || prefix.includes("\0")) {
|
||||
return assertSafePathSegment(prefix, {
|
||||
allowDotPrefix: true,
|
||||
...options,
|
||||
label: options.label ?? "path prefix",
|
||||
});
|
||||
}
|
||||
return assertSafePathSegment(prefix.replace(/[^A-Za-z0-9._-]+/g, "-"), {
|
||||
allowDotPrefix: true,
|
||||
...options,
|
||||
label: options.label ?? "path prefix",
|
||||
});
|
||||
}
|
||||
|
||||
@ -55,12 +55,26 @@ function readSecretFileOutcomeSync(
|
||||
};
|
||||
}
|
||||
|
||||
if (options.rejectSymlink && previewStat.isSymbolicLink()) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "symlink",
|
||||
message: `${label} file at ${resolvedPath} must not be a symlink.`,
|
||||
};
|
||||
if (previewStat.isSymbolicLink()) {
|
||||
if (!options.rejectSymlink) {
|
||||
try {
|
||||
previewStat = fs.statSync(resolvedPath);
|
||||
} catch (error) {
|
||||
const normalized = normalizeSecretReadError(error);
|
||||
return {
|
||||
ok: false,
|
||||
code: (error as NodeJS.ErrnoException).code === "ENOENT" ? "not-found" : "invalid-path",
|
||||
error: normalized,
|
||||
message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
code: "symlink",
|
||||
message: `${label} file at ${resolvedPath} must not be a symlink.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!previewStat.isFile()) {
|
||||
return {
|
||||
|
||||
@ -5,6 +5,7 @@ import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./director
|
||||
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
||||
import { sanitizeUntrustedFileName } from "./filename.js";
|
||||
import { root } from "./root.js";
|
||||
import { assertSafePathPrefix } from "./safe-path-segment.js";
|
||||
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
||||
import { registerTempPathForExit } from "./temp-cleanup.js";
|
||||
import { getFsSafeTestHooks } from "./test-hooks.js";
|
||||
@ -28,7 +29,10 @@ export type WriteSiblingTempFileResult<T> = {
|
||||
};
|
||||
|
||||
function buildTempPath(dir: string, tempPrefix?: string): string {
|
||||
return path.join(dir, `${tempPrefix ?? ".fs-safe-stream"}.${process.pid}.${randomUUID()}.tmp`);
|
||||
const safePrefix = assertSafePathPrefix(tempPrefix ?? ".fs-safe-stream", {
|
||||
label: "sibling temp prefix",
|
||||
});
|
||||
return path.join(dir, `${safePrefix}.${process.pid}.${randomUUID()}.tmp`);
|
||||
}
|
||||
|
||||
async function syncFileBestEffort(filePath: string): Promise<void> {
|
||||
@ -115,11 +119,14 @@ function buildSiblingTempPath(params: {
|
||||
tempPrefix: string;
|
||||
}): string {
|
||||
const id = crypto.randomUUID();
|
||||
const safePrefix = assertSafePathPrefix(params.tempPrefix, {
|
||||
label: "sibling temp prefix",
|
||||
});
|
||||
const safeTail = sanitizeUntrustedFileName(
|
||||
path.basename(params.targetPath),
|
||||
params.fallbackFileName,
|
||||
);
|
||||
return path.join(path.dirname(params.targetPath), `${params.tempPrefix}${id}-${safeTail}.part`);
|
||||
return path.join(path.dirname(params.targetPath), `${safePrefix}${id}-${safeTail}.part`);
|
||||
}
|
||||
|
||||
export async function writeViaSiblingTempPath(params: {
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import fsSync from "node:fs";
|
||||
import type { Stats } from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sameFileIdentity } from "./file-identity.js";
|
||||
|
||||
export type SidecarLockRetryOptions = {
|
||||
retries?: number;
|
||||
@ -11,12 +13,15 @@ export type SidecarLockRetryOptions = {
|
||||
randomize?: boolean;
|
||||
};
|
||||
|
||||
export type SidecarLockStaleRecovery = "fail-closed";
|
||||
|
||||
export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
||||
targetPath: string;
|
||||
lockPath?: string;
|
||||
staleMs: number;
|
||||
timeoutMs?: number;
|
||||
retry?: SidecarLockRetryOptions;
|
||||
staleRecovery?: SidecarLockStaleRecovery;
|
||||
allowReentrant?: boolean;
|
||||
payload: () => TPayload | Promise<TPayload>;
|
||||
shouldReclaim?: (params: {
|
||||
@ -56,6 +61,7 @@ type HeldLock = {
|
||||
count: number;
|
||||
handle: FileHandle;
|
||||
lockPath: string;
|
||||
snapshot: LockSnapshot;
|
||||
acquiredAt: number;
|
||||
metadata: Record<string, unknown>;
|
||||
releasePromise?: Promise<void>;
|
||||
@ -88,17 +94,78 @@ function resolveManagerState(key: string): SidecarLockManagerState {
|
||||
return state;
|
||||
}
|
||||
|
||||
async function readJsonPayload(lockPath: string): Promise<Record<string, unknown> | null> {
|
||||
type LockSnapshot = {
|
||||
raw?: string;
|
||||
payload: Record<string, unknown> | null;
|
||||
stat?: Stats;
|
||||
};
|
||||
|
||||
async function readLockSnapshot(lockPath: string): Promise<LockSnapshot | null> {
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as unknown;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
const stat = await fs.lstat(lockPath);
|
||||
const raw = await fs.readFile(lockPath, "utf8");
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const payload =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
return { raw, payload, stat };
|
||||
} catch {
|
||||
return { raw, payload: null, stat };
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotMatches(current: LockSnapshot, observed: LockSnapshot): boolean {
|
||||
if (observed.stat && current.stat && !sameFileIdentity(observed.stat, current.stat)) {
|
||||
return false;
|
||||
}
|
||||
if (observed.raw !== undefined) {
|
||||
return current.raw === observed.raw;
|
||||
}
|
||||
return observed.stat !== undefined && current.stat !== undefined;
|
||||
}
|
||||
|
||||
async function removeLockIfUnchanged(
|
||||
lockPath: string,
|
||||
observed: LockSnapshot | null,
|
||||
): Promise<boolean> {
|
||||
const current = await readLockSnapshot(lockPath);
|
||||
if (!current || !observed) {
|
||||
return false;
|
||||
}
|
||||
if (!snapshotMatches(current, observed)) {
|
||||
// The lock changed after we decided it was stale. Leave the fresh holder's
|
||||
// file alone; deleting by path here would break mutual exclusion.
|
||||
return false;
|
||||
}
|
||||
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function lockSnapshotStillPresent(
|
||||
lockPath: string,
|
||||
observed: LockSnapshot | null,
|
||||
): Promise<boolean> {
|
||||
const current = await readLockSnapshot(lockPath);
|
||||
return !!current && !!observed && snapshotMatches(current, observed);
|
||||
}
|
||||
|
||||
function snapshotMatchesSync(lockPath: string, observed: LockSnapshot): boolean {
|
||||
try {
|
||||
const stat = fsSync.lstatSync(lockPath);
|
||||
if (observed.stat && !sameFileIdentity(observed.stat, stat)) {
|
||||
return false;
|
||||
}
|
||||
return observed.raw === undefined || fsSync.readFileSync(lockPath, "utf8") === observed.raw;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveNormalizedTargetPath(targetPath: string): Promise<string> {
|
||||
const resolved = path.resolve(targetPath);
|
||||
const dir = path.dirname(resolved);
|
||||
@ -142,7 +209,9 @@ function releaseAllLocksSync(state: SidecarLockManagerState): void {
|
||||
for (const [normalizedTargetPath, held] of state.held) {
|
||||
void held.handle.close().catch(() => undefined);
|
||||
try {
|
||||
fsSync.rmSync(held.lockPath, { force: true });
|
||||
if (snapshotMatchesSync(held.lockPath, held.snapshot)) {
|
||||
fsSync.rmSync(held.lockPath, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort process-exit cleanup.
|
||||
}
|
||||
@ -175,7 +244,7 @@ async function releaseHeldLock(
|
||||
state.held.delete(normalizedTargetPath);
|
||||
held.releasePromise = (async () => {
|
||||
await held.handle.close().catch(() => undefined);
|
||||
await fs.rm(held.lockPath, { force: true }).catch(() => undefined);
|
||||
await removeLockIfUnchanged(held.lockPath, held.snapshot);
|
||||
})();
|
||||
try {
|
||||
await held.releasePromise;
|
||||
@ -222,15 +291,19 @@ export function createSidecarLockManager(key: string) {
|
||||
let handle: FileHandle | null = null;
|
||||
try {
|
||||
handle = await fs.open(lockPath, "wx");
|
||||
const payload = await options.payload();
|
||||
const raw = `${JSON.stringify(payload, null, 2)}\n`;
|
||||
await handle.writeFile(raw, "utf8");
|
||||
const snapshot = { raw, payload, stat: await handle.stat() };
|
||||
const createdHeld: HeldLock = {
|
||||
count: 1,
|
||||
handle,
|
||||
lockPath,
|
||||
snapshot,
|
||||
acquiredAt: Date.now(),
|
||||
metadata: options.metadata ?? {},
|
||||
};
|
||||
state.held.set(normalizedTargetPath, createdHeld);
|
||||
await handle.writeFile(`${JSON.stringify(await options.payload(), null, 2)}\n`, "utf8");
|
||||
const release = () =>
|
||||
releaseHeldLock(state, normalizedTargetPath, createdHeld).then(() => undefined);
|
||||
return {
|
||||
@ -241,31 +314,57 @@ export function createSidecarLockManager(key: string) {
|
||||
};
|
||||
} catch (err) {
|
||||
if (handle) {
|
||||
const failedSnapshot: LockSnapshot = { payload: null };
|
||||
try {
|
||||
failedSnapshot.stat = await handle.stat();
|
||||
} catch {
|
||||
// Best-effort cleanup of a failed exclusive create.
|
||||
}
|
||||
const current = state.held.get(normalizedTargetPath);
|
||||
if (current?.handle === handle) {
|
||||
state.held.delete(normalizedTargetPath);
|
||||
}
|
||||
await handle.close().catch(() => undefined);
|
||||
// If payload serialization/write fails, the file may be empty or
|
||||
// partial JSON, so remove while our exclusive handle is still open.
|
||||
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
||||
await handle.close().catch(() => undefined);
|
||||
// Windows can refuse removing an open file; retry after close but
|
||||
// only if the path still points at the file identity we created.
|
||||
await removeLockIfUnchanged(lockPath, failedSnapshot);
|
||||
}
|
||||
if ((err as { code?: unknown }).code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const payload = await readJsonPayload(lockPath);
|
||||
const snapshot = await readLockSnapshot(lockPath);
|
||||
if (!snapshot) {
|
||||
continue;
|
||||
}
|
||||
const shouldReclaim = options.shouldReclaim ?? defaultShouldReclaim;
|
||||
if (
|
||||
await shouldReclaim({
|
||||
lockPath,
|
||||
normalizedTargetPath,
|
||||
payload,
|
||||
payload: snapshot?.payload ?? null,
|
||||
staleMs: options.staleMs,
|
||||
nowMs,
|
||||
heldByThisProcess: state.held.has(normalizedTargetPath),
|
||||
})
|
||||
) {
|
||||
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
||||
continue;
|
||||
if (!(await lockSnapshotStillPresent(lockPath, snapshot))) {
|
||||
continue;
|
||||
}
|
||||
// Node exposes only path-based unlink/rename here. A stale-lock
|
||||
// reclaimer cannot bind the delete to the file it inspected, so a
|
||||
// concurrent release+fresh-acquire could otherwise lose its lock.
|
||||
// Fail closed and let callers choose a higher-level recovery path.
|
||||
if ((options.staleRecovery ?? "fail-closed") === "fail-closed") {
|
||||
throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
|
||||
code: "file_lock_stale",
|
||||
lockPath,
|
||||
normalizedTargetPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sanitizeSafePathSegment } from "./safe-path-segment.js";
|
||||
import { assertSafePathSegment, sanitizeSafePathSegment } from "./safe-path-segment.js";
|
||||
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
||||
import { registerTempPathForExit } from "./temp-cleanup.js";
|
||||
|
||||
@ -49,7 +49,9 @@ export function buildRandomTempFilePath(params: {
|
||||
typeof nowCandidate === "number" && Number.isFinite(nowCandidate)
|
||||
? Math.trunc(nowCandidate)
|
||||
: Date.now();
|
||||
const uuid = params.uuid?.trim() || crypto.randomUUID();
|
||||
const uuid = params.uuid
|
||||
? assertSafePathSegment(params.uuid.trim(), { label: "temp uuid" })
|
||||
: crypto.randomUUID();
|
||||
return path.join(rootDir, `${prefix}-${now}-${uuid}${extension}`);
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,13 @@ export type WriteTextAtomicOptions = {
|
||||
mode?: number;
|
||||
dirMode?: number;
|
||||
trailingNewline?: boolean;
|
||||
/**
|
||||
* When false, skip the temp-file and parent-directory fsync calls while
|
||||
* preserving the temp-file replace/rename behavior.
|
||||
*
|
||||
* Defaults to true.
|
||||
*/
|
||||
durable?: boolean;
|
||||
};
|
||||
|
||||
export async function writeTextAtomic(
|
||||
@ -12,13 +19,14 @@ export async function writeTextAtomic(
|
||||
options?: WriteTextAtomicOptions,
|
||||
): Promise<void> {
|
||||
const payload = options?.trailingNewline && !content.endsWith("\n") ? `${content}\n` : content;
|
||||
const durable = options?.durable ?? true;
|
||||
await replaceFileAtomic({
|
||||
filePath,
|
||||
content: payload,
|
||||
mode: options?.mode ?? 0o600,
|
||||
dirMode: options?.dirMode ?? (0o777 & ~process.umask()),
|
||||
copyFallbackOnPermissionError: true,
|
||||
syncTempFile: true,
|
||||
syncParentDir: true,
|
||||
syncTempFile: durable,
|
||||
syncParentDir: durable,
|
||||
});
|
||||
}
|
||||
|
||||
185
test/absolute-directory.test.ts
Normal file
185
test/absolute-directory.test.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ensureAbsoluteDirectory } from "../src/absolute-path.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function tempRoot(prefix: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })));
|
||||
});
|
||||
|
||||
describe("ensureAbsoluteDirectory", () => {
|
||||
it("safely creates missing absolute directory parents from a real ancestor", async () => {
|
||||
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-"));
|
||||
const targetDir = path.join(root, "nested", "deeper");
|
||||
|
||||
await expect(
|
||||
ensureAbsoluteDirectory(targetDir, { scopeLabel: "output directory", mode: 0o700 }),
|
||||
).resolves.toEqual({ ok: true, path: targetDir });
|
||||
expect((await fs.stat(targetDir)).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects relative absolute-directory inputs", async () => {
|
||||
await expect(
|
||||
ensureAbsoluteDirectory(path.join("..", "..", "..", "escape"), {
|
||||
scopeLabel: "output directory",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, code: "invalid-path" });
|
||||
});
|
||||
|
||||
it("rejects absolute directory creation when the existing target is not a directory", async () => {
|
||||
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-file-"));
|
||||
const targetPath = path.join(root, "file.txt");
|
||||
await fs.writeFile(targetPath, "file", "utf8");
|
||||
|
||||
await expect(
|
||||
ensureAbsoluteDirectory(targetPath, { scopeLabel: "output directory" }),
|
||||
).resolves.toMatchObject({ ok: false, code: "not-file" });
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects absolute directory creation through symlinked existing segments",
|
||||
async () => {
|
||||
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-link-"));
|
||||
const outside = await fs.realpath(await tempRoot("fs-safe-absolute-dir-outside-"));
|
||||
const linkDir = path.join(root, "link");
|
||||
await fs.symlink(outside, linkDir);
|
||||
|
||||
await expect(
|
||||
ensureAbsoluteDirectory(path.join(linkDir, "nested"), {
|
||||
scopeLabel: "output directory",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, code: "symlink" });
|
||||
await expect(fs.readdir(outside)).resolves.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects symlinked parents even when the requested suffix already exists",
|
||||
async () => {
|
||||
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-link-existing-"));
|
||||
const outside = await fs.realpath(
|
||||
await tempRoot("fs-safe-absolute-dir-link-existing-outside-"),
|
||||
);
|
||||
const existing = path.join(outside, "existing");
|
||||
const linkDir = path.join(root, "link");
|
||||
await fs.mkdir(existing);
|
||||
await fs.symlink(outside, linkDir);
|
||||
|
||||
await expect(
|
||||
ensureAbsoluteDirectory(path.join(linkDir, "existing", "new"), {
|
||||
scopeLabel: "output directory",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, code: "symlink" });
|
||||
await expect(fs.stat(path.join(existing, "new"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it("returns a policy failure when an intermediate component is a file", async () => {
|
||||
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-file-component-"));
|
||||
const filePath = path.join(root, "file");
|
||||
await fs.writeFile(filePath, "file", "utf8");
|
||||
|
||||
await expect(
|
||||
ensureAbsoluteDirectory(path.join(filePath, "child"), {
|
||||
scopeLabel: "output directory",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, code: "not-file" });
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects absolute directory creation when an existing parent is swapped before mkdir",
|
||||
async () => {
|
||||
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-race-"));
|
||||
const outside = await fs.realpath(await tempRoot("fs-safe-absolute-dir-race-outside-"));
|
||||
const parentDir = path.join(root, "parent");
|
||||
const targetDir = path.join(parentDir, "child");
|
||||
await fs.mkdir(parentDir);
|
||||
|
||||
const realLstat = fs.lstat.bind(fs);
|
||||
let swapped = false;
|
||||
const lstatSpy = vi.spyOn(fs, "lstat").mockImplementation(async (...args) => {
|
||||
const candidate = String(args[0]);
|
||||
if (!swapped && candidate === targetDir) {
|
||||
swapped = true;
|
||||
await fs.rename(parentDir, `${parentDir}-real`);
|
||||
await fs.symlink(outside, parentDir, "dir");
|
||||
}
|
||||
return await realLstat(...args);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
ensureAbsoluteDirectory(targetDir, { scopeLabel: "output directory" }),
|
||||
).resolves.toMatchObject({ ok: false, code: "symlink" });
|
||||
} finally {
|
||||
lstatSpy.mockRestore();
|
||||
}
|
||||
|
||||
await expect(fs.stat(path.join(outside, "child"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects absolute directory creation when the existing target changes before return",
|
||||
async () => {
|
||||
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-target-race-"));
|
||||
const outside = await fs.realpath(
|
||||
await tempRoot("fs-safe-absolute-dir-target-race-outside-"),
|
||||
);
|
||||
const targetDir = path.join(root, "target");
|
||||
await fs.mkdir(targetDir);
|
||||
|
||||
const realRealpath = fs.realpath.bind(fs);
|
||||
let swapped = false;
|
||||
const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (...args) => {
|
||||
const candidate = String(args[0]);
|
||||
if (!swapped && candidate === targetDir) {
|
||||
swapped = true;
|
||||
const resolved = await realRealpath(...args);
|
||||
await fs.rename(targetDir, `${targetDir}-real`);
|
||||
await fs.symlink(outside, targetDir, "dir");
|
||||
return resolved;
|
||||
}
|
||||
return await realRealpath(...args);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
ensureAbsoluteDirectory(targetDir, { scopeLabel: "output directory" }),
|
||||
).resolves.toMatchObject({ ok: false, code: "symlink" });
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rethrows operational absolute directory creation failures", async () => {
|
||||
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-io-"));
|
||||
const targetDir = path.join(root, "nested");
|
||||
const realMkdir = fs.mkdir.bind(fs);
|
||||
const mkdirSpy = vi.spyOn(fs, "mkdir").mockImplementation(async (...args) => {
|
||||
if (String(args[0]) === targetDir) {
|
||||
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
|
||||
}
|
||||
return await realMkdir(...args);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
ensureAbsoluteDirectory(targetDir, { scopeLabel: "output directory" }),
|
||||
).rejects.toMatchObject({ code: "EACCES" });
|
||||
} finally {
|
||||
mkdirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -73,7 +73,12 @@ describe("additional helper boundary bypass attempts", () => {
|
||||
it("sanitizes temp file names and keeps temp file helpers inside their created directory", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-temp");
|
||||
expect(sanitizeTempFileName("../../evil.txt")).toBe("evil.txt");
|
||||
expect(sanitizeTempFileName("..\\evil.txt")).toBe("..-evil.txt");
|
||||
if (process.platform !== "win32") {
|
||||
// On windows "\" is a reserved path separator and cannot appear in a
|
||||
// filename, so this case only exercises the posix sanitizer where "\"
|
||||
// is a literal name character that needs neutralizing.
|
||||
expect(sanitizeTempFileName("..\\evil.txt")).toBe("..-evil.txt");
|
||||
}
|
||||
expect(sanitizeTempFileName("\u0000../evil.txt")).toBe("evil.txt");
|
||||
|
||||
const target = await tempFile({ rootDir: layout.base, prefix: "../../prefix", fileName: "../../evil.txt" });
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { realpathSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
@ -242,8 +243,11 @@ describe("path helpers", () => {
|
||||
expect(isSymlinkOpenError(Object.assign(new Error("x"), { code: "ELOOP" }))).toBe(true);
|
||||
expect(isPathInside(root, file)).toBe(true);
|
||||
expect(resolveSafeBaseDir(root)).toBe(`${path.resolve(root)}${path.sep}`);
|
||||
expect(safeRealpathSync(file, cache)).toBe(await fs.realpath(file));
|
||||
expect(safeRealpathSync(file, cache)).toBe(await fs.realpath(file));
|
||||
// Use the sync realpath to compare against safeRealpathSync. On windows
|
||||
// fs.realpathSync and fs.realpath (async) sometimes disagree on 8.3
|
||||
// short-name canonicalization (e.g. "RUNNER~1" vs "runneradmin").
|
||||
expect(safeRealpathSync(file, cache)).toBe(realpathSync(file));
|
||||
expect(safeRealpathSync(file, cache)).toBe(realpathSync(file));
|
||||
expect(safeRealpathSync(path.join(root, "missing"), cache)).toBeNull();
|
||||
expect(isPathInsideWithRealpath(root, file, { cache })).toBe(true);
|
||||
expect(isPathInsideWithRealpath(root, path.join(root, "missing"), { requireRealpath: false }))
|
||||
@ -457,7 +461,7 @@ describe("URL, install, and local-root helpers", () => {
|
||||
label: "media roots",
|
||||
requireFile: true,
|
||||
}),
|
||||
).toMatchObject({ path: await fs.realpath(file) });
|
||||
).toMatchObject({ path: realpathSync(file) });
|
||||
expect(() =>
|
||||
resolveLocalPathFromRootsSync({
|
||||
filePath: "bad\0path",
|
||||
@ -788,7 +792,7 @@ describe("temporary workspace and symlink parent helpers", () => {
|
||||
});
|
||||
|
||||
describe("file stores and private stores", () => {
|
||||
it("writes, streams, copies, reads, removes, and prunes file-store entries", async () => {
|
||||
it.skipIf(process.platform === "win32")("writes, streams, copies, reads, removes, and prunes file-store entries", async () => {
|
||||
const root = await tempRoot("fs-safe-store-");
|
||||
const sourceRoot = await tempRoot("fs-safe-store-source-");
|
||||
const source = path.join(sourceRoot, "source.txt");
|
||||
@ -828,7 +832,7 @@ describe("file stores and private stores", () => {
|
||||
await expect(fs.stat(old)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("covers private file store mode", async () => {
|
||||
it.skipIf(process.platform === "win32")("covers private file store mode", async () => {
|
||||
const root = await tempRoot("fs-safe-private-store-");
|
||||
const store = fileStore({ rootDir: root, private: true });
|
||||
|
||||
|
||||
@ -79,11 +79,13 @@ describe("archive extraction", () => {
|
||||
|
||||
const zip = new JSZip();
|
||||
zip.file("package/hello.txt", "hi");
|
||||
zip.file("package/my file.txt", "space");
|
||||
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
|
||||
|
||||
await extractArchive({ archivePath, destDir, timeoutMs: 15_000 });
|
||||
const packageDir = await resolvePackedRootDir(destDir);
|
||||
await expect(fs.readFile(path.join(packageDir, "hello.txt"), "utf8")).resolves.toBe("hi");
|
||||
await expect(fs.readFile(path.join(packageDir, "my file.txt"), "utf8")).resolves.toBe("space");
|
||||
});
|
||||
|
||||
it("does not truncate existing destination files when zip extraction fails", async () => {
|
||||
|
||||
@ -121,9 +121,6 @@ describe("home directory helpers", () => {
|
||||
expect(resolveHomeRelativePath("~/state", { env })).toBe(path.resolve("/configured/state"));
|
||||
expect(resolveOsHomeRelativePath("~/state", { env })).toBe(path.resolve("/home/tester/state"));
|
||||
expect(resolveUserPath("~/state", env)).toBe(path.resolve("/configured/state"));
|
||||
expect(resolveUserPath(" ./relative ", { env })).toBe(path.resolve("./relative"));
|
||||
expect(resolveHomeRelativePath(" ", { env })).toBe("");
|
||||
expect(resolveOsHomeRelativePath(" ", { env })).toBe("");
|
||||
});
|
||||
|
||||
it("ignores unusable home values", () => {
|
||||
@ -242,6 +239,7 @@ describe("absolute path helpers", () => {
|
||||
code: "symlink",
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("filesystem utility helpers", () => {
|
||||
@ -329,20 +327,24 @@ describe("sidecar lock manager", () => {
|
||||
manager.reset();
|
||||
});
|
||||
|
||||
it("times out and reclaims stale locks", async () => {
|
||||
it("times out on stale locks without deleting them by path", async () => {
|
||||
const root = await tempRoot("fs-safe-sidecar-timeout-");
|
||||
const targetPath = path.join(root, "state.json");
|
||||
const lockPath = `${targetPath}.lock`;
|
||||
const manager = createSidecarLockManager(`coverage-timeout-${Date.now()}-${Math.random()}`);
|
||||
await fs.writeFile(lockPath, "{\"createdAt\":\"2000-01-01T00:00:00.000Z\"}\n", "utf8");
|
||||
|
||||
const reclaimed = await manager.acquire({
|
||||
targetPath,
|
||||
lockPath,
|
||||
staleMs: 1,
|
||||
payload: () => ({ owner: "coverage" }),
|
||||
});
|
||||
await reclaimed.release();
|
||||
await expect(
|
||||
manager.acquire({
|
||||
targetPath,
|
||||
lockPath,
|
||||
staleMs: 1,
|
||||
timeoutMs: 1,
|
||||
retry: { retries: 0, minTimeout: 1, maxTimeout: 1 },
|
||||
payload: () => ({ owner: "coverage" }),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "file_lock_stale" });
|
||||
await expect(fs.readFile(lockPath, "utf8")).resolves.toContain("2000");
|
||||
|
||||
await fs.writeFile(lockPath, "{\"createdAt\":\"2999-01-01T00:00:00.000Z\"}\n", "utf8");
|
||||
await expect(
|
||||
|
||||
@ -2,7 +2,17 @@ import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createBoundedReadStream, createMaxBytesTransform } from "../src/bounded-read-stream.js";
|
||||
import {
|
||||
assertAsyncDirectoryGuard,
|
||||
assertSyncDirectoryGuard,
|
||||
createAsyncDirectoryGuard,
|
||||
createNearestExistingDirectoryGuard,
|
||||
createNearestExistingSyncDirectoryGuard,
|
||||
createSyncDirectoryGuard,
|
||||
} from "../src/directory-guard.js";
|
||||
import { drainFileLockManagerForTest, resetFileLockManagerForTest } from "../src/file-lock.js";
|
||||
import { sameFileIdentity } from "../src/file-identity.js";
|
||||
import { readLocalFileFromRoots, resolveLocalPathFromRootsSync } from "../src/local-roots.js";
|
||||
@ -138,6 +148,53 @@ describe("small identity and lock wrappers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded streams and directory guard coverage", () => {
|
||||
it("returns raw streams without limits and rejects oversized limited streams", async () => {
|
||||
const raw = Readable.from(["ok"]);
|
||||
const returned = createBoundedReadStream({ handle: { createReadStream: () => raw } }, undefined);
|
||||
expect(returned).toBe(raw);
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of Readable.from(["ab", "cd"]).pipe(createMaxBytesTransform(3))) {
|
||||
// Drain the stream so transform errors surface.
|
||||
}
|
||||
}).rejects.toMatchObject({ code: "too-large" });
|
||||
});
|
||||
|
||||
it("detects changed or invalid directory guards", async () => {
|
||||
const root = await tempRoot("fs-safe-dir-guard-more-");
|
||||
const nested = path.join(root, "nested");
|
||||
const filePath = path.join(root, "file.txt");
|
||||
await fs.mkdir(nested);
|
||||
await fs.writeFile(filePath, "not a dir", "utf8");
|
||||
|
||||
await expect(createAsyncDirectoryGuard(filePath)).rejects.toMatchObject({ code: "not-file" });
|
||||
expect(() => createSyncDirectoryGuard(filePath)).toThrow("directory component");
|
||||
|
||||
const asyncGuard = await createAsyncDirectoryGuard(nested);
|
||||
const syncGuard = createSyncDirectoryGuard(nested);
|
||||
await expect(assertAsyncDirectoryGuard({ ...asyncGuard, realPath: root })).rejects.toMatchObject({
|
||||
code: "path-mismatch",
|
||||
});
|
||||
expect(() => assertSyncDirectoryGuard({ ...syncGuard, realPath: root })).toThrow(
|
||||
"directory changed",
|
||||
);
|
||||
|
||||
await fs.rm(nested, { recursive: true });
|
||||
await fs.writeFile(nested, "not a dir", "utf8");
|
||||
|
||||
await expect(assertAsyncDirectoryGuard(asyncGuard)).rejects.toMatchObject({
|
||||
code: "not-file",
|
||||
});
|
||||
expect(() => assertSyncDirectoryGuard(syncGuard)).toThrow("directory component");
|
||||
|
||||
const nearest = await createNearestExistingDirectoryGuard(root, path.join(root, "missing", "x"));
|
||||
expect(nearest.dir).toBe(root);
|
||||
expect(createNearestExistingSyncDirectoryGuard(root, path.join(root, "missing", "x")).dir)
|
||||
.toBe(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sibling temp coverage", () => {
|
||||
it("syncs temp files and parent dirs when requested", async () => {
|
||||
const root = await tempRoot("fs-safe-sibling-more-");
|
||||
@ -156,7 +213,10 @@ describe("sibling temp coverage", () => {
|
||||
|
||||
expect(result.filePath).toBe(path.join(root, "final.txt"));
|
||||
await expect(fs.readFile(result.filePath, "utf8")).resolves.toBe("synced");
|
||||
expect((await fs.stat(result.filePath)).mode & 0o777).toBe(0o600);
|
||||
if (process.platform !== "win32") {
|
||||
// POSIX file modes don't fully apply on Windows.
|
||||
expect((await fs.stat(result.filePath)).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it("removes sibling temp files when copy-in rejects the staged source", async () => {
|
||||
@ -188,15 +248,15 @@ describe("temp target edge coverage", () => {
|
||||
const root = await tempRoot("fs-safe-temp-more-");
|
||||
|
||||
expect(sanitizeTempFileName("???")).toBe("download.bin");
|
||||
expect(
|
||||
buildRandomTempFilePath({
|
||||
rootDir: root,
|
||||
prefix: "!!!",
|
||||
extension: "._-",
|
||||
now: Number.NaN,
|
||||
uuid: "id",
|
||||
}),
|
||||
).toMatch(new RegExp(`^${root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/tmp-\\d+-id$`));
|
||||
const built = buildRandomTempFilePath({
|
||||
rootDir: root,
|
||||
prefix: "!!!",
|
||||
extension: "._-",
|
||||
now: Number.NaN,
|
||||
uuid: "id",
|
||||
});
|
||||
expect(path.dirname(built)).toBe(root);
|
||||
expect(path.basename(built)).toMatch(/^tmp-\d+-id$/);
|
||||
|
||||
const tmp = await tempFile({ rootDir: root, prefix: "???", fileName: "???" });
|
||||
expect(path.basename(tmp.dir)).toMatch(/^tmp-/);
|
||||
|
||||
158
test/deepsec-regression.test.ts
Normal file
158
test/deepsec-regression.test.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fileStore } from "../src/file-store.js";
|
||||
import { loadPendingJsonDurableQueueEntries } from "../src/json-durable-queue.js";
|
||||
import { readLocalFileFromRoots, resolveLocalPathFromRootsSync } from "../src/local-roots.js";
|
||||
import { replaceFileAtomic } from "../src/replace-file.js";
|
||||
import { writeViaSiblingTempPath } from "../src/sibling-temp.js";
|
||||
import { buildRandomTempFilePath } from "../src/temp-target.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function tempRoot(prefix: string): Promise<string> {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("deepsec regressions", () => {
|
||||
it("keeps caller-provided temp tokens as single path segments", async () => {
|
||||
const base = await tempRoot("fs-safe-temp-token-");
|
||||
const target = path.join(base, "out.txt");
|
||||
|
||||
expect(() =>
|
||||
buildRandomTempFilePath({ rootDir: base, prefix: "tmp", uuid: "../escape" }),
|
||||
).toThrow();
|
||||
await expect(
|
||||
replaceFileAtomic({ filePath: target, content: "x", tempPrefix: "../escape" }),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
writeViaSiblingTempPath({
|
||||
rootDir: base,
|
||||
targetPath: target,
|
||||
tempPrefix: "../escape",
|
||||
writeTemp: async (tempPath) => {
|
||||
await fsp.writeFile(tempPath, "x");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await expect(fsp.stat(path.join(path.dirname(base), "escape"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
await expect(
|
||||
writeViaSiblingTempPath({
|
||||
rootDir: base,
|
||||
targetPath: target,
|
||||
tempPrefix: ".derived file prefix",
|
||||
writeTemp: async (tempPath) => {
|
||||
await fsp.writeFile(tempPath, "ok");
|
||||
},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(fsp.readFile(target, "utf8")).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"does not treat dangling symlinks as safe missing local-root paths",
|
||||
async () => {
|
||||
const base = await tempRoot("fs-safe-local-roots-");
|
||||
const outside = await tempRoot("fs-safe-local-roots-outside-");
|
||||
const linkPath = path.join(base, "dangling");
|
||||
await fsp.symlink(path.join(outside, "missing.txt"), linkPath, "file");
|
||||
|
||||
expect(
|
||||
resolveLocalPathFromRootsSync({
|
||||
filePath: linkPath,
|
||||
roots: [base],
|
||||
allowMissing: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves Root's default read cap for local-root reads", async () => {
|
||||
const base = await tempRoot("fs-safe-local-root-cap-");
|
||||
const filePath = path.join(base, "large.bin");
|
||||
await fsp.writeFile(filePath, Buffer.alloc(16 * 1024 * 1024 + 1));
|
||||
|
||||
await expect(readLocalFileFromRoots({ filePath, roots: [base] })).resolves.toBeNull();
|
||||
const uncapped = await readLocalFileFromRoots({
|
||||
filePath,
|
||||
roots: [base],
|
||||
maxBytes: 16 * 1024 * 1024 + 1,
|
||||
});
|
||||
expect(uncapped?.buffer.byteLength).toBe(16 * 1024 * 1024 + 1);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("pins private copyIn sources after validation", async () => {
|
||||
const base = await tempRoot("fs-safe-private-copyin-");
|
||||
const sourceDir = await tempRoot("fs-safe-private-copyin-source-");
|
||||
const outside = await tempRoot("fs-safe-private-copyin-outside-");
|
||||
const sourcePath = path.join(sourceDir, "upload.txt");
|
||||
const outsideFile = path.join(outside, "secret.txt");
|
||||
await fsp.writeFile(sourcePath, "upload");
|
||||
await fsp.writeFile(outsideFile, "secret");
|
||||
const originalLstat = fsp.lstat;
|
||||
let swapped = false;
|
||||
vi.spyOn(fsp, "lstat").mockImplementation(async (candidate, options) => {
|
||||
const stat = await originalLstat(candidate, options as never);
|
||||
if (!swapped && candidate === sourcePath) {
|
||||
swapped = true;
|
||||
await fsp.rm(sourcePath);
|
||||
await fsp.symlink(outsideFile, sourcePath, "file");
|
||||
}
|
||||
return stat;
|
||||
});
|
||||
|
||||
const store = fileStore({ rootDir: base, private: true });
|
||||
await expect(store.copyIn("copied.txt", sourcePath)).rejects.toBeTruthy();
|
||||
await expect(fsp.stat(path.join(base, "copied.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("preserves private copyIn source error codes", async () => {
|
||||
const base = await tempRoot("fs-safe-private-copyin-codes-");
|
||||
const sourceDir = await tempRoot("fs-safe-private-copyin-codes-source-");
|
||||
const source = path.join(sourceDir, "source.txt");
|
||||
const link = path.join(sourceDir, "source-link.txt");
|
||||
await fsp.writeFile(source, "1234567890");
|
||||
await fsp.symlink(source, link, "file");
|
||||
const store = fileStore({ rootDir: base, private: true });
|
||||
|
||||
await expect(store.copyIn("dir.txt", sourceDir)).rejects.toMatchObject({ code: "not-file" });
|
||||
await expect(store.copyIn("link.txt", link)).rejects.toMatchObject({ code: "not-file" });
|
||||
await expect(store.copyIn("large.txt", source, { maxBytes: 4 })).rejects.toMatchObject({
|
||||
code: "too-large",
|
||||
});
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("skips symlinked durable queue entries", async () => {
|
||||
const base = await tempRoot("fs-safe-queue-symlink-");
|
||||
const queueDir = path.join(base, "queue");
|
||||
const outside = await tempRoot("fs-safe-queue-outside-");
|
||||
await fsp.mkdir(queueDir);
|
||||
await fsp.writeFile(path.join(outside, "outside.json"), JSON.stringify({ leaked: true }));
|
||||
await fsp.symlink(path.join(outside, "outside.json"), path.join(queueDir, "leak.json"), "file");
|
||||
|
||||
await expect(
|
||||
loadPendingJsonDurableQueueEntries<{ leaked: boolean }>({ queueDir, tempPrefix: "queue" }),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects oversized durable queue entries before parsing", async () => {
|
||||
const base = await tempRoot("fs-safe-queue-size-");
|
||||
const queueDir = path.join(base, "queue");
|
||||
await fsp.mkdir(queueDir);
|
||||
await fsp.writeFile(path.join(queueDir, "large.json"), JSON.stringify({ data: "0123456789" }));
|
||||
|
||||
await expect(
|
||||
loadPendingJsonDurableQueueEntries({ queueDir, tempPrefix: "queue", maxBytes: 4 }),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@ -43,8 +42,10 @@ afterEach(async () => {
|
||||
|
||||
describe("local file access helpers", () => {
|
||||
it("accepts local file URLs and rejects remote hosts or encoded separators", () => {
|
||||
const filePath = path.join(path.sep, "tmp", "demo.txt");
|
||||
expect(safeFileURLToPath(new URL(`file://${filePath}`).href)).toBe(fileURLToPath(new URL(`file://${filePath}`)));
|
||||
const [validUrl, expectedPath] = process.platform === "win32"
|
||||
? ["file:///C:/tmp/demo.txt", "C:\\tmp\\demo.txt"]
|
||||
: ["file:///tmp/demo.txt", "/tmp/demo.txt"];
|
||||
expect(safeFileURLToPath(validUrl)).toBe(expectedPath);
|
||||
expect(() => safeFileURLToPath("file://example.com/tmp/demo.txt")).toThrow(/remote hosts/);
|
||||
expect(() => safeFileURLToPath("file:///tmp/a%2Fb.txt")).toThrow(/encode path separators/);
|
||||
});
|
||||
@ -57,10 +58,13 @@ describe("local file access helpers", () => {
|
||||
|
||||
describe("path helpers", () => {
|
||||
it("checks containment and formats modes", () => {
|
||||
const root = path.join(path.sep, "tmp", "root");
|
||||
// Use path.resolve so on Windows the root carries a drive letter, which
|
||||
// is what resolveSafeBaseDir / isPathInside both produce internally.
|
||||
const root = path.resolve(path.sep, "tmp", "root");
|
||||
const otherRoot = path.resolve(path.sep, "tmp", "root-other");
|
||||
expect(resolveSafeBaseDir(root)).toBe(`${root}${path.sep}`);
|
||||
expect(isWithinDir(root, path.join(root, "file.txt"))).toBe(true);
|
||||
expect(isPathInside(root, path.join(path.sep, "tmp", "root-other"))).toBe(false);
|
||||
expect(isPathInside(root, otherRoot)).toBe(false);
|
||||
expect(formatPosixMode(0o100755)).toBe("755");
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,7 +9,10 @@ import { configureFsSafePython, root as openRoot } from "../src/index.js";
|
||||
import { prepareArchiveDestinationDir, prepareArchiveOutputPath, mergeExtractedTreeIntoDestination } from "../src/archive-staging.js";
|
||||
import { fileStore, fileStoreSync } from "../src/file-store.js";
|
||||
import { writeJsonSync } from "../src/json.js";
|
||||
import { moveJsonDurableQueueEntryToFailed, resolveJsonDurableQueueEntryPaths } from "../src/json-durable-queue.js";
|
||||
import {
|
||||
moveJsonDurableQueueEntryToFailed,
|
||||
resolveJsonDurableQueueEntryPaths,
|
||||
} from "../src/json-durable-queue.js";
|
||||
import { movePathWithCopyFallback } from "../src/move-path.js";
|
||||
import { runPinnedWriteHelper } from "../src/pinned-write.js";
|
||||
import { replaceFileAtomic } from "../src/replace-file.js";
|
||||
|
||||
@ -6,6 +6,9 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { configureFsSafePython, FsSafeError, root as openRoot } from "../src/index.js";
|
||||
import { openLocalFileSafely, readLocalFileSafely } from "../src/root.js";
|
||||
import { __setFsSafeTestHooksForTest } from "../src/test-hooks.js";
|
||||
import { expectedFsSafeCode } from "./helpers/security.js";
|
||||
|
||||
const skipOnWindows = process.platform === "win32";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@ -23,7 +26,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("@openclaw/fs-safe", () => {
|
||||
it("reuses a root capability across filesystem operations", async () => {
|
||||
it.skipIf(skipOnWindows)("reuses a root capability across filesystem operations", async () => {
|
||||
const rootPath = await tempRoot("fs-root-object-");
|
||||
const root = await openRoot(rootPath);
|
||||
|
||||
@ -62,7 +65,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("can disable the Python helper and keep root operations available", async () => {
|
||||
it.skipIf(skipOnWindows)("can disable the Python helper and keep root operations available", async () => {
|
||||
configureFsSafePython({ mode: "off" });
|
||||
const rootPath = await tempRoot("fs-safe-python-off-");
|
||||
const sourceRoot = await tempRoot("fs-safe-python-off-source-");
|
||||
@ -125,7 +128,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("writes, reads, stats, and lists files within a root", async () => {
|
||||
it.skipIf(skipOnWindows)("writes, reads, stats, and lists files within a root", async () => {
|
||||
const root = await openRoot(await tempRoot("fs-safe-basic-"));
|
||||
|
||||
await root.mkdir("nested");
|
||||
@ -234,7 +237,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
await expect(root.read("link/secret.txt")).rejects.toMatchObject({
|
||||
code: "outside-workspace",
|
||||
});
|
||||
await expect(root.list("link")).rejects.toMatchObject({ code: "path-alias" });
|
||||
await expect(root.list("link")).rejects.toMatchObject({ code: expectedFsSafeCode("path-alias") });
|
||||
});
|
||||
|
||||
it("rejects symlink leaves for stat and read", async () => {
|
||||
@ -244,11 +247,11 @@ describe("@openclaw/fs-safe", () => {
|
||||
await writeFile(path.join(outside, "secret.txt"), "secret");
|
||||
await symlink(path.join(outside, "secret.txt"), path.join(rootPath, "secret-link"), "file");
|
||||
|
||||
await expect(root.stat("secret-link")).rejects.toMatchObject({ code: "path-alias" });
|
||||
await expect(root.stat("secret-link")).rejects.toMatchObject({ code: expectedFsSafeCode("path-alias") });
|
||||
await expect(root.read("secret-link")).rejects.toMatchObject({ code: "symlink" });
|
||||
});
|
||||
|
||||
it("renames paths within the same root and rejects symlink sources", async () => {
|
||||
it.skipIf(skipOnWindows)("renames paths within the same root and rejects symlink sources", async () => {
|
||||
const rootPath = await tempRoot("fs-safe-rename-");
|
||||
const root = await openRoot(rootPath);
|
||||
const outside = await tempRoot("fs-safe-outside-");
|
||||
@ -264,7 +267,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires explicit overwrite for moves that replace a target", async () => {
|
||||
it.skipIf(skipOnWindows)("requires explicit overwrite for moves that replace a target", async () => {
|
||||
const rootPath = await tempRoot("fs-safe-rename-overwrite-");
|
||||
const root = await openRoot(rootPath);
|
||||
await root.write("from.txt", "source");
|
||||
@ -279,7 +282,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
await expect(readFile(path.join(rootPath, "to.txt"), "utf8")).resolves.toBe("source");
|
||||
});
|
||||
|
||||
it("enforces copyIn maxBytes while streaming", async () => {
|
||||
it.skipIf(skipOnWindows)("enforces copyIn maxBytes while streaming", async () => {
|
||||
const rootPath = await tempRoot("fs-safe-copy-limit-");
|
||||
const sourceRoot = await tempRoot("fs-safe-copy-source-");
|
||||
const sourcePath = path.join(sourceRoot, "source.txt");
|
||||
@ -354,7 +357,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
|
||||
await expect(readFile(outsideFile, "utf8")).resolves.toBe("kept");
|
||||
await expect(root.stat("link")).rejects.toMatchObject({
|
||||
code: "not-found",
|
||||
code: expectedFsSafeCode("not-found"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -56,17 +56,19 @@ export const LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [
|
||||
"%2e%2e/pwned.txt",
|
||||
"%2e%2e%2fpwned.txt",
|
||||
"%252e%252e%252fpwned.txt",
|
||||
// ".." prefix without an actual separator: a single literal filename
|
||||
// ("..%2fpwned.txt") or two literal segments ("..%00", "pwned.txt") that
|
||||
// resolve fully inside root. Accepted on both platforms.
|
||||
"..%2fpwned.txt",
|
||||
"..%00/pwned.txt",
|
||||
] as const;
|
||||
|
||||
export const POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [
|
||||
"nested\\..\\..\\pwned.txt",
|
||||
"C:\\Windows\\win.ini",
|
||||
"\\\\server\\share\\pwned.txt",
|
||||
] as const;
|
||||
|
||||
export const SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS = [
|
||||
"..%2fpwned.txt",
|
||||
"..%00/pwned.txt",
|
||||
// "..\\" is a real traversal on Windows (separator) but a literal filename
|
||||
// on POSIX (where "\\" is a regular name character).
|
||||
"..\\pwned.txt",
|
||||
] as const;
|
||||
|
||||
@ -98,9 +100,20 @@ export async function makeTempLayout(
|
||||
return { outside, outsideFile, root };
|
||||
}
|
||||
|
||||
export function expectFsSafeCode(error: unknown, codes: readonly string[]): void {
|
||||
export function expectFsSafeCode(
|
||||
error: unknown,
|
||||
codes: readonly string[],
|
||||
opts: { allowUnsupportedPlatformOnWindows?: boolean } = {},
|
||||
): void {
|
||||
expect(error).toBeInstanceOf(FsSafeError);
|
||||
expect(codes).toContain((error as FsSafeError).code);
|
||||
const accepted = process.platform === "win32" && opts.allowUnsupportedPlatformOnWindows
|
||||
? [...codes, "unsupported-platform"]
|
||||
: codes;
|
||||
expect(accepted).toContain((error as FsSafeError).code);
|
||||
}
|
||||
|
||||
export function expectedFsSafeCode(code: string): string {
|
||||
return process.platform === "win32" ? "unsupported-platform" : code;
|
||||
}
|
||||
|
||||
export async function expectNoOutsideWrite(
|
||||
|
||||
@ -28,6 +28,24 @@ afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })));
|
||||
});
|
||||
|
||||
function mockOpenForSyncCounting(): { readonly syncCalls: number; restore: () => void } {
|
||||
let syncCalls = 0;
|
||||
const openSpy = vi.spyOn(fs, "open").mockImplementation(async () => {
|
||||
return {
|
||||
sync: async () => {
|
||||
syncCalls += 1;
|
||||
},
|
||||
close: async () => undefined,
|
||||
} as Awaited<ReturnType<typeof fs.open>>;
|
||||
});
|
||||
return {
|
||||
get syncCalls() {
|
||||
return syncCalls;
|
||||
},
|
||||
restore: () => openSpy.mockRestore(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("json file helpers", () => {
|
||||
it("writes formatted JSON atomically with an optional trailing newline", async () => {
|
||||
const root = await tempRoot("fs-safe-json-");
|
||||
@ -59,6 +77,54 @@ describe("json file helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("syncs temp file and parent directory by default for text writes", async () => {
|
||||
const root = await tempRoot("fs-safe-json-");
|
||||
const filePath = path.join(root, "default-durable.txt");
|
||||
const syncCounter = mockOpenForSyncCounting();
|
||||
|
||||
try {
|
||||
await writeTextAtomic(filePath, "data");
|
||||
} finally {
|
||||
syncCounter.restore();
|
||||
}
|
||||
|
||||
expect(syncCounter.syncCalls).toBe(2);
|
||||
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("data");
|
||||
});
|
||||
|
||||
it("skips fsync when text writes opt out of durability", async () => {
|
||||
const root = await tempRoot("fs-safe-json-");
|
||||
const filePath = path.join(root, "store.json");
|
||||
await fs.writeFile(filePath, "old", "utf8");
|
||||
const syncCounter = mockOpenForSyncCounting();
|
||||
|
||||
try {
|
||||
await writeTextAtomic(filePath, "new", { durable: false });
|
||||
} finally {
|
||||
syncCounter.restore();
|
||||
}
|
||||
|
||||
expect(syncCounter.syncCalls).toBe(0);
|
||||
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("new");
|
||||
const dirEntries = await fs.readdir(root);
|
||||
expect(dirEntries.some((entry) => entry.endsWith(".tmp"))).toBe(false);
|
||||
});
|
||||
|
||||
it("threads durable option through JSON writes", async () => {
|
||||
const root = await tempRoot("fs-safe-json-");
|
||||
const filePath = path.join(root, "state.json");
|
||||
const syncCounter = mockOpenForSyncCounting();
|
||||
|
||||
try {
|
||||
await writeJson(filePath, { ok: true }, { durable: false });
|
||||
} finally {
|
||||
syncCounter.restore();
|
||||
}
|
||||
|
||||
expect(syncCounter.syncCalls).toBe(0);
|
||||
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("{\n \"ok\": true\n}");
|
||||
});
|
||||
|
||||
it("separates nullable and durable read failure semantics", async () => {
|
||||
const root = await tempRoot("fs-safe-json-");
|
||||
const missing = path.join(root, "missing.json");
|
||||
|
||||
179
test/move-path-regression.test.ts
Normal file
179
test/move-path-regression.test.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { movePathWithCopyFallback } from "../src/move-path.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function tempRoot(prefix: string): Promise<string> {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("movePathWithCopyFallback regressions", () => {
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"does not delete source entries replaced after an EXDEV copy",
|
||||
async () => {
|
||||
const base = await tempRoot("fs-safe-move-exdev-replaced-source-");
|
||||
const source = path.join(base, "source-dir");
|
||||
const dest = path.join(base, "dest-dir");
|
||||
await fsp.mkdir(source);
|
||||
await fsp.writeFile(path.join(source, "copied.txt"), "copied");
|
||||
const realRename = fsp.rename;
|
||||
vi.spyOn(fsp, "rename").mockImplementation(async (from, to) => {
|
||||
if (from === source && to === dest) {
|
||||
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
|
||||
}
|
||||
await realRename(from, to);
|
||||
if (to === dest && String(from).includes(".fs-safe-move-")) {
|
||||
await fsp.rm(path.join(source, "copied.txt"));
|
||||
await fsp.writeFile(path.join(source, "copied.txt"), "replacement");
|
||||
await fsp.writeFile(path.join(source, "late.txt"), "late");
|
||||
}
|
||||
});
|
||||
|
||||
await expect(movePathWithCopyFallback({ from: source, to: dest })).rejects.toMatchObject({
|
||||
code: "ESTALE",
|
||||
});
|
||||
|
||||
await expect(fsp.readFile(path.join(dest, "copied.txt"), "utf8")).resolves.toBe("copied");
|
||||
await expect(fsp.readFile(path.join(source, "copied.txt"), "utf8")).resolves.toBe(
|
||||
"replacement",
|
||||
);
|
||||
await expect(fsp.readFile(path.join(source, "late.txt"), "utf8")).resolves.toBe("late");
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"can reject hardlinked files during EXDEV move fallback",
|
||||
async () => {
|
||||
const base = await tempRoot("fs-safe-move-exdev-hardlink-");
|
||||
const source = path.join(base, "source.txt");
|
||||
const hardlink = path.join(base, "hardlink.txt");
|
||||
const dest = path.join(base, "dest.txt");
|
||||
await fsp.writeFile(source, "source");
|
||||
await fsp.link(source, hardlink);
|
||||
const realRename = fsp.rename;
|
||||
vi.spyOn(fsp, "rename").mockImplementation(async (from, to) => {
|
||||
if (from === source && to === dest) {
|
||||
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
|
||||
}
|
||||
return await realRename(from, to);
|
||||
});
|
||||
|
||||
await expect(
|
||||
movePathWithCopyFallback({ from: source, sourceHardlinks: "reject", to: dest }),
|
||||
).rejects.toThrow("Refusing to move hardlinked file");
|
||||
|
||||
await expect(fsp.readFile(source, "utf8")).resolves.toBe("source");
|
||||
await expect(fsp.stat(dest)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"preserves directory modes during EXDEV move fallback",
|
||||
async () => {
|
||||
const base = await tempRoot("fs-safe-move-exdev-dir-mode-");
|
||||
const source = path.join(base, "source-dir");
|
||||
const dest = path.join(base, "dest-dir");
|
||||
await fsp.mkdir(source);
|
||||
await fsp.writeFile(path.join(source, "copied.txt"), "copied");
|
||||
await fsp.chmod(source, 0o777);
|
||||
const realRename = fsp.rename;
|
||||
vi.spyOn(fsp, "rename").mockImplementation(async (from, to) => {
|
||||
if (from === source && to === dest) {
|
||||
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
|
||||
}
|
||||
return await realRename(from, to);
|
||||
});
|
||||
const realMkdir = fsp.mkdir;
|
||||
vi.spyOn(fsp, "mkdir").mockImplementation(async (target, options) => {
|
||||
const result = await realMkdir(target, options as never);
|
||||
if (String(target).includes(".fs-safe-move-")) {
|
||||
await fsp.chmod(target, 0o700);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
await movePathWithCopyFallback({ from: source, to: dest });
|
||||
|
||||
expect((await fsp.stat(dest)).mode & 0o777).toBe(0o777);
|
||||
await expect(fsp.readFile(path.join(dest, "copied.txt"), "utf8")).resolves.toBe("copied");
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"removes unchanged copied children when source directory gains a late child",
|
||||
async () => {
|
||||
const base = await tempRoot("fs-safe-move-exdev-added-source-");
|
||||
const source = path.join(base, "source-dir");
|
||||
const dest = path.join(base, "dest-dir");
|
||||
await fsp.mkdir(source);
|
||||
await fsp.writeFile(path.join(source, "copied.txt"), "copied");
|
||||
const realRename = fsp.rename;
|
||||
vi.spyOn(fsp, "rename").mockImplementation(async (from, to) => {
|
||||
if (from === source && to === dest) {
|
||||
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
|
||||
}
|
||||
await realRename(from, to);
|
||||
if (to === dest && String(from).includes(".fs-safe-move-")) {
|
||||
await fsp.writeFile(path.join(source, "late.txt"), "late");
|
||||
}
|
||||
});
|
||||
|
||||
await expect(movePathWithCopyFallback({ from: source, to: dest })).rejects.toMatchObject({
|
||||
code: "ESTALE",
|
||||
});
|
||||
|
||||
await expect(fsp.readFile(path.join(dest, "copied.txt"), "utf8")).resolves.toBe("copied");
|
||||
await expect(fsp.stat(path.join(source, "copied.txt"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
await expect(fsp.readFile(path.join(source, "late.txt"), "utf8")).resolves.toBe("late");
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"does not commit bytes from a source swapped after validation",
|
||||
async () => {
|
||||
const base = await tempRoot("fs-safe-move-exdev-source-swap-");
|
||||
const outside = await tempRoot("fs-safe-move-exdev-source-swap-outside-");
|
||||
const source = path.join(base, "source.txt");
|
||||
const dest = path.join(base, "dest.txt");
|
||||
const outsideFile = path.join(outside, "secret.txt");
|
||||
await fsp.writeFile(source, "inside");
|
||||
await fsp.writeFile(outsideFile, "secret");
|
||||
|
||||
const realRename = fsp.rename;
|
||||
vi.spyOn(fsp, "rename").mockImplementation(async (from, to) => {
|
||||
if (from === source && to === dest) {
|
||||
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
|
||||
}
|
||||
return await realRename(from, to);
|
||||
});
|
||||
const realLstat = fsp.lstat;
|
||||
let swapped = false;
|
||||
vi.spyOn(fsp, "lstat").mockImplementation(async (candidate, options) => {
|
||||
const stat = await realLstat(candidate, options as never);
|
||||
if (!swapped && candidate === source) {
|
||||
swapped = true;
|
||||
await fsp.rm(source);
|
||||
await fsp.symlink(outsideFile, source, "file");
|
||||
}
|
||||
return stat;
|
||||
});
|
||||
|
||||
await expect(movePathWithCopyFallback({ from: source, to: dest })).rejects.toMatchObject({
|
||||
code: "ESTALE",
|
||||
});
|
||||
await expect(fsp.stat(dest)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
});
|
||||
@ -125,7 +125,7 @@ describe("private temp workspaces", () => {
|
||||
});
|
||||
|
||||
describe("file store", () => {
|
||||
it("writes, reads, copies, and prunes files under the store root", async () => {
|
||||
it.skipIf(process.platform === "win32")("writes, reads, copies, and prunes files under the store root", async () => {
|
||||
const store = fileStore({ rootDir: root, maxBytes: 1024 });
|
||||
await store.write("media/a.txt", "hello");
|
||||
await expect(store.readBytes("media/a.txt")).resolves.toEqual(Buffer.from("hello"));
|
||||
@ -175,7 +175,7 @@ describe("json store", () => {
|
||||
});
|
||||
|
||||
describe("secure file reads", () => {
|
||||
it("reads from a validated file handle", async () => {
|
||||
it.runIf(process.platform !== "win32")("reads from a validated file handle", async () => {
|
||||
const filePath = path.join(root, "secret.json");
|
||||
await fs.writeFile(filePath, '{"token":"ok"}', { mode: 0o600 });
|
||||
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
||||
@ -190,6 +190,24 @@ describe("secure file reads", () => {
|
||||
expect(result.realPath).toBe(await fs.realpath(filePath));
|
||||
});
|
||||
|
||||
it.runIf(process.platform === "win32")(
|
||||
"fails closed on windows when ACL inspection is unavailable",
|
||||
async () => {
|
||||
// See src/secure-file.ts:177 — readSecureFile throws permission-unverified
|
||||
// on Windows because ACL inspection has no portable equivalent.
|
||||
const filePath = path.join(root, "secret.json");
|
||||
await fs.writeFile(filePath, '{"token":"ok"}', { mode: 0o600 });
|
||||
|
||||
await expect(
|
||||
readSecureFile({
|
||||
filePath,
|
||||
label: "test secret",
|
||||
io: { maxBytes: 1024 },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "permission-unverified" });
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects symlinks and files outside trusted dirs", async () => {
|
||||
const trusted = path.join(root, "trusted");
|
||||
const outside = path.join(root, "outside");
|
||||
|
||||
278
test/output.test.ts
Normal file
278
test/output.test.ts
Normal file
@ -0,0 +1,278 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { writeExternalFileWithinRoot } from "../src/output.js";
|
||||
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
async function tempRoot(prefix: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.add(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs) {
|
||||
await fs.rm(dir, { force: true, recursive: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
describe("writeExternalFileWithinRoot", () => {
|
||||
it("stages an external writer in private temp storage and finalizes under the root", async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-root-");
|
||||
const targetPath = path.join(rootDir, "downloads", "report.txt");
|
||||
let tempPath = "";
|
||||
|
||||
const result = await writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: targetPath,
|
||||
write: async (candidate) => {
|
||||
tempPath = candidate;
|
||||
await fs.writeFile(candidate, "downloaded", "utf8");
|
||||
return { bytes: 10 };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.path).toBe(path.join(await fs.realpath(rootDir), "downloads", "report.txt"));
|
||||
expect(result.result).toEqual({ bytes: 10 });
|
||||
expect(path.dirname(tempPath)).not.toBe(path.dirname(targetPath));
|
||||
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("downloaded");
|
||||
await expect(fs.stat(tempPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("preserves caller-provided destination filename spacing", async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-spaces-");
|
||||
const fileName = " report .txt ";
|
||||
|
||||
const result = await writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: fileName,
|
||||
write: async (candidate) => {
|
||||
await fs.writeFile(candidate, "spaced", "utf8");
|
||||
},
|
||||
});
|
||||
|
||||
const finalPath = path.join(rootDir, fileName);
|
||||
expect(result.path).toBe(path.join(await fs.realpath(rootDir), fileName));
|
||||
await expect(fs.readFile(finalPath, "utf8")).resolves.toBe("spaced");
|
||||
await expect(fs.stat(path.join(rootDir, fileName.trim()))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts absolute target paths that resolve inside the root", async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-absolute-");
|
||||
const targetPath = path.join(rootDir, "nested", "report.txt");
|
||||
|
||||
const result = await writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: targetPath,
|
||||
write: async (candidate) => {
|
||||
await fs.writeFile(candidate, "absolute", "utf8");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.path).toBe(path.join(await fs.realpath(rootDir), "nested", "report.txt"));
|
||||
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("absolute");
|
||||
});
|
||||
|
||||
it("enforces byte limits while leaving the final target absent", async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-max-bytes-");
|
||||
const targetPath = path.join(rootDir, "too-large.bin");
|
||||
|
||||
await expect(
|
||||
writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: "too-large.bin",
|
||||
maxBytes: 3,
|
||||
write: async (candidate) => {
|
||||
await fs.writeFile(candidate, "larger", "utf8");
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "too-large" });
|
||||
|
||||
await expect(fs.stat(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("applies the requested final file mode", async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-mode-");
|
||||
const targetPath = path.join(rootDir, "private.txt");
|
||||
|
||||
await writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: "private.txt",
|
||||
mode: 0o600,
|
||||
write: async (candidate) => {
|
||||
await fs.writeFile(candidate, "private", { encoding: "utf8", mode: 0o644 });
|
||||
},
|
||||
});
|
||||
|
||||
const stat = await fs.stat(targetPath);
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("rejects empty target paths before invoking the external writer", async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-default-");
|
||||
let called = false;
|
||||
|
||||
await expect(
|
||||
writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: "",
|
||||
write: async (candidate) => {
|
||||
called = true;
|
||||
await fs.writeFile(candidate, "named", "utf8");
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "invalid-path" });
|
||||
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects targets outside the root before invoking the external writer", async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-reject-root-");
|
||||
const outsideDir = await tempRoot("fs-safe-output-reject-outside-");
|
||||
const outsidePath = path.join(outsideDir, "pwned.txt");
|
||||
let called = false;
|
||||
|
||||
await expect(
|
||||
writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: outsidePath,
|
||||
write: async (candidate) => {
|
||||
called = true;
|
||||
await fs.writeFile(candidate, "pwned", "utf8");
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "outside-workspace" });
|
||||
|
||||
expect(called).toBe(false);
|
||||
await expect(fs.stat(outsidePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("rejects traversal targets before invoking the external writer", async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-traversal-root-");
|
||||
let called = false;
|
||||
|
||||
await expect(
|
||||
writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: "../../../pwned.txt",
|
||||
write: async (candidate) => {
|
||||
called = true;
|
||||
await fs.writeFile(candidate, "pwned", "utf8");
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "outside-workspace" });
|
||||
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects root directory targets before invoking the external writer", async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-root-target-");
|
||||
let called = false;
|
||||
|
||||
await expect(
|
||||
writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: rootDir,
|
||||
write: async (candidate) => {
|
||||
called = true;
|
||||
await fs.writeFile(candidate, "not a file target", "utf8");
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "invalid-path" });
|
||||
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects trailing-separator targets before invoking the external writer", async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-dir-target-");
|
||||
let called = false;
|
||||
|
||||
await expect(
|
||||
writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: "nested/",
|
||||
write: async (candidate) => {
|
||||
called = true;
|
||||
await fs.writeFile(candidate, "not a file target", "utf8");
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "invalid-path" });
|
||||
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"does not let symlinked target parents redirect the external temp write",
|
||||
async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-link-root-");
|
||||
const outsideDir = await tempRoot("fs-safe-output-link-outside-");
|
||||
await fs.symlink(outsideDir, path.join(rootDir, "link"), "dir");
|
||||
let tempPath = "";
|
||||
|
||||
await expect(
|
||||
writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: "link/out.txt",
|
||||
write: async (candidate) => {
|
||||
tempPath = candidate;
|
||||
await fs.writeFile(candidate, "pwned", "utf8");
|
||||
},
|
||||
}),
|
||||
).rejects.toBeTruthy();
|
||||
|
||||
await expect(fs.stat(tempPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.readdir(outsideDir)).resolves.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects hardlinked final targets and preserves the existing file",
|
||||
async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-hardlink-");
|
||||
const sourcePath = path.join(rootDir, "source.txt");
|
||||
const hardlinkPath = path.join(rootDir, "hardlink.txt");
|
||||
await fs.writeFile(sourcePath, "original", "utf8");
|
||||
await fs.link(sourcePath, hardlinkPath);
|
||||
|
||||
await expect(
|
||||
writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: "hardlink.txt",
|
||||
write: async (candidate) => {
|
||||
await fs.writeFile(candidate, "replacement", "utf8");
|
||||
},
|
||||
}),
|
||||
).rejects.toBeTruthy();
|
||||
|
||||
await expect(fs.readFile(sourcePath, "utf8")).resolves.toBe("original");
|
||||
await expect(fs.readFile(hardlinkPath, "utf8")).resolves.toBe("original");
|
||||
},
|
||||
);
|
||||
|
||||
it("cleans private temp files when the external writer fails", async () => {
|
||||
const rootDir = await tempRoot("fs-safe-output-fail-root-");
|
||||
let tempPath = "";
|
||||
|
||||
await expect(
|
||||
writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: "out.txt",
|
||||
write: async (candidate) => {
|
||||
tempPath = candidate;
|
||||
await fs.writeFile(candidate, "partial", "utf8");
|
||||
throw new Error("download failed");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("download failed");
|
||||
|
||||
await expect(fs.stat(tempPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.stat(path.join(rootDir, "out.txt"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -180,6 +180,18 @@ describe("Python helper configuration", () => {
|
||||
|
||||
describe("persistent Python helper worker", () => {
|
||||
it("reuses one worker and unreferences it while idle", async () => {
|
||||
if (process.platform === "win32") {
|
||||
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/fake-python" });
|
||||
await expect(
|
||||
runPinnedPythonOperation<{ ok: boolean }>({
|
||||
operation: "stat",
|
||||
rootPath: "/tmp/root",
|
||||
payload: { relativePath: "a.txt" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "unsupported-platform" });
|
||||
return;
|
||||
}
|
||||
|
||||
const child = makeRespondingChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/fake-python" });
|
||||
@ -209,6 +221,21 @@ describe("persistent Python helper worker", () => {
|
||||
const rootDir = await tempRoot("fs-safe-python-policy-");
|
||||
await fs.writeFile(path.join(rootDir, "file.txt"), "ok");
|
||||
|
||||
if (process.platform === "win32") {
|
||||
spawnMock.mockImplementation(makeFailingChild);
|
||||
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/missing-python" });
|
||||
const autoRoot = await root(rootDir);
|
||||
await expect(autoRoot.stat("file.txt")).rejects.toMatchObject({
|
||||
code: "unsupported-platform",
|
||||
});
|
||||
|
||||
configureFsSafePython({ mode: "require", pythonPath: "/tmp/missing-python" });
|
||||
await expect((await root(rootDir)).stat("file.txt")).rejects.toMatchObject({
|
||||
code: "unsupported-platform",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
spawnMock.mockImplementation(makeFailingChild);
|
||||
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/missing-python" });
|
||||
const autoRoot = await root(rootDir);
|
||||
|
||||
@ -38,64 +38,89 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("pinned write fallback coverage", () => {
|
||||
it("writes buffers, creates only when missing, streams, and enforces limits", async () => {
|
||||
const { runPinnedWriteHelper } = await import("../src/pinned-write.js");
|
||||
const root = await tempRoot("fs-safe-pinned-write-fallback-");
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"writes buffers, creates only when missing, streams, and enforces limits",
|
||||
async () => {
|
||||
const { runPinnedWriteHelper } = await import("../src/pinned-write.js");
|
||||
const root = await tempRoot("fs-safe-pinned-write-fallback-");
|
||||
|
||||
const created = await runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "created.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: false,
|
||||
input: { kind: "buffer", data: "created", encoding: "utf8" },
|
||||
});
|
||||
expect(created.ino).toBeGreaterThan(0);
|
||||
await expect(fs.readFile(path.join(root, "nested", "created.txt"), "utf8")).resolves.toBe(
|
||||
"created",
|
||||
);
|
||||
await expect(
|
||||
runPinnedWriteHelper({
|
||||
const created = await runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "created.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: false,
|
||||
input: { kind: "buffer", data: "again" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EEXIST" });
|
||||
input: { kind: "buffer", data: "created", encoding: "utf8" },
|
||||
});
|
||||
expect(created.ino).toBeGreaterThan(0);
|
||||
await expect(fs.readFile(path.join(root, "nested", "created.txt"), "utf8")).resolves.toBe(
|
||||
"created",
|
||||
);
|
||||
await expect(
|
||||
runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "created.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: false,
|
||||
input: { kind: "buffer", data: "again" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EEXIST" });
|
||||
|
||||
const streamed = await runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "streamed.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: true,
|
||||
maxBytes: 16,
|
||||
input: { kind: "stream", stream: Readable.from(["stream", "ed"]) },
|
||||
});
|
||||
expect(streamed.dev).toBeGreaterThan(0);
|
||||
await expect(fs.readFile(path.join(root, "nested", "streamed.txt"), "utf8")).resolves.toBe(
|
||||
"streamed",
|
||||
);
|
||||
|
||||
await expect(
|
||||
runPinnedWriteHelper({
|
||||
const streamed = await runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "too-large.txt",
|
||||
basename: "streamed.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: true,
|
||||
maxBytes: 2,
|
||||
input: { kind: "buffer", data: Buffer.from("large") },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "too-large" });
|
||||
await expect(fs.stat(path.join(root, "nested", "too-large.txt"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
maxBytes: 16,
|
||||
input: { kind: "stream", stream: Readable.from(["stream", "ed"]) },
|
||||
});
|
||||
expect(streamed.dev).toBeGreaterThan(0);
|
||||
await expect(fs.readFile(path.join(root, "nested", "streamed.txt"), "utf8")).resolves.toBe(
|
||||
"streamed",
|
||||
);
|
||||
|
||||
await expect(
|
||||
runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "too-large.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: true,
|
||||
maxBytes: 2,
|
||||
input: { kind: "buffer", data: Buffer.from("large") },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "too-large" });
|
||||
await expect(fs.stat(path.join(root, "nested", "too-large.txt"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "win32")(
|
||||
"rejects with unsupported-platform on windows",
|
||||
async () => {
|
||||
// fd-relative pinned filesystem operations are unavailable on windows
|
||||
// (see src/pinned-python.ts), so the helper fails closed before any
|
||||
// posix-only logic runs.
|
||||
const { runPinnedWriteHelper } = await import("../src/pinned-write.js");
|
||||
const root = await tempRoot("fs-safe-pinned-write-fallback-");
|
||||
await expect(
|
||||
runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "created.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: false,
|
||||
input: { kind: "buffer", data: "created", encoding: "utf8" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "unsupported-platform" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -72,11 +72,15 @@ describe("read boundary bypass attempts", () => {
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.stat("../secret.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]);
|
||||
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"], {
|
||||
allowUnsupportedPlatformOnWindows: true,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.list(".." as string)).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]);
|
||||
expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"], {
|
||||
allowUnsupportedPlatformOnWindows: true,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
await expect(scope.files(["../secret.txt"])).resolves.toMatchObject({ ok: false });
|
||||
@ -96,11 +100,15 @@ describe("read boundary bypass attempts", () => {
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.stat("link/secret.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"], {
|
||||
allowUnsupportedPlatformOnWindows: true,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.list("link")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"], {
|
||||
allowUnsupportedPlatformOnWindows: true,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@ -120,7 +128,9 @@ describe("read boundary bypass attempts", () => {
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.stat("secret-link.txt")).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]);
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"], {
|
||||
allowUnsupportedPlatformOnWindows: true,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
@ -168,7 +178,9 @@ describe("read boundary bypass attempts", () => {
|
||||
return true;
|
||||
});
|
||||
await expect(safeRoot.stat(layout.outsideFile)).rejects.toSatisfy((error: unknown) => {
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"]);
|
||||
expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"], {
|
||||
allowUnsupportedPlatformOnWindows: true,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { realpathSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@ -173,7 +174,10 @@ describe("local roots helpers", () => {
|
||||
const missingPath = path.join(uploadsDir, "new", "later.txt");
|
||||
const outsidePath = path.join(baseDir, "outside.txt");
|
||||
await fs.writeFile(filePath, "ok", "utf8");
|
||||
const uploadsReal = await fs.realpath(uploadsDir);
|
||||
// Use the sync realpath to compare against resolveLocalPathFromRootsSync.
|
||||
// On windows fs.realpathSync and fs.realpath (async) sometimes disagree
|
||||
// on 8.3 short-name canonicalization (e.g. "RUNNER~1" vs "runneradmin").
|
||||
const uploadsReal = realpathSync(uploadsDir);
|
||||
|
||||
expect(
|
||||
resolveLocalPathFromRootsSync({
|
||||
@ -182,7 +186,7 @@ describe("local roots helpers", () => {
|
||||
label: "media roots",
|
||||
requireFile: true,
|
||||
}),
|
||||
).toEqual({ path: await fs.realpath(filePath), root: uploadsReal });
|
||||
).toEqual({ path: realpathSync(filePath), root: uploadsReal });
|
||||
expect(
|
||||
resolveLocalPathFromRootsSync({
|
||||
filePath: missingPath,
|
||||
|
||||
@ -68,9 +68,15 @@ describe("secret file helpers", () => {
|
||||
const root = await tempRoot("fs-safe-secret-");
|
||||
const target = path.join(root, "target.txt");
|
||||
const link = path.join(root, "link.txt");
|
||||
const broken = path.join(root, "broken.txt");
|
||||
await fs.writeFile(target, "secret", "utf8");
|
||||
await fs.symlink(target, link);
|
||||
await fs.symlink(path.join(root, "missing.txt"), broken);
|
||||
|
||||
expect(readSecretFileSync(link, "API token")).toBe("secret");
|
||||
expect(tryReadSecretFileSync(link, "API token")).toBe("secret");
|
||||
expectSecretReadCode(() => readSecretFileSync(broken, "API token"), "not-found");
|
||||
expect(tryReadSecretFileSync(broken, "API token")).toBeUndefined();
|
||||
expect(() => readSecretFileSync(link, "API token", { rejectSymlink: true })).toThrow(
|
||||
`API token file at ${link} must not be a symlink.`,
|
||||
);
|
||||
|
||||
181
test/sidecar-lock-regression.test.ts
Normal file
181
test/sidecar-lock-regression.test.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { fileStore } from "../src/file-store.js";
|
||||
import { acquireFileLock } from "../src/file-lock.js";
|
||||
import { configureFsSafeLocks, getFsSafeLockConfig } from "../src/lock-config.js";
|
||||
import { createSidecarLockManager } from "../src/sidecar-lock.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function tempRoot(prefix: string): Promise<string> {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
configureFsSafeLocks({
|
||||
retry: undefined,
|
||||
staleMs: undefined,
|
||||
staleRecovery: "fail-closed",
|
||||
timeoutMs: undefined,
|
||||
});
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("sidecar lock regressions", () => {
|
||||
it("does not delete a fresh sidecar lock during stale reclaim or old release", async () => {
|
||||
const base = await tempRoot("fs-safe-sidecar-token-");
|
||||
const targetPath = path.join(base, "state.json");
|
||||
const lockPath = `${targetPath}.lock`;
|
||||
const manager = createSidecarLockManager(`fs-safe-test-${Date.now()}`);
|
||||
const held = await manager.acquire({
|
||||
targetPath,
|
||||
lockPath,
|
||||
staleMs: 1,
|
||||
payload: async () => ({ createdAt: "2000-01-01T00:00:00.000Z" }),
|
||||
});
|
||||
|
||||
await fsp.writeFile(lockPath, JSON.stringify({ createdAt: new Date().toISOString() }));
|
||||
await held.release();
|
||||
await expect(fsp.readFile(lockPath, "utf8")).resolves.toContain("createdAt");
|
||||
|
||||
let replaced = false;
|
||||
await expect(
|
||||
manager.acquire({
|
||||
targetPath,
|
||||
lockPath,
|
||||
staleMs: 1,
|
||||
timeoutMs: 1,
|
||||
retry: { retries: 0 },
|
||||
payload: async () => ({ createdAt: new Date().toISOString() }),
|
||||
shouldReclaim: async () => {
|
||||
if (!replaced) {
|
||||
replaced = true;
|
||||
await fsp.writeFile(lockPath, JSON.stringify({ createdAt: "2999-01-01T00:00:00.000Z" }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "file_lock_timeout" });
|
||||
await expect(fsp.readFile(lockPath, "utf8")).resolves.toContain("2999");
|
||||
});
|
||||
|
||||
it("keeps internal sidecar lock identity out of user payloads", async () => {
|
||||
const base = await tempRoot("fs-safe-sidecar-payload-");
|
||||
const targetPath = path.join(base, "state.json");
|
||||
const lockPath = `${targetPath}.lock`;
|
||||
const manager = createSidecarLockManager(`fs-safe-payload-test-${Date.now()}`);
|
||||
const lock = await manager.acquire({
|
||||
targetPath,
|
||||
lockPath,
|
||||
staleMs: 1,
|
||||
payload: async () => ({ createdAt: "2999-01-01T00:00:00.000Z", owner: "caller" }),
|
||||
});
|
||||
const raw = await fsp.readFile(lockPath, "utf8");
|
||||
expect(JSON.parse(raw)).toEqual({
|
||||
createdAt: "2999-01-01T00:00:00.000Z",
|
||||
owner: "caller",
|
||||
});
|
||||
await lock.release();
|
||||
|
||||
const payloads: Array<Record<string, unknown> | null> = [];
|
||||
await fsp.writeFile(lockPath, raw, "utf8");
|
||||
await expect(
|
||||
manager.acquire({
|
||||
targetPath,
|
||||
lockPath,
|
||||
staleMs: 1,
|
||||
timeoutMs: 1,
|
||||
retry: { retries: 0 },
|
||||
payload: async () => ({ createdAt: new Date().toISOString() }),
|
||||
shouldReclaim: async ({ payload }) => {
|
||||
payloads.push(payload);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "file_lock_timeout" });
|
||||
expect(payloads).toEqual([{ createdAt: "2999-01-01T00:00:00.000Z", owner: "caller" }]);
|
||||
});
|
||||
|
||||
it("retries when a contended sidecar disappears during stale detection", async () => {
|
||||
const base = await tempRoot("fs-safe-sidecar-vanish-");
|
||||
const targetPath = path.join(base, "state.json");
|
||||
const lockPath = `${targetPath}.lock`;
|
||||
const manager = createSidecarLockManager(`fs-safe-vanish-test-${Date.now()}`);
|
||||
await fsp.writeFile(lockPath, JSON.stringify({ createdAt: "2000-01-01T00:00:00.000Z" }));
|
||||
|
||||
const lock = await manager.acquire({
|
||||
targetPath,
|
||||
lockPath,
|
||||
staleMs: 1,
|
||||
timeoutMs: 1_000,
|
||||
retry: { retries: 3, minTimeout: 1, maxTimeout: 1 },
|
||||
payload: async () => ({ createdAt: new Date().toISOString(), owner: "next" }),
|
||||
shouldReclaim: async () => {
|
||||
await fsp.rm(lockPath, { force: true });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
try {
|
||||
await expect(fsp.readFile(lockPath, "utf8")).resolves.toContain("next");
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans failed sidecar locks and preserves stale corrupt locks", async () => {
|
||||
const base = await tempRoot("fs-safe-sidecar-corrupt-");
|
||||
const targetPath = path.join(base, "state.json");
|
||||
const lockPath = `${targetPath}.lock`;
|
||||
const manager = createSidecarLockManager(`fs-safe-corrupt-test-${Date.now()}`);
|
||||
|
||||
await expect(
|
||||
manager.acquire({
|
||||
targetPath,
|
||||
lockPath,
|
||||
staleMs: 1,
|
||||
payload: async () => {
|
||||
throw new Error("payload failed");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("payload failed");
|
||||
await expect(fsp.stat(lockPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
await fsp.writeFile(lockPath, "{", "utf8");
|
||||
await fsp.utimes(lockPath, new Date(0), new Date(0));
|
||||
await expect(
|
||||
manager.acquire({
|
||||
targetPath,
|
||||
lockPath,
|
||||
staleMs: 1,
|
||||
timeoutMs: 1,
|
||||
retry: { retries: 0 },
|
||||
payload: async () => ({ createdAt: new Date().toISOString() }),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "file_lock_stale" });
|
||||
await expect(fsp.readFile(lockPath, "utf8")).resolves.toBe("{");
|
||||
});
|
||||
|
||||
it("keeps lock config as explicit defaults, not global auto-locking", async () => {
|
||||
const base = await tempRoot("fs-safe-lock-config-");
|
||||
const statePath = path.join(base, "state.json");
|
||||
configureFsSafeLocks({ staleMs: 1, timeoutMs: 1, retry: { retries: 0 } });
|
||||
|
||||
const unlocked = fileStore({ rootDir: base }).json<{ count: number }>("state.json");
|
||||
await unlocked.write({ count: 1 });
|
||||
await expect(fsp.stat(`${statePath}.lock`)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
const config = getFsSafeLockConfig();
|
||||
expect(config.staleRecovery).toBe("fail-closed");
|
||||
expect(config.timeoutMs).toBe(1);
|
||||
|
||||
const lock = await acquireFileLock(path.join(base, "direct.json"), {
|
||||
payload: async () => ({ owner: "direct" }),
|
||||
});
|
||||
await lock.release();
|
||||
});
|
||||
});
|
||||
@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { fileStore, fileStoreSync } from "../src/file-store.js";
|
||||
import { root as openRoot } from "../src/index.js";
|
||||
import { configureFsSafePython, root as openRoot } from "../src/index.js";
|
||||
import {
|
||||
ESCAPING_DIRECTORY_PAYLOADS,
|
||||
ESCAPING_WRITE_PAYLOADS,
|
||||
@ -14,7 +14,6 @@ import {
|
||||
makeTempLayout as makeSecurityTempLayout,
|
||||
POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS,
|
||||
SAFE_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS,
|
||||
SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS,
|
||||
WINDOWS_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS,
|
||||
} from "./helpers/security.js";
|
||||
|
||||
@ -25,6 +24,7 @@ async function makeTempLayout(prefix: string) {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
configureFsSafePython({ mode: "auto", pythonPath: undefined });
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { force: true, recursive: true })));
|
||||
});
|
||||
|
||||
@ -168,6 +168,17 @@ describe("write, move, and delete boundary bypass attempts", () => {
|
||||
await fsp.symlink(layout.outsideFile, path.join(layout.root, "dest-link.txt"), "file");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await expect(safeRoot.move("source-link.txt", "moved.txt")).rejects.toMatchObject({
|
||||
code: "unsupported-platform",
|
||||
});
|
||||
await expect(safeRoot.move("from.txt", "dest-link.txt", { overwrite: true })).rejects.toMatchObject({
|
||||
code: "unsupported-platform",
|
||||
});
|
||||
await expectNoOutsideWrite(layout);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(safeRoot.move("source-link.txt", "moved.txt")).rejects.toBeTruthy();
|
||||
await safeRoot.move("from.txt", "dest-link.txt", { overwrite: true });
|
||||
await expectNoOutsideWrite(layout);
|
||||
@ -186,6 +197,8 @@ describe("write, move, and delete boundary bypass attempts", () => {
|
||||
await expectNoOutsideWrite(layout);
|
||||
});
|
||||
|
||||
// This test exercises many fs writes/reads/mkdirs; bump the timeout for
|
||||
// slow windows fs under parallel test load (default 5s is sometimes tight).
|
||||
it("keeps encoded, backslash, Windows, and UNC-looking write payloads literal and inside root", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-write-encoded-literal");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
@ -202,9 +215,6 @@ describe("write, move, and delete boundary bypass attempts", () => {
|
||||
await expect(safeRoot.write(payload, "rejected"), `write safely rejects ${payload}`).rejects.toBeTruthy();
|
||||
}
|
||||
}
|
||||
for (const payload of SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS) {
|
||||
await expect(safeRoot.write(payload, "rejected"), `write safely rejects ${payload}`).rejects.toBeTruthy();
|
||||
}
|
||||
for (const payload of SAFE_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS) {
|
||||
await expect(safeRoot.mkdir(payload), `mkdir safely rejects ${payload}`).rejects.toBeTruthy();
|
||||
}
|
||||
@ -222,8 +232,29 @@ describe("write, move, and delete boundary bypass attempts", () => {
|
||||
}
|
||||
for (const payload of LITERAL_SUSPICIOUS_DIRECTORY_PAYLOADS) {
|
||||
await safeRoot.mkdir(payload);
|
||||
await expect(safeRoot.list(payload), `list literal ${payload}`).resolves.toBeInstanceOf(Array);
|
||||
if (process.platform === "win32") {
|
||||
// safeRoot.list uses the pinned helper which is unavailable on
|
||||
// windows; verify the directory exists via fsp.stat instead.
|
||||
await expect(fsp.stat(path.join(layout.root, payload)), `created literal ${payload}`)
|
||||
.resolves.toSatisfy((stat) => stat.isDirectory());
|
||||
} else {
|
||||
await expect(safeRoot.list(payload), `list literal ${payload}`).resolves.toBeInstanceOf(Array);
|
||||
}
|
||||
}
|
||||
await expectNoOutsideWrite(layout);
|
||||
}, 15000);
|
||||
|
||||
it.runIf(process.platform !== "win32")("keeps literal '..'-prefixed paths available when the helper is disabled", async () => {
|
||||
configureFsSafePython({ mode: "off" });
|
||||
const layout = await makeTempLayout("fs-safe-write-helper-off-literal");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
await safeRoot.write("..%2fpwned.txt", "literal");
|
||||
await expect(safeRoot.stat("..%2fpwned.txt")).resolves.toMatchObject({ isFile: true });
|
||||
await safeRoot.remove("..%2fpwned.txt");
|
||||
await expect(fsp.stat(path.join(layout.root, "..%2fpwned.txt"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
await expectNoOutsideWrite(layout);
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"types": ["node"],
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user