@openclaw/fs-safe 0.7.0 → 0.7.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 +29 -0
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +0 -1
- package/dist/directory-guard.d.ts +2 -1
- package/dist/directory-guard.d.ts.map +1 -1
- package/dist/directory-guard.js +10 -0
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +72 -26
- package/dist/file-observation.d.ts +9 -0
- package/dist/file-observation.d.ts.map +1 -0
- package/dist/file-observation.js +22 -0
- package/dist/json.js +6 -6
- package/dist/native-pinned-write-windows.js +1 -1
- package/dist/opened-file-failure.d.ts +7 -0
- package/dist/opened-file-failure.d.ts.map +1 -0
- package/dist/opened-file-failure.js +41 -0
- package/dist/opened-realpath.d.ts.map +1 -1
- package/dist/opened-realpath.js +8 -2
- package/dist/pinned-write.js +2 -1
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +75 -58
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +46 -13
- package/dist/sidecar-lock-reclaim.d.ts +16 -5
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +47 -37
- package/dist/sidecar-lock-root.d.ts +3 -0
- package/dist/sidecar-lock-root.d.ts.map +1 -0
- package/dist/sidecar-lock-root.js +76 -0
- package/dist/strict-file-identity.d.ts.map +1 -1
- package/dist/strict-file-identity.js +4 -1
- package/docs/archive.md +12 -4
- package/docs/json.md +3 -0
- package/docs/output.md +6 -3
- package/docs/sidecar-lock.md +56 -4
- package/docs/writing.md +6 -0
- package/package.json +8 -8
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { inspectDirectoryIdentity } from "./directory-guard.js";
|
|
3
|
+
import { FsSafeError } from "./errors.js";
|
|
4
|
+
import { fileObservation } from "./file-observation.js";
|
|
5
|
+
import { isNotFoundPathError } from "./path.js";
|
|
6
|
+
import { resolveRootPath } from "./root-path.js";
|
|
7
|
+
export async function openSidecarRoot(lockRoot, relative, discardObservation, onOpenFailure) {
|
|
8
|
+
const resolved = await lockRoot.resolve(relative);
|
|
9
|
+
const canonicalPath = async () => (await resolveRootPath({
|
|
10
|
+
absolutePath: resolved,
|
|
11
|
+
rootPath: lockRoot.rootReal,
|
|
12
|
+
rootCanonicalPath: lockRoot.rootReal,
|
|
13
|
+
boundaryLabel: "sidecar lock root",
|
|
14
|
+
})).canonicalPath;
|
|
15
|
+
const expectedRealPath = await canonicalPath();
|
|
16
|
+
if (expectedRealPath === lockRoot.rootReal)
|
|
17
|
+
throw new FsSafeError("not-file", "sidecar lock is a directory");
|
|
18
|
+
const parents = [];
|
|
19
|
+
let completeParents = true;
|
|
20
|
+
if (discardObservation) {
|
|
21
|
+
// Follow permitted in-root aliases first; receipts cover canonical ancestry.
|
|
22
|
+
for (let dir = path.dirname(expectedRealPath);; dir = path.dirname(dir)) {
|
|
23
|
+
try {
|
|
24
|
+
parents.push({ dir, stat: await inspectDirectoryIdentity(dir) });
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!isNotFoundPathError(error))
|
|
28
|
+
throw error;
|
|
29
|
+
completeParents = false;
|
|
30
|
+
}
|
|
31
|
+
if (dir === lockRoot.rootReal)
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const observation = fileObservation();
|
|
36
|
+
try {
|
|
37
|
+
const opened = await observation.run(() => lockRoot.open(relative));
|
|
38
|
+
if (opened.realPath !== expectedRealPath) {
|
|
39
|
+
await opened.handle.close().catch(() => undefined);
|
|
40
|
+
throw new FsSafeError("path-mismatch", "sidecar lock path changed during open");
|
|
41
|
+
}
|
|
42
|
+
return opened;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const openFailed = observation.has(error, `open:${resolved}`);
|
|
46
|
+
if (openFailed)
|
|
47
|
+
onOpenFailure?.(error);
|
|
48
|
+
const missing = openFailed && error instanceof FsSafeError && error.code === "not-found";
|
|
49
|
+
if (missing && !discardObservation)
|
|
50
|
+
return null;
|
|
51
|
+
const discardable = observation.has(error, `unlinked:${resolved}`) ||
|
|
52
|
+
(discardObservation === "changed" && observation.has(error, `changed:${resolved}`));
|
|
53
|
+
if (!discardObservation || (!missing && (!completeParents || !discardable)))
|
|
54
|
+
throw error;
|
|
55
|
+
try {
|
|
56
|
+
try {
|
|
57
|
+
const current = await lockRoot.stat(relative);
|
|
58
|
+
if (!current.isFile || current.isSymbolicLink || current.nlink !== 1)
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
catch (probeError) {
|
|
62
|
+
if (!(probeError instanceof FsSafeError && probeError.code === "not-found"))
|
|
63
|
+
throw probeError;
|
|
64
|
+
}
|
|
65
|
+
for (const { dir, stat } of parents)
|
|
66
|
+
await inspectDirectoryIdentity(dir, stat);
|
|
67
|
+
await lockRoot.resolve(relative);
|
|
68
|
+
if (await canonicalPath() === expectedRealPath)
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Keep the original failure when directory or confinement proof fails.
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"strict-file-identity.d.ts","sourceRoot":"","sources":["../src/strict-file-identity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"strict-file-identity.d.ts","sourceRoot":"","sources":["../src/strict-file-identity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAI3C,KAAK,iBAAiB,GAAG,IAAI,CAAC,WAAW,EAAE,KAAK,GAAG,KAAK,CAAC,CAAC;AA+B1D,wBAAsB,mBAAmB,CAAC,CAAC,SAAS,iBAAiB,EACnE,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACzB,QAAQ,CAAC,EAAE,iBAAiB,EAC5B,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED,wBAAgB,uBAAuB,CAAC,CAAC,SAAS,iBAAiB,EACjE,OAAO,EAAE,MAAM,CAAC,EAChB,QAAQ,CAAC,EAAE,iBAAiB,EAC5B,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,CAAC,CAOH"}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { FsSafeError } from "./errors.js";
|
|
2
|
+
import { recordFileObservationFailure } from "./file-observation.js";
|
|
2
3
|
function identityMismatch() {
|
|
3
|
-
|
|
4
|
+
const error = new FsSafeError("path-mismatch", "file identity changed or could not be verified");
|
|
5
|
+
recordFileObservationFailure(error, "identity");
|
|
6
|
+
return error;
|
|
4
7
|
}
|
|
5
8
|
function identityCheck(expected, platform) {
|
|
6
9
|
const known = {};
|
package/docs/archive.md
CHANGED
|
@@ -245,9 +245,13 @@ framing rules before parser normalization:
|
|
|
245
245
|
padded sizes must fit `Number.MAX_SAFE_INTEGER`, even with PAX overrides,
|
|
246
246
|
before member budgets are considered.
|
|
247
247
|
|
|
248
|
-
Framing failures use `ArchiveFormatError("archive-header-invalid")
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
Framing failures use `ArchiveFormatError("archive-header-invalid")`, except
|
|
249
|
+
invalid UTF-8 or nonzero bytes after the first NUL in fixed name, linkname,
|
|
250
|
+
and USTAR prefix fields, which use `ArchiveSecurityError("entry-path")`.
|
|
251
|
+
Missing linknames on links and nonempty linknames on non-links still use the
|
|
252
|
+
format error. PAX `x` and GNU long-name/long-link `L`/`K` payloads retain their
|
|
253
|
+
existing support and metadata limits; the zero-body rule is not applied to all
|
|
254
|
+
non-regular types.
|
|
251
255
|
PAX effective sizes still determine regular-member framing. Admission preserves
|
|
252
256
|
the input bytes, and all entry/path/byte limits and extraction deadlines remain
|
|
253
257
|
in force. Native inspection now completes this admission pass before parsing,
|
|
@@ -411,7 +415,11 @@ regular-file entry into a bounded `Buffer` without extracting a tree. It pins
|
|
|
411
415
|
and privately stages the archive input, rejects link, directory, and duplicate
|
|
412
416
|
entries, verifies ZIP CRC and declared size,
|
|
413
417
|
and throws `ArchiveLimitError` if the requested entry's output exceeds
|
|
414
|
-
`maxBytes`.
|
|
418
|
+
`maxBytes`. ZIP output within that cap must match the declared uncompressed
|
|
419
|
+
size exactly; either a shorter or longer payload throws
|
|
420
|
+
`ArchiveFormatError("archive-header-invalid")` before bytes are returned on
|
|
421
|
+
both JavaScript and native backends.
|
|
422
|
+
For TAR, `maxBytes` applies only to that requested entry: a larger
|
|
415
423
|
unrequested member remains valid within the default archive admission limits.
|
|
416
424
|
TAR traversal uses default entry-count, compressed-input, and metadata limits,
|
|
417
425
|
plus the 768 MiB decoded ceiling derived from default extracted/archive byte
|
package/docs/json.md
CHANGED
|
@@ -144,6 +144,9 @@ rename, so that fallback is temporarily non-atomic while retaining the staged
|
|
|
144
144
|
file's `0600` mode; use the async `writeJson()`/`replaceFileAtomic()` surfaces
|
|
145
145
|
when fallback policy must be explicit.
|
|
146
146
|
|
|
147
|
+
Its private sibling temporary name is independent of the destination basename,
|
|
148
|
+
so staging does not lengthen a valid destination filename.
|
|
149
|
+
|
|
147
150
|
```ts
|
|
148
151
|
writeJsonSync("./prefs.json", { theme: "dark" });
|
|
149
152
|
```
|
package/docs/output.md
CHANGED
|
@@ -58,9 +58,12 @@ through the package's filename sanitizer; `fallbackFileName` supplies the name
|
|
|
58
58
|
when nothing remains. This removes traversal, device-name, and invalid-character
|
|
59
59
|
hazards but does not trim Windows-normalized trailing dots or spaces; reject or
|
|
60
60
|
rewrite those when cross-platform filename uniqueness matters.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
`staging: "workspace"` passes the sanitized basename to the producer.
|
|
62
|
+
`staging: "sibling"` embeds that basename in its randomized temporary name.
|
|
63
|
+
The final target and returned `path` use the destination basename, sanitized
|
|
64
|
+
when needed as described above. Guarded temporary files used only inside
|
|
65
|
+
fs-safe have independent names so their length does not grow with the
|
|
66
|
+
destination basename.
|
|
64
67
|
|
|
65
68
|
## Choosing a staging mode
|
|
66
69
|
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -94,16 +94,21 @@ type FileLockRetryOptions = {
|
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
`payload` is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
|
|
97
|
+
Errors thrown by `payload`, its JSON serialization (including `toJSON`), or
|
|
98
|
+
`parsePayload` propagate unchanged without retrying the callback. Rethrowing an
|
|
99
|
+
error saved from an earlier filesystem operation does not grant retry authority.
|
|
97
100
|
Retry counts must be non-negative safe integers. Retry factors and delays must be finite and non-negative, and when both delay bounds are provided `minTimeout` cannot exceed `maxTimeout`. `timeoutMs` accepts a finite non-negative deadline or positive infinity for an unbounded wait; invalid numeric values reject before filesystem acquisition starts.
|
|
98
101
|
`parsePayload` replaces JSON parsing for legacy or custom sidecars. Its `unknown`
|
|
99
102
|
result is passed to `shouldReclaim` and `shouldRemoveStaleLock`, allowing PID,
|
|
100
103
|
process-start, argv, or role schemas to remain application-owned.
|
|
101
104
|
|
|
102
105
|
On Windows, a pathed `EPERM` from creating or opening the lock file can be a
|
|
103
|
-
short teardown race after another holder unlinks it.
|
|
104
|
-
specific denial at most eight times
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
short teardown race after another holder unlinks it. Both async and sync locks
|
|
107
|
+
retry that specific open denial at most eight times per acquisition, within the
|
|
108
|
+
caller's retry/deadline budget. A parent-directory denial, a callback/read/stat
|
|
109
|
+
failure, or exhaustion of either budget surfaces the original error; a denied
|
|
110
|
+
open is not converted to `file_lock_timeout`. Retrying always requires fresh
|
|
111
|
+
exclusive creation and grants no ownership or removal authority.
|
|
107
112
|
|
|
108
113
|
## Owner-scoped reentrancy
|
|
109
114
|
|
|
@@ -155,6 +160,45 @@ an existing `Root` capability. `lockPath` must resolve inside that root.
|
|
|
155
160
|
Identity-conditioned removal remains the only release and reclaim deletion
|
|
156
161
|
path.
|
|
157
162
|
|
|
163
|
+
An owner can finish releasing while another async acquirer inspects its record.
|
|
164
|
+
Create-only Root writes do not open an existing record merely to inherit its
|
|
165
|
+
mode. Once a pathname sample and opened descriptor agree, a failed acquisition
|
|
166
|
+
snapshot can be discarded only when the original descriptor has exact identity,
|
|
167
|
+
was not observed with multiple links, and proves it was unlinked (`nlink === 0`).
|
|
168
|
+
This includes Windows resolver
|
|
169
|
+
`EPERM`/`EBADF` failures, with evidence captured at the failing operation before
|
|
170
|
+
closing the descriptor. The canonical in-root ancestor chain and Root are
|
|
171
|
+
rechecked; permitted in-root parent symlinks are resolved before those checks.
|
|
172
|
+
|
|
173
|
+
A contending waiter may also encounter a new holder between its pre-open
|
|
174
|
+
pathname inspection and opening the file. It may discard that stale observation
|
|
175
|
+
only when the old sample and opened descriptor have different, strictly known
|
|
176
|
+
regular-file identities, neither was observed with multiple links, and the
|
|
177
|
+
opened descriptor and complete canonical ancestry pass reinspection. This does
|
|
178
|
+
not prove the old pathname sample was unlinked rather than moved. The new
|
|
179
|
+
holder's payload is not read or adopted. Post-create admission never opts into
|
|
180
|
+
this pre-open-change policy.
|
|
181
|
+
|
|
182
|
+
Discarding an acquisition observation is not proof that the pathname is absent:
|
|
183
|
+
another owner may already have created the next record. Every discarded
|
|
184
|
+
observation consumes the normal retry/deadline budget and requires fresh
|
|
185
|
+
exclusive creation. It supplies no release, reclaim, or held-lock authority.
|
|
186
|
+
Generic `Root.open()` and held-owner/reclaim reads still reject failed opens.
|
|
187
|
+
Moving an already-matched pinned descriptor without unlinking it, unknown or
|
|
188
|
+
inexact identities, retargeted ancestors, and unrelated filesystem or caller
|
|
189
|
+
errors fail closed. Failure receipts belong only to the current Root observation,
|
|
190
|
+
including during nested or concurrent acquisitions; historical error identity
|
|
191
|
+
is not changed-file, unlink, or open-denial evidence.
|
|
192
|
+
|
|
193
|
+
After creating a record, the async Root-backed acquirer checks the reopened
|
|
194
|
+
bytes against its exact serialized payload and ownership token. A replacement
|
|
195
|
+
is never adopted; a descriptor observed unlinked at the end of admission is
|
|
196
|
+
never registered as held. Failed admission cleanup retains the original creator
|
|
197
|
+
receipt, so it cannot remove a replacement using a later stat alone. Native
|
|
198
|
+
mode changes the create mechanism, not these Root-backed admission checks.
|
|
199
|
+
Non-Root and synchronous snapshots retain their descriptor/read/path checks
|
|
200
|
+
and do not use the Root opened-path resolver.
|
|
201
|
+
|
|
158
202
|
## Release handle
|
|
159
203
|
|
|
160
204
|
```ts
|
|
@@ -184,6 +228,14 @@ compromise interval treats a thrown verification I/O error as a lost lock and
|
|
|
184
228
|
invokes `onCompromised` once, matching the asynchronous `.catch(() => false)`
|
|
185
229
|
contract. An explicit `verifyStillHeld()` call still propagates that I/O error.
|
|
186
230
|
|
|
231
|
+
Windows synchronous lock parents use the same canonical path spelling as
|
|
232
|
+
`root()`, including short-name expansion. With `lockRoot`, a failed parent
|
|
233
|
+
canonicalization or an out-of-root parent still rejects. Missing-path observations from snapshot
|
|
234
|
+
`lstat`/`open`, and identity-mismatched snapshots, consume the normal retry and
|
|
235
|
+
deadline budget. Errors from descriptor reads/stats or parsing are not treated
|
|
236
|
+
as missing snapshots, even when their code is `ENOENT`. Held verification,
|
|
237
|
+
release, and reclaim do not retry open denials.
|
|
238
|
+
|
|
187
239
|
Both synchronous helpers consume the [process-wide lock defaults](config.md#configurefssafelocks-config).
|
|
188
240
|
A synchronous retry sleep is clamped to the remaining finite deadline, so a long or jittered backoff cannot extend the configured timeout or block forever.
|
|
189
241
|
Per-call options take precedence, including zero values; a per-call `retry`
|
package/docs/writing.md
CHANGED
|
@@ -27,6 +27,9 @@ await fs.mkdir("snapshots/2026/05");
|
|
|
27
27
|
5. Atomically rename the temp file over the destination.
|
|
28
28
|
6. Stat the resulting fd and verify identity.
|
|
29
29
|
|
|
30
|
+
Private sibling temporary names are independent of the destination basename,
|
|
31
|
+
so staging does not add a suffix to an otherwise valid long filename.
|
|
32
|
+
|
|
30
33
|
A failure before the final rename leaves the destination at its previous
|
|
31
34
|
contents. A successful rename publishes the complete replacement. This
|
|
32
35
|
old-or-new guarantee does not apply to `append()` or `openWritable()`, which
|
|
@@ -86,6 +89,9 @@ alone is never proof that the name still refers to the expected file.
|
|
|
86
89
|
### `fs.create(rel, data, options?)`
|
|
87
90
|
|
|
88
91
|
Don't-clobber variant of `write()`. Throws `already-exists` if the target is there.
|
|
92
|
+
Create-only preflight preserves boundary, alias, hardlink, and type checks without
|
|
93
|
+
opening an existing target to inherit its mode; a fresh file uses the requested
|
|
94
|
+
mode or the normal new-file default.
|
|
89
95
|
|
|
90
96
|
```ts
|
|
91
97
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"filesystem",
|
|
@@ -149,13 +149,13 @@
|
|
|
149
149
|
"crabbox:warmup": "crabbox warmup"
|
|
150
150
|
},
|
|
151
151
|
"optionalDependencies": {
|
|
152
|
-
"@openclaw/fs-safe-darwin-arm64": "0.7.
|
|
153
|
-
"@openclaw/fs-safe-darwin-x64": "0.7.
|
|
154
|
-
"@openclaw/fs-safe-linux-arm64-gnu": "0.7.
|
|
155
|
-
"@openclaw/fs-safe-linux-arm64-musl": "0.7.
|
|
156
|
-
"@openclaw/fs-safe-linux-x64-gnu": "0.7.
|
|
157
|
-
"@openclaw/fs-safe-linux-x64-musl": "0.7.
|
|
158
|
-
"@openclaw/fs-safe-win32-x64-msvc": "0.7.
|
|
152
|
+
"@openclaw/fs-safe-darwin-arm64": "0.7.1",
|
|
153
|
+
"@openclaw/fs-safe-darwin-x64": "0.7.1",
|
|
154
|
+
"@openclaw/fs-safe-linux-arm64-gnu": "0.7.1",
|
|
155
|
+
"@openclaw/fs-safe-linux-arm64-musl": "0.7.1",
|
|
156
|
+
"@openclaw/fs-safe-linux-x64-gnu": "0.7.1",
|
|
157
|
+
"@openclaw/fs-safe-linux-x64-musl": "0.7.1",
|
|
158
|
+
"@openclaw/fs-safe-win32-x64-msvc": "0.7.1",
|
|
159
159
|
"jszip": "^3.10.1",
|
|
160
160
|
"tar": "7.5.22"
|
|
161
161
|
},
|