@openclaw/fs-safe 0.7.0 → 0.7.2

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/archive.d.ts.map +1 -1
  3. package/dist/archive.js +0 -1
  4. package/dist/directory-guard.d.ts +2 -1
  5. package/dist/directory-guard.d.ts.map +1 -1
  6. package/dist/directory-guard.js +10 -0
  7. package/dist/file-lock-sync.d.ts.map +1 -1
  8. package/dist/file-lock-sync.js +72 -26
  9. package/dist/file-observation.d.ts +9 -0
  10. package/dist/file-observation.d.ts.map +1 -0
  11. package/dist/file-observation.js +22 -0
  12. package/dist/guarded-mutation.d.ts +1 -0
  13. package/dist/guarded-mutation.d.ts.map +1 -1
  14. package/dist/guarded-mutation.js +2 -0
  15. package/dist/json.js +6 -6
  16. package/dist/move-path.d.ts +2 -0
  17. package/dist/move-path.d.ts.map +1 -1
  18. package/dist/move-path.js +21 -3
  19. package/dist/native-pinned-write-windows.js +1 -1
  20. package/dist/opened-file-failure.d.ts +7 -0
  21. package/dist/opened-file-failure.d.ts.map +1 -0
  22. package/dist/opened-file-failure.js +41 -0
  23. package/dist/opened-realpath.d.ts.map +1 -1
  24. package/dist/opened-realpath.js +8 -2
  25. package/dist/pinned-write.js +2 -1
  26. package/dist/root-impl.d.ts.map +1 -1
  27. package/dist/root-impl.js +75 -58
  28. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  29. package/dist/sidecar-lock-acquire.js +46 -13
  30. package/dist/sidecar-lock-reclaim.d.ts +16 -5
  31. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  32. package/dist/sidecar-lock-reclaim.js +47 -37
  33. package/dist/sidecar-lock-root.d.ts +3 -0
  34. package/dist/sidecar-lock-root.d.ts.map +1 -0
  35. package/dist/sidecar-lock-root.js +76 -0
  36. package/dist/strict-file-identity.d.ts.map +1 -1
  37. package/dist/strict-file-identity.js +4 -1
  38. package/docs/archive.md +12 -4
  39. package/docs/atomic.md +34 -0
  40. package/docs/json.md +3 -0
  41. package/docs/output.md +6 -3
  42. package/docs/sidecar-lock.md +56 -4
  43. package/docs/writing.md +6 -0
  44. 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;AAG3C,KAAK,iBAAiB,GAAG,IAAI,CAAC,WAAW,EAAE,KAAK,GAAG,KAAK,CAAC,CAAC;AA6B1D,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
+ {"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
- return new FsSafeError("path-mismatch", "file identity changed or could not be verified");
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")`. PAX `x`
249
- and GNU long-name/long-link `L`/`K` payloads retain their existing support and
250
- metadata limits; the zero-body rule is not applied to all non-regular types.
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`. For TAR, `maxBytes` applies only to that requested entry: a larger
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/atomic.md CHANGED
@@ -209,6 +209,40 @@ throws `ESTALE` before commit when possible. If the destination has already
209
209
  been committed, cleanup still preserves the changed source entries and throws
210
210
  `ESTALE`.
211
211
 
212
+ ### Final rename authorization
213
+
214
+ Pass `assertBeforeRename` when a move depends on a revocable lease or another
215
+ caller-owned authorization. The helper captures this callback when called and
216
+ runs it synchronously after asynchronous preparation and directory checks,
217
+ immediately before each rename is dispatched:
218
+
219
+ ```ts
220
+ type MovePathWithCopyFallbackOptions = {
221
+ from: string;
222
+ sourceHardlinks?: "allow" | "reject";
223
+ to: string;
224
+ assertBeforeRename?: () => void;
225
+ };
226
+ ```
227
+
228
+ Throw to refuse publication. The original error is propagated, including errors
229
+ with `EXDEV` or `EPERM` codes; an authorization failure never starts a copy
230
+ fallback. A genuine rename failure may still require a second authorization
231
+ check before publishing the staged copy.
232
+
233
+ The callback must return `undefined` synchronously. Returning a Promise, thenable,
234
+ or any other value refuses the rename with a `TypeError`; rejected asynchronous
235
+ results are consumed without authorizing the operation. Perform asynchronous
236
+ policy checks before calling the helper and use this callback to recheck the
237
+ current owner at the mutation boundary.
238
+
239
+ A refused rename leaves the source and destination unchanged; any private
240
+ staged copy follows the helper's normal cleanup. The check does not cancel an
241
+ already-dispatched rename or make an external lease store atomic with the
242
+ filesystem. Cleanup after a successful move retains the existing source-identity
243
+ checks. Omitting the
244
+ callback preserves the usual move behavior.
245
+
212
246
  ## Difference from `root()`
213
247
 
214
248
  | `Root` methods | `atomic` helpers |
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
- The same sanitized basename is used for producer staging, guarded internal
62
- temps, the final rename target, and the returned `path`; raw and staged names
63
- never diverge.
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
 
@@ -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. The async lock retries that
104
- specific denial at most eight times. A parent-directory denial, a denial from a
105
- callback, or a ninth consecutive lock-file denial surfaces as the original
106
- `EPERM`; it is not converted to `file_lock_timeout`.
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.0",
3
+ "version": "0.7.2",
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.0",
153
- "@openclaw/fs-safe-darwin-x64": "0.7.0",
154
- "@openclaw/fs-safe-linux-arm64-gnu": "0.7.0",
155
- "@openclaw/fs-safe-linux-arm64-musl": "0.7.0",
156
- "@openclaw/fs-safe-linux-x64-gnu": "0.7.0",
157
- "@openclaw/fs-safe-linux-x64-musl": "0.7.0",
158
- "@openclaw/fs-safe-win32-x64-msvc": "0.7.0",
152
+ "@openclaw/fs-safe-darwin-arm64": "0.7.2",
153
+ "@openclaw/fs-safe-darwin-x64": "0.7.2",
154
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.7.2",
155
+ "@openclaw/fs-safe-linux-arm64-musl": "0.7.2",
156
+ "@openclaw/fs-safe-linux-x64-gnu": "0.7.2",
157
+ "@openclaw/fs-safe-linux-x64-musl": "0.7.2",
158
+ "@openclaw/fs-safe-win32-x64-msvc": "0.7.2",
159
159
  "jszip": "^3.10.1",
160
160
  "tar": "7.5.22"
161
161
  },