@openclaw/fs-safe 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +39 -2
- package/dist/absolute-path.d.ts +14 -0
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +203 -2
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +2 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +81 -7
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +25 -42
- package/dist/bounded-read-stream.d.ts +8 -0
- package/dist/bounded-read-stream.d.ts.map +1 -0
- package/dist/bounded-read-stream.js +20 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/directory-guard.d.ts +18 -0
- package/dist/directory-guard.d.ts.map +1 -0
- package/dist/directory-guard.js +70 -0
- package/dist/file-lock.d.ts +4 -2
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +13 -2
- package/dist/file-store-boundary.d.ts +2 -4
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +11 -7
- package/dist/file-store-prune.d.ts +12 -0
- package/dist/file-store-prune.d.ts.map +1 -0
- package/dist/file-store-prune.js +86 -0
- package/dist/file-store-source.d.ts +5 -0
- package/dist/file-store-source.d.ts.map +1 -0
- package/dist/file-store-source.js +30 -0
- package/dist/file-store.d.ts +2 -6
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +60 -53
- package/dist/guarded-mkdir.d.ts +6 -0
- package/dist/guarded-mkdir.d.ts.map +1 -0
- package/dist/guarded-mkdir.js +45 -0
- package/dist/guarded-mutation.d.ts +33 -0
- package/dist/guarded-mutation.d.ts.map +1 -0
- package/dist/guarded-mutation.js +76 -0
- package/dist/home-dir.d.ts.map +1 -1
- package/dist/home-dir.js +38 -33
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/json-document-store.d.ts +2 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +7 -3
- package/dist/json-durable-queue.d.ts +45 -0
- package/dist/json-durable-queue.d.ts.map +1 -0
- package/dist/json-durable-queue.js +200 -0
- package/dist/json.d.ts +35 -5
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +69 -2
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +17 -3
- package/dist/lock-config.d.ts +10 -0
- package/dist/lock-config.d.ts.map +1 -0
- package/dist/lock-config.js +12 -0
- package/dist/move-path.d.ts +1 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +195 -7
- package/dist/output.d.ts +13 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +69 -0
- package/dist/path-stat.d.ts +4 -0
- package/dist/path-stat.d.ts.map +1 -0
- package/dist/path-stat.js +15 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +4 -3
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +44 -19
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +28 -15
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +52 -8
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +5 -4
- package/dist/replace-file.d.ts +1 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +93 -31
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +62 -53
- package/dist/safe-path-segment.d.ts +9 -0
- package/dist/safe-path-segment.d.ts.map +1 -0
- package/dist/safe-path-segment.js +51 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +28 -7
- package/dist/sibling-temp.d.ts +1 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +32 -7
- package/dist/sidecar-lock.d.ts +2 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +93 -13
- package/dist/store.d.ts +1 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1 -0
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +7 -4
- package/dist/test-hooks.d.ts +6 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/text-atomic.d.ts +7 -0
- package/dist/text-atomic.d.ts.map +1 -1
- package/dist/text-atomic.js +3 -2
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +48 -13
- package/docs/advanced.md +10 -0
- package/docs/atomic.md +33 -5
- package/docs/config.md +32 -3
- package/docs/index.md +3 -2
- package/docs/json-store.md +4 -0
- package/docs/json.md +34 -0
- package/docs/output.md +92 -0
- package/docs/sidecar-lock.md +13 -12
- package/docs/store.md +33 -0
- package/docs/temp.md +10 -2
- package/docs/testing.md +10 -0
- package/package.json +7 -2
package/docs/json.md
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
readJsonIfExists,
|
|
10
10
|
readJsonSync,
|
|
11
11
|
tryReadJsonSync,
|
|
12
|
+
readRootJsonSync,
|
|
13
|
+
readRootJsonObjectSync,
|
|
14
|
+
readRootStructuredFileSync,
|
|
12
15
|
writeJson,
|
|
13
16
|
writeJsonSync,
|
|
14
17
|
JsonFileReadError,
|
|
@@ -69,6 +72,32 @@ Synchronous strict reader. Throws `JsonFileReadError` on missing or invalid inpu
|
|
|
69
72
|
|
|
70
73
|
Synchronous, generic, lenient. Returns `T | null`. Useful in boot paths where you want a typed result without async.
|
|
71
74
|
|
|
75
|
+
## Root-bounded structured reads
|
|
76
|
+
|
|
77
|
+
Use the root-bounded readers when you already have a trusted root directory and
|
|
78
|
+
a caller-controlled relative path, but you only need one synchronous structured
|
|
79
|
+
read instead of a full `root()` handle.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const result = readRootJsonObjectSync({
|
|
83
|
+
rootDir: "/safe/workspace",
|
|
84
|
+
relativePath: "plugin/openclaw.plugin.json",
|
|
85
|
+
boundaryLabel: "plugin manifest",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!result.ok) {
|
|
89
|
+
// reason is "open", "parse", or "invalid"
|
|
90
|
+
throw new Error(result.reason);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(result.value);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`readRootJsonSync()` parses any JSON value. `readRootJsonObjectSync()` only
|
|
97
|
+
accepts objects. `readRootStructuredFileSync()` accepts a custom parser and
|
|
98
|
+
validator so callers can layer JSON5, TOML, YAML, or domain-specific validation
|
|
99
|
+
without making `fs-safe` depend on those formats.
|
|
100
|
+
|
|
72
101
|
## Writing
|
|
73
102
|
|
|
74
103
|
### `writeJson(filePath, value, options?)`
|
|
@@ -86,9 +115,14 @@ type WriteJsonOptions = {
|
|
|
86
115
|
mode?: number; // file mode (default 0o600)
|
|
87
116
|
dirMode?: number; // mode for parent dirs created on demand
|
|
88
117
|
trailingNewline?: boolean; // append "\n" if missing (default false)
|
|
118
|
+
durable?: boolean; // default true; false skips temp/parent fsync
|
|
89
119
|
};
|
|
90
120
|
```
|
|
91
121
|
|
|
122
|
+
`durable: false` preserves atomic temp-file replacement but skips the temp-file
|
|
123
|
+
and parent-directory `fsync` calls. Use it only for reconstructible JSON state
|
|
124
|
+
where lower latency matters more than crash-durability.
|
|
125
|
+
|
|
92
126
|
### `writeJsonSync(pathname, data)`
|
|
93
127
|
|
|
94
128
|
Synchronous variant. Convenience wrapper that uses the sync atomic-write path with sensible defaults.
|
package/docs/output.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# External outputs
|
|
2
|
+
|
|
3
|
+
`@openclaw/fs-safe/output` covers the case where another library insists on
|
|
4
|
+
writing to an absolute path you give it. Browser downloads, renderers, media
|
|
5
|
+
tools, and native libraries often have this shape:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { writeExternalFileWithinRoot } from "@openclaw/fs-safe/output";
|
|
9
|
+
|
|
10
|
+
await writeExternalFileWithinRoot({
|
|
11
|
+
rootDir: "/srv/workspace/downloads",
|
|
12
|
+
path: "reports/today.pdf",
|
|
13
|
+
write: async (filePath) => {
|
|
14
|
+
await download.saveAs(filePath);
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The external writer never receives the final destination path. It receives a
|
|
20
|
+
private temp file path instead. After the callback returns, fs-safe copies that
|
|
21
|
+
staged file into the requested target through the same root boundary used by
|
|
22
|
+
`Root.copyIn()`.
|
|
23
|
+
|
|
24
|
+
## Signature
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
function writeExternalFileWithinRoot<T = void>(
|
|
28
|
+
options: ExternalFileWriteOptions<T>,
|
|
29
|
+
): Promise<ExternalFileWriteResult<T>>;
|
|
30
|
+
|
|
31
|
+
type ExternalFileWriteOptions<T = void> = {
|
|
32
|
+
rootDir: string;
|
|
33
|
+
path: string; // relative or absolute, but must stay under rootDir
|
|
34
|
+
write: (filePath: string) => Promise<T>;
|
|
35
|
+
maxBytes?: number;
|
|
36
|
+
mode?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type ExternalFileWriteResult<T = void> = {
|
|
40
|
+
path: string; // final absolute path under the canonical root
|
|
41
|
+
result: T; // value returned by write()
|
|
42
|
+
};
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The requested `path` must name a file. Missing destination parents are created
|
|
46
|
+
by the helper because the operation is "produce this output file under the
|
|
47
|
+
root"; callers should choose the filename before calling this API.
|
|
48
|
+
|
|
49
|
+
Use `maxBytes` when the external producer can create arbitrarily large files.
|
|
50
|
+
Use `mode` when the finalized file needs a specific POSIX mode. Both are
|
|
51
|
+
enforced during the `Root.copyIn()` finalization step, after the external writer
|
|
52
|
+
has produced the staged file and before the final target is committed.
|
|
53
|
+
|
|
54
|
+
## Why not pass the final path to the library?
|
|
55
|
+
|
|
56
|
+
If a target parent can be swapped after validation, handing an external library
|
|
57
|
+
the final path can make the library write outside the intended root before
|
|
58
|
+
fs-safe has a chance to finalize or reject the operation. This helper stages in
|
|
59
|
+
a private temp workspace first, then finalizes with `Root.copyIn()`. That keeps
|
|
60
|
+
the trust-boundary write inside fs-safe's root-aware copy/atomic-write path.
|
|
61
|
+
|
|
62
|
+
## Browser download example
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const outputPath = requestedOutputPath || sanitizeBrowserSuggestedName(suggestedFilename);
|
|
66
|
+
|
|
67
|
+
await writeExternalFileWithinRoot({
|
|
68
|
+
rootDir: downloadsRoot,
|
|
69
|
+
path: outputPath,
|
|
70
|
+
maxBytes: 512 * 1024 * 1024,
|
|
71
|
+
write: async (filePath) => {
|
|
72
|
+
await download.saveAs(filePath);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The chosen path may be absolute if it is already inside `downloadsRoot`, or
|
|
78
|
+
relative to `downloadsRoot`. Traversal, symlink parent escapes, hardlinked final
|
|
79
|
+
targets, over-large staged files, and missing temp files surface as
|
|
80
|
+
`FsSafeError`s.
|
|
81
|
+
|
|
82
|
+
This helper is not the right fit when the final filename depends on inspecting
|
|
83
|
+
the produced bytes. In that case, write to a private temp workspace, sniff or
|
|
84
|
+
validate the file, choose the final name, then copy or write into the root with
|
|
85
|
+
the normal root APIs.
|
|
86
|
+
|
|
87
|
+
## See also
|
|
88
|
+
|
|
89
|
+
- [Root writes](writing.md) — `write`, `copyIn`, `move`, and `mkdir`.
|
|
90
|
+
- [Temp workspaces](temp.md) — private scratch directories for longer workflows.
|
|
91
|
+
- [`pathScope()`](path-scope.md) — validation-only helper when you must pass an
|
|
92
|
+
absolute path directly to another library.
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# File lock
|
|
2
2
|
|
|
3
|
-
`acquireFileLock()` and `withFileLock()` provide a cross-process file lock with retry
|
|
3
|
+
`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.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import { acquireFileLock } from "@openclaw/fs-safe/file-lock";
|
|
@@ -19,9 +19,9 @@ try {
|
|
|
19
19
|
|
|
20
20
|
## Why sidecar?
|
|
21
21
|
|
|
22
|
-
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
|
|
22
|
+
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.
|
|
23
23
|
|
|
24
|
-
The library installs a `process.on("exit")` handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars.
|
|
24
|
+
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.
|
|
25
25
|
|
|
26
26
|
## API
|
|
27
27
|
|
|
@@ -48,9 +48,10 @@ function createFileLockManager(key: string): FileLockManager;
|
|
|
48
48
|
type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
49
49
|
managerKey?: string; // optional in-process manager namespace
|
|
50
50
|
lockPath?: string; // override; defaults to `${targetPath}.lock`
|
|
51
|
-
staleMs
|
|
51
|
+
staleMs?: number; // default 30_000
|
|
52
52
|
timeoutMs?: number; // overall acquire deadline; default unbounded
|
|
53
53
|
retry?: FileLockRetryOptions;
|
|
54
|
+
staleRecovery?: "fail-closed"; // default
|
|
54
55
|
allowReentrant?: boolean; // if this process already holds it, increment a count instead of failing
|
|
55
56
|
payload: () => TPayload | Promise<TPayload>;
|
|
56
57
|
shouldReclaim?: (params: {
|
|
@@ -100,7 +101,7 @@ try {
|
|
|
100
101
|
}
|
|
101
102
|
```
|
|
102
103
|
|
|
103
|
-
If your process dies before `release()` runs and skips the exit handler, the
|
|
104
|
+
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.
|
|
104
105
|
|
|
105
106
|
## `withFileLock` — common shape made one-liner
|
|
106
107
|
|
|
@@ -139,9 +140,9 @@ await handle.release();
|
|
|
139
140
|
await locks.drain();
|
|
140
141
|
```
|
|
141
142
|
|
|
142
|
-
##
|
|
143
|
+
## Stale policy: `shouldReclaim`
|
|
143
144
|
|
|
144
|
-
The default policy
|
|
145
|
+
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":
|
|
145
146
|
|
|
146
147
|
```ts
|
|
147
148
|
import { kill } from "node:process";
|
|
@@ -155,26 +156,26 @@ const handle = await acquireFileLock(targetPath, {
|
|
|
155
156
|
if (!Number.isFinite(pid)) return true;
|
|
156
157
|
try {
|
|
157
158
|
kill(pid, 0);
|
|
158
|
-
return false; // process still alive —
|
|
159
|
+
return false; // process still alive — keep waiting
|
|
159
160
|
} catch {
|
|
160
|
-
return true; // process gone —
|
|
161
|
+
return true; // process gone — fail closed for recovery
|
|
161
162
|
}
|
|
162
163
|
},
|
|
163
164
|
});
|
|
164
165
|
```
|
|
165
166
|
|
|
166
|
-
`heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case).
|
|
167
|
+
`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.
|
|
167
168
|
|
|
168
169
|
## What sidecar locks defend against
|
|
169
170
|
|
|
170
171
|
- **Two processes writing the same file at once.** `acquire` serializes the critical section.
|
|
171
|
-
- **
|
|
172
|
+
- **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.
|
|
172
173
|
- **Race between simultaneous acquire attempts.** `O_CREAT | O_EXCL` ensures one wins.
|
|
173
174
|
|
|
174
175
|
## What they do **not** defend against
|
|
175
176
|
|
|
176
177
|
- **Misbehaving holders that ignore the lock.** Locks are advisory — only callers that go through `acquire` are bound.
|
|
177
|
-
- **
|
|
178
|
+
- **Automatic stale lock deletion.** If a process crashes, use the payload and your own supervisor/process table to decide when to remove the sidecar.
|
|
178
179
|
- **Multi-host coordination over network filesystems.** Behavior depends on the underlying filesystem's `O_EXCL` semantics; treat as best-effort.
|
|
179
180
|
|
|
180
181
|
## Common patterns
|
package/docs/store.md
CHANGED
|
@@ -9,9 +9,13 @@ The `store` subpath bundles two managed wrappers around the same safe-write prim
|
|
|
9
9
|
|
|
10
10
|
```ts
|
|
11
11
|
import {
|
|
12
|
+
ensureJsonDurableQueueDirs,
|
|
12
13
|
fileStore,
|
|
13
14
|
fileStoreSync,
|
|
14
15
|
jsonStore,
|
|
16
|
+
loadPendingJsonDurableQueueEntries,
|
|
17
|
+
resolveJsonDurableQueueEntryPaths,
|
|
18
|
+
writeJsonDurableQueueEntry,
|
|
15
19
|
type FileStore,
|
|
16
20
|
type FileStoreOptions,
|
|
17
21
|
type FileStoreSync,
|
|
@@ -25,6 +29,7 @@ import {
|
|
|
25
29
|
| [`fileStore()`](file-store.md) | Multi-file directories with safe relative paths, size limits, atomic replacement, stream writes, copy-in, and TTL-based pruning. |
|
|
26
30
|
| `fileStoreSync()` | Synchronous variant of `fileStore()` for places that genuinely cannot await. |
|
|
27
31
|
| [`jsonStore()`](json-store.md) | A single keyed JSON state file with explicit fallback, atomic writes, and optional sidecar locking around read-modify-write updates. |
|
|
32
|
+
| Durable JSON queue helpers | Append/load/ack JSON entry files using atomic writes and delivered markers. |
|
|
28
33
|
| [Private file-store mode](private-file-store.md) | `fileStore({ private: true })` for credentials, tokens, and per-agent state at `0600` files under `0700` directories. |
|
|
29
34
|
|
|
30
35
|
`fileStore().json("rel.json")` and `jsonStore({ filePath })` are intentionally separate primitives. Use `fileStore().json(...)` when JSON state lives alongside other files in the same managed directory; use `jsonStore({ filePath })` when you have a single absolute path and want the keyed JSON shape directly.
|
|
@@ -34,6 +39,34 @@ import {
|
|
|
34
39
|
- **Multi-file directory under one root** — reach for `fileStore()`. It exposes `write`, `writeJson`, `writeText`, `writeStream`, `read*`, `open`, `copyIn`, `remove`, and `pruneExpired` against safe relative paths.
|
|
35
40
|
- **One JSON state file** — reach for `jsonStore({ filePath })`. Its `update()` and `updateOr()` methods cover the merge-into-defaults and read-modify-write cases.
|
|
36
41
|
- **Credentials or tokens** — pass `private: true` to `fileStore()`. Same store shape; writes route through the secret-file atomic path with `0600`/`0700` permissions.
|
|
42
|
+
- **Durable work queues** — use the durable JSON queue helpers when each work item is a standalone JSON file and acknowledgement is represented by moving it through a short-lived `.delivered` marker.
|
|
43
|
+
|
|
44
|
+
## Durable JSON queues
|
|
45
|
+
|
|
46
|
+
The durable queue helpers are intentionally low-level. They do not decide retry,
|
|
47
|
+
dedupe, or recovery policy; they just provide the filesystem mechanics that
|
|
48
|
+
several queue implementations otherwise rewrite by hand.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
await ensureJsonDurableQueueDirs({ queueDir, failedDir });
|
|
52
|
+
|
|
53
|
+
const paths = resolveJsonDurableQueueEntryPaths(queueDir, id);
|
|
54
|
+
await writeJsonDurableQueueEntry({
|
|
55
|
+
filePath: paths.jsonPath,
|
|
56
|
+
entry,
|
|
57
|
+
tempPrefix: "queue",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const pending = await loadPendingJsonDurableQueueEntries({ queueDir, tempPrefix: "queue" });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`id` must be a single safe path segment: non-empty, not dot-prefixed, and made
|
|
64
|
+
from letters, numbers, `_`, `-`, and `.`. Slashes, backslashes, NUL bytes, `.`,
|
|
65
|
+
and `..` are rejected.
|
|
66
|
+
|
|
67
|
+
Use `ackJsonDurableQueueEntry()` after durable processing succeeds and
|
|
68
|
+
`moveJsonDurableQueueEntryToFailed()` when the caller wants to quarantine an
|
|
69
|
+
entry for inspection.
|
|
37
70
|
|
|
38
71
|
## Related pages
|
|
39
72
|
|
package/docs/temp.md
CHANGED
|
@@ -175,9 +175,15 @@ const result = await writeSiblingTempFile<string>({
|
|
|
175
175
|
|
|
176
176
|
`writeSiblingTempFile` chooses a random sibling name in `dir`, calls your `writeTemp()` callback, validates that `resolveFinalPath(result)` is still inside that same directory, and renames the temp file there.
|
|
177
177
|
|
|
178
|
+
By default it preserves the historical private-helper behavior of chmodding
|
|
179
|
+
`dir` to `dirMode` (default `0o700`). Pass `chmodDir: false` when the directory
|
|
180
|
+
is a public staging/output path whose existing mode must be preserved.
|
|
181
|
+
|
|
178
182
|
### `writeViaSiblingTempPath`
|
|
179
183
|
|
|
180
|
-
A higher-level convenience
|
|
184
|
+
A higher-level convenience for callback-based producers. The callback writes to
|
|
185
|
+
a private temp path, then the helper copies the result into `targetPath` through
|
|
186
|
+
the root boundary:
|
|
181
187
|
|
|
182
188
|
```ts
|
|
183
189
|
import { writeViaSiblingTempPath } from "@openclaw/fs-safe/advanced";
|
|
@@ -191,7 +197,9 @@ await writeViaSiblingTempPath({
|
|
|
191
197
|
});
|
|
192
198
|
```
|
|
193
199
|
|
|
194
|
-
If `replaceFileAtomic` does what you need, prefer that
|
|
200
|
+
If `replaceFileAtomic` does what you need, prefer that. Use
|
|
201
|
+
`writeViaSiblingTempPath` when the producer needs a concrete temp pathname but
|
|
202
|
+
the final destination still needs root-boundary checks.
|
|
195
203
|
|
|
196
204
|
## Secure temp root
|
|
197
205
|
|
package/docs/testing.md
CHANGED
|
@@ -159,6 +159,16 @@ Run only the security boundary corpus while iterating on root/path/archive/temp
|
|
|
159
159
|
pnpm test:security
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
+
Run the static primitive guard after changing low-level filesystem helpers:
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
pnpm lint:fs-boundary
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
It catches the specific raw fallback patterns that previously led to
|
|
169
|
+
check-then-use bugs, such as direct copy-to-destination fallback and sync temp
|
|
170
|
+
workspace reads that bypass pinned file descriptors.
|
|
171
|
+
|
|
162
172
|
`pnpm check` also runs `pnpm lint:file-size`. New source and test files should stay under 500 lines. Existing larger files have explicit budgets in `scripts/check-file-size.mjs`; do not increase those budgets as part of unrelated work.
|
|
163
173
|
|
|
164
174
|
## See also
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
"types": "./dist/path.d.ts",
|
|
37
37
|
"default": "./dist/path.js"
|
|
38
38
|
},
|
|
39
|
+
"./output": {
|
|
40
|
+
"types": "./dist/output.d.ts",
|
|
41
|
+
"default": "./dist/output.js"
|
|
42
|
+
},
|
|
39
43
|
"./advanced": {
|
|
40
44
|
"types": "./dist/advanced.d.ts",
|
|
41
45
|
"default": "./dist/advanced.js"
|
|
@@ -97,11 +101,12 @@
|
|
|
97
101
|
"benchmark": "node scripts/benchmark.mjs",
|
|
98
102
|
"build": "tsc -p tsconfig.json",
|
|
99
103
|
"lint:file-size": "node scripts/check-file-size.mjs",
|
|
104
|
+
"lint:fs-boundary": "node scripts/check-fs-boundary-primitives.mjs",
|
|
100
105
|
"prepack": "node scripts/prepack-build.mjs",
|
|
101
106
|
"test": "vitest run",
|
|
102
107
|
"test:coverage": "vitest run --coverage",
|
|
103
108
|
"test:security": "vitest run test/fs-safe.test.ts test/openclaw-read-bypass-parity.test.ts test/openclaw-write-bypass-parity.test.ts test/additional-bypass-parity.test.ts test/adversarial-boundary-payloads.test.ts",
|
|
104
|
-
"check": "pnpm lint:file-size && pnpm build && pnpm test",
|
|
109
|
+
"check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm test",
|
|
105
110
|
"docs:site": "node scripts/build-docs-site.mjs"
|
|
106
111
|
},
|
|
107
112
|
"optionalDependencies": {
|