@openclaw/fs-safe 0.11.0 → 0.12.0

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 (116) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +4 -2
  3. package/dist/advanced.d.ts +1 -1
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -1
  6. package/dist/archive-entry.d.ts.map +1 -1
  7. package/dist/archive-entry.js +4 -3
  8. package/dist/archive-gzip-tail.d.ts.map +1 -1
  9. package/dist/archive-gzip-tail.js +13 -9
  10. package/dist/archive-read.d.ts.map +1 -1
  11. package/dist/archive-read.js +37 -33
  12. package/dist/archive-zip-names.d.ts.map +1 -1
  13. package/dist/archive-zip-names.js +17 -5
  14. package/dist/bounded-read.js +2 -2
  15. package/dist/device-path.d.ts.map +1 -1
  16. package/dist/device-path.js +5 -3
  17. package/dist/file-hash.d.ts.map +1 -1
  18. package/dist/file-hash.js +9 -2
  19. package/dist/file-store-prune.d.ts.map +1 -1
  20. package/dist/file-store-prune.js +9 -1
  21. package/dist/filename.d.ts +1 -0
  22. package/dist/filename.d.ts.map +1 -1
  23. package/dist/filename.js +30 -13
  24. package/dist/guarded-mkdir.d.ts +2 -0
  25. package/dist/guarded-mkdir.d.ts.map +1 -1
  26. package/dist/guarded-mkdir.js +50 -15
  27. package/dist/guest.d.ts.map +1 -1
  28. package/dist/guest.js +12 -4
  29. package/dist/install-path.d.ts +6 -0
  30. package/dist/install-path.d.ts.map +1 -1
  31. package/dist/install-path.js +13 -0
  32. package/dist/json-durable-queue-ownership.d.ts +2 -0
  33. package/dist/json-durable-queue-ownership.d.ts.map +1 -1
  34. package/dist/json-durable-queue-ownership.js +47 -3
  35. package/dist/json-durable-queue-read.d.ts +6 -0
  36. package/dist/json-durable-queue-read.d.ts.map +1 -0
  37. package/dist/json-durable-queue-read.js +59 -0
  38. package/dist/json-durable-queue.d.ts +1 -1
  39. package/dist/json-durable-queue.d.ts.map +1 -1
  40. package/dist/json-durable-queue.js +37 -80
  41. package/dist/native-binding.d.ts +7 -0
  42. package/dist/native-binding.d.ts.map +1 -1
  43. package/dist/path-scope-lexical.d.ts +14 -0
  44. package/dist/path-scope-lexical.d.ts.map +1 -0
  45. package/dist/path-scope-lexical.js +27 -0
  46. package/dist/path.d.ts.map +1 -1
  47. package/dist/path.js +20 -2
  48. package/dist/pinned-write.js +1 -0
  49. package/dist/root-boundary.d.ts +25 -0
  50. package/dist/root-boundary.d.ts.map +1 -0
  51. package/dist/root-boundary.js +177 -0
  52. package/dist/root-context.d.ts.map +1 -1
  53. package/dist/root-context.js +32 -5
  54. package/dist/root-directory-list.d.ts.map +1 -1
  55. package/dist/root-directory-list.js +10 -2
  56. package/dist/root-impl.d.ts.map +1 -1
  57. package/dist/root-impl.js +53 -34
  58. package/dist/root-move-preflight.d.ts +8 -0
  59. package/dist/root-move-preflight.d.ts.map +1 -0
  60. package/dist/root-move-preflight.js +16 -0
  61. package/dist/root-path-existing.d.ts +2 -0
  62. package/dist/root-path-existing.d.ts.map +1 -1
  63. package/dist/root-path-existing.js +10 -2
  64. package/dist/root-path.d.ts +2 -0
  65. package/dist/root-path.d.ts.map +1 -1
  66. package/dist/root-path.js +54 -26
  67. package/dist/root-paths.d.ts +2 -6
  68. package/dist/root-paths.d.ts.map +1 -1
  69. package/dist/root-paths.js +14 -26
  70. package/dist/root-walk.d.ts.map +1 -1
  71. package/dist/root-walk.js +2 -1
  72. package/dist/root-write-mode.d.ts +2 -0
  73. package/dist/root-write-mode.d.ts.map +1 -1
  74. package/dist/root-write-mode.js +20 -7
  75. package/dist/root-write-verification.d.ts.map +1 -1
  76. package/dist/root-write-verification.js +12 -3
  77. package/dist/safe-path-segment.d.ts.map +1 -1
  78. package/dist/safe-path-segment.js +3 -1
  79. package/dist/secure-file-windows.d.ts +10 -0
  80. package/dist/secure-file-windows.d.ts.map +1 -0
  81. package/dist/secure-file-windows.js +186 -0
  82. package/dist/secure-file.d.ts.map +1 -1
  83. package/dist/secure-file.js +6 -3
  84. package/dist/sibling-staged-file.d.ts +1 -0
  85. package/dist/sibling-staged-file.d.ts.map +1 -1
  86. package/dist/sibling-staged-file.js +7 -0
  87. package/dist/sibling-temp.d.ts.map +1 -1
  88. package/dist/sibling-temp.js +2 -1
  89. package/dist/sidecar-lock-acquire.js +1 -1
  90. package/dist/sidecar-lock.d.ts.map +1 -1
  91. package/dist/sidecar-lock.js +2 -3
  92. package/dist/temp-target.d.ts.map +1 -1
  93. package/dist/temp-target.js +3 -1
  94. package/dist/walk.d.ts.map +1 -1
  95. package/dist/walk.js +11 -10
  96. package/docs/advanced.md +3 -1
  97. package/docs/archive.md +6 -0
  98. package/docs/contributing.md +4 -1
  99. package/docs/copy.md +1 -1
  100. package/docs/durability.md +4 -2
  101. package/docs/file-store.md +6 -0
  102. package/docs/filename.md +9 -2
  103. package/docs/guest.md +5 -0
  104. package/docs/install-path.md +59 -13
  105. package/docs/install.md +4 -1
  106. package/docs/native-helper.md +4 -2
  107. package/docs/native.md +14 -2
  108. package/docs/path.md +1 -1
  109. package/docs/permissions.md +26 -1
  110. package/docs/public-api.md +5 -0
  111. package/docs/root.md +3 -1
  112. package/docs/secure-file.md +15 -14
  113. package/docs/security-model.md +1 -1
  114. package/docs/store.md +22 -1
  115. package/docs/temp.md +4 -0
  116. package/package.json +8 -8
@@ -76,7 +76,10 @@ setup use `useSuiteFixture` from `test/helpers/suite-fixture.ts`: setup has a se
76
76
  removing directories. Run shared-state corpora sequentially with a deadline per
77
77
  payload. Keep child-process liveness limits separate from fixture preparation.
78
78
  The Windows CI slow-copy proof runs the real package-copy process-exit test with a
79
- six-second copy delay, retaining its four-second child deadline:
79
+ 16-second copy delay, a 10-second child deadline, a 15-second test deadline, and
80
+ 60-second setup and teardown hook budgets. Other hosts retain the ordinary
81
+ six-second delay, four-second child deadline, five-second test deadline, and
82
+ 30-second hook budgets:
80
83
 
81
84
  ```bash
82
85
  pnpm build
package/docs/copy.md CHANGED
@@ -64,7 +64,7 @@ Automatic copying does not recover from permission errors, I/O errors, cancellat
64
64
 
65
65
  On Windows, automatic byte copying uses the native binding when available to transfer data between the already-checked file handles in chunks of at most 1 MiB, with smaller buffers for small files. This accelerates NTFS and cross-volume copies without reopening source or destination pathnames. The native worker finishes before its descriptors are closed or cancellation is reported. `clone: "never"` and native-disabled copies use JavaScript read/write loops with reusable buffers: at most 1 MiB per active file on Windows, or 128 KiB elsewhere. Both paths share the file-worker budget across sibling directories, wait for all admitted writes after cancellation or failure, and restore directory timestamps after their descendants finish. Completed directory traversals awaiting writes or metadata are bounded by concurrency; ancestors remain pinned while traversing their children.
66
66
 
67
- On Linux, automatic byte copying also uses the native binding when available. It reads in 1 MiB chunks and leaves leading and trailing zero-filled portions of each chunk unwritten in the new file, avoiding their allocation on filesystems that support sparse files. A final size update preserves trailing holes and all-zero files. This path uses reads and writes, without cloning or copy offload; it still reads the full logical contents and does not promise identical sparse extent layout. `clone: "never"` retains the JavaScript byte-copy path.
67
+ On Linux, automatic byte copying also uses the native binding when available. It reads in chunks up to 1 MiB, using smaller buffers for smaller source-size hints, and leaves leading and trailing zero-filled portions of each chunk unwritten in the new file, avoiding their allocation on filesystems that support sparse files. A final size update preserves trailing holes and all-zero files. This path uses reads and writes, without cloning or copy offload; it still reads the full logical contents and does not promise identical sparse extent layout. `clone: "never"` retains the JavaScript byte-copy path.
68
68
 
69
69
  Clones preserve file contents, empty directories, timestamps, executable modes where supported, and literal symbolic links. Editing a clone does not modify its source. Unsupported filesystem operations fail; callers may choose their own copy or checkout fallback after the failed operation has settled.
70
70
 
@@ -242,8 +242,10 @@ When the optional binding is active, hashing runs as an async native task and
242
242
  does not occupy the JavaScript event loop with digest updates. With native mode
243
243
  `off`, or in `auto` when no binding loads, the fallback performs asynchronous
244
244
  positioned reads in chunks of up to 256 KiB but updates Node's `Hash` on the JavaScript
245
- thread. Both paths stream constant-size buffers rather than loading the file
246
- into memory. Native mode `require` keeps its usual fail-closed loader semantics.
245
+ thread. Both paths stream bounded buffers rather than loading the file into memory.
246
+ The fallback sizes its scratch buffer to small files and grows it if a stale
247
+ size hint is exceeded, while still probing for actual EOF and byte-limit overflow.
248
+ Native mode `require` keeps its usual fail-closed loader semantics.
247
249
 
248
250
  ### Synchronous hashing
249
251
 
@@ -256,6 +256,12 @@ type FileStorePruneOptions = {
256
256
 
257
257
  Symlinks are skipped. The walk is best-effort — failures on individual entries don't abort the whole prune. Compares against `mtimeMs`.
258
258
 
259
+ Pruning rechecks that a selected entry is still a regular file and still expired
260
+ immediately before guarded removal. Fresh replacements and in-place timestamp
261
+ refreshes are preserved; replacements that are themselves expired remain
262
+ eligible. This does not require read permission. The existing best-effort
263
+ external-process race window after dispatch still applies.
264
+
259
265
  ## Difference from `Root`
260
266
 
261
267
  | `FileStore` | `Root` |
package/docs/filename.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Filenames
2
2
 
3
- `sanitizeUntrustedFileName(name, fallback)` reduces a filename string from an untrusted source to one traversal-free path segment. Use it as a thin first pass before storing user-supplied names; pair with [`safeDirName`](install-path.md#safedirname) when you need stricter directory-name handling.
3
+ `sanitizeUntrustedFileName(name, fallback)` reduces a filename string from an untrusted source to one traversal-free path segment. Use it as a thin first pass before storing user-supplied names; use [`safePathSegmentHashedV2`](install-path.md#safepathsegmenthashedv2) when mapping untrusted install IDs to separate directory names.
4
4
 
5
5
  ```ts
6
6
  import { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced";
@@ -27,6 +27,13 @@ In order:
27
27
  6. **Suffix Windows reserved basenames.** Compare the part before the first `.` case-insensitively with the Windows device-name set, including `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`, `CONOUT$`, `COM1..9`, `LPT1..9`, and their superscript `¹`, `²`, and `³` variants. Windows-ignored spaces and dots at the end of that basename do not disguise a device name. A match gains `_` before its extension, preserving the original case and extension on every platform.
28
28
  7. **Truncate.** If the cleaned segment is longer than 200 UTF-16 code units, take up to the first 200 without splitting a valid Unicode surrogate pair.
29
29
 
30
+ If truncation itself exposes a reserved-device basename after Windows ignores
31
+ trailing spaces or dots, the result is shortened once more and receives the
32
+ same underscore suffix. A name that reaches the sanitization branch therefore
33
+ remains at most 200 UTF-16 code units and is never a Windows reserved-device
34
+ alias. `fallbackName` is returned verbatim for empty or path-alias input, so
35
+ callers must supply a fallback that already satisfies their filename policy.
36
+
30
37
  That's it. The function stays intentionally small: it removes traversal and
31
38
  the most obvious cross-platform device and character hazards, but it is not a
32
39
  complete portable-filename or uniqueness policy.
@@ -92,5 +99,5 @@ await fs.write(`uploads/${safe}`, body); // fs is a Root() handle; rejects trave
92
99
 
93
100
  ## See also
94
101
 
95
- - [Install path helpers](install-path.md) — `safeDirName`, `safePathSegmentHashed` for directory-segment sanitization.
102
+ - [Install path helpers](install-path.md) — legacy directory-segment sanitizers and `safePathSegmentHashedV2` for untrusted install IDs.
96
103
  - [`root()`](root.md) — the boundary you'll write into after sanitizing.
package/docs/guest.md CHANGED
@@ -113,6 +113,11 @@ prefixes and use short random suffixes independent of the destination basename,
113
113
  so legal names near the filesystem's component limit also work for writes and
114
114
  cross-device moves.
115
115
 
116
+ Cross-device symlink moves create the new link in a private destination-side
117
+ staging directory before atomically replacing the destination. Link creation or
118
+ publication failure preserves the existing destination and source link; ordinary
119
+ failure cleanup removes the staging directory.
120
+
116
121
  Cross-device directory moves build a copy manifest and check it during source
117
122
  cleanup. Source changes can leave the published destination and some or all
118
123
  of the source. Regular-file and symlink move fallbacks unlink the source
@@ -8,6 +8,7 @@ import {
8
8
  resolveSafeInstallDir,
9
9
  safeDirName,
10
10
  safePathSegmentHashed,
11
+ safePathSegmentHashedV2,
11
12
  } from "@openclaw/fs-safe/advanced";
12
13
  ```
13
14
 
@@ -35,14 +36,17 @@ if (!r.ok) return reply(400, r.error);
35
36
  await fs.mkdir(r.path, { recursive: true });
36
37
  ```
37
38
 
38
- For ids whose default-sanitized form might collide (e.g. `"foo/bar"` and `"foo\\bar"` both map to `"foo__bar"`), pass `nameEncoder: safePathSegmentHashed` to append a content hash:
39
+ For untrusted IDs that must occupy separate install directories, pass
40
+ `nameEncoder: safePathSegmentHashedV2`. The default `safeDirName` and legacy
41
+ `safePathSegmentHashed` can map distinct IDs to the same directory; the boundary
42
+ check does not establish which ID owns an existing directory.
39
43
 
40
44
  ```ts
41
45
  const r = resolveSafeInstallDir({
42
- baseDir: "/srv/plugins",
46
+ baseDir: "/srv/plugins-v2",
43
47
  id: untrustedId,
44
48
  invalidNameMessage: "invalid plugin name",
45
- nameEncoder: safePathSegmentHashed,
49
+ nameEncoder: safePathSegmentHashedV2,
46
50
  });
47
51
  ```
48
52
 
@@ -87,11 +91,48 @@ safeDirName(""); // ""
87
91
 
88
92
  `safeDirName` does *not* try to be exhaustive about Windows-reserved names or special characters. It is purely a separator-stripping pass — `resolveSafeInstallDir` adds the boundary check on top so an `"../../etc"` input cannot escape `baseDir`.
89
93
 
90
- For stricter sanitization, use `safePathSegmentHashed`.
94
+ Use `safePathSegmentHashedV2` when distinct untrusted IDs need separate names.
95
+
96
+ ### `safePathSegmentHashedV2`
97
+
98
+ ```ts
99
+ function safePathSegmentHashedV2(input: string): string;
100
+ ```
101
+
102
+ Hashes every trimmed ID, including ordinary short names, into `id-v2-` followed
103
+ by 64 lowercase hexadecimal SHA-256 digits. The result is always 70 ASCII bytes,
104
+ contains no separators, and avoids Windows device names and ignored suffixes.
105
+ There is no readable prefix to truncate and no unchanged-name branch. Distinct
106
+ trimmed IDs, including an ID that looks like an encoded output, remain distinct
107
+ unless their full SHA-256 digests collide. The lowercase ASCII output also
108
+ preserves that distinction on case-insensitive and Unicode-normalizing volumes.
109
+
110
+ The stable V2 digest recipe is SHA-256 of the UTF-8 bytes of
111
+ `"@openclaw/fs-safe:install-path:v2\0"`, followed by the UTF-16LE bytes of
112
+ `input.trim()`, without a byte-order mark. The NUL-terminated prefix separates
113
+ this use of SHA-256 from other hash domains. UTF-16LE preserves exact JavaScript
114
+ code units, including lone surrogates. Inputs are not case-folded or Unicode
115
+ normalized. Surrounding whitespace, as removed by JavaScript `String.trim()`,
116
+ is the only intentional equivalence; internal whitespace remains significant.
117
+
118
+ ```ts
119
+ const segment = safePathSegmentHashedV2("plugin/v1"); // id-v2-<64 hex digits>
120
+ safePathSegmentHashedV2(" plugin/v1 ") === segment; // true
121
+ safePathSegmentHashedV2("Plugin/v1") === segment; // false
122
+ ```
123
+
124
+ This encoder computes a name; it does not authorize access or prove ownership
125
+ of a directory. Store the original trimmed ID in application-owned metadata and
126
+ verify it before reusing an existing install directory. Keep each install tree
127
+ on one encoding version. Switching to V2 changes existing paths: use a new base
128
+ directory or explicitly migrate directories after verifying their recorded IDs.
129
+ Do not silently fall back to a legacy path when the V2 path is missing.
91
130
 
92
131
  ### `safePathSegmentHashed`
93
132
 
94
- Returns a directory-safe segment **plus** a short content hash when sanitization changed the input or when the safe form is too long. Use this when input collisions matter:
133
+ Legacy readable encoding retained for path compatibility. It appends a short
134
+ content hash when sanitization changed the input or when the safe form is too
135
+ long; ordinary short names remain unchanged. Use V2 for new untrusted-ID mappings.
95
136
 
96
137
  ```ts
97
138
  safePathSegmentHashed("plugin-v1"); // "plugin-v1" (unchanged short input)
@@ -104,31 +145,35 @@ safePathSegmentHashed("."); // "skill-cdb4ee2aea"
104
145
 
105
146
  The sanitization is more aggressive than `safeDirName`: any character not in `[A-Za-z0-9._-]` becomes `-`, runs of `-` collapse, leading and trailing `-` are stripped, the empty/`.`/`..` fallback is `"skill"`. Long results are truncated to 50 chars before the hash is appended.
106
147
 
107
- The suffix is the first 10 hex characters of `sha256(trimmedInput)`. It makes
108
- collisions between distinct trimmed inputs unlikely, but it is a 40-bit
109
- identifier rather than a mathematical uniqueness guarantee. Inputs that differ
110
- only by surrounding whitespace intentionally map to the same output.
148
+ The suffix is the first 10 hex characters of `sha256(trimmedInput)`. This is a
149
+ 40-bit identifier, and the hash is not applied to every ID: short generated
150
+ outputs overlap with accepted literal inputs. Case variants can also share a
151
+ directory on case-insensitive filesystems. Distinct IDs can therefore alias
152
+ without a hash collision. Do not use this legacy encoder as an identity or
153
+ authorization boundary for untrusted IDs. Inputs that differ only by surrounding
154
+ whitespace intentionally map to the same output. Its output and the default
155
+ encoder selected by `resolveSafeInstallDir` remain unchanged for compatibility.
111
156
 
112
157
  ## Common patterns
113
158
 
114
159
  ### Install a plugin
115
160
 
116
161
  ```ts
117
- import { resolveSafeInstallDir, assertCanonicalPathWithinBase, safePathSegmentHashed } from "@openclaw/fs-safe/advanced";
162
+ import { resolveSafeInstallDir, assertCanonicalPathWithinBase, safePathSegmentHashedV2 } from "@openclaw/fs-safe/advanced";
118
163
  import { extractArchive } from "@openclaw/fs-safe/archive";
119
164
  import fs from "node:fs/promises";
120
165
 
121
166
  const r = resolveSafeInstallDir({
122
- baseDir: "/srv/plugins",
167
+ baseDir: "/srv/plugins-v2",
123
168
  id: untrustedName,
124
169
  invalidNameMessage: "invalid plugin name",
125
- nameEncoder: safePathSegmentHashed,
170
+ nameEncoder: safePathSegmentHashedV2,
126
171
  });
127
172
  if (!r.ok) return reply(400, r.error);
128
173
 
129
174
  await fs.mkdir(r.path, { recursive: true, mode: 0o755 });
130
175
  await assertCanonicalPathWithinBase({
131
- baseDir: "/srv/plugins",
176
+ baseDir: "/srv/plugins-v2",
132
177
  candidatePath: r.path,
133
178
  boundaryLabel: "plugin install dir",
134
179
  });
@@ -148,6 +193,7 @@ const snap = resolveSafeInstallDir({
148
193
  baseDir: "/srv/snapshots",
149
194
  id: `${runId}-${version}`,
150
195
  invalidNameMessage: "invalid snapshot id",
196
+ nameEncoder: safePathSegmentHashedV2,
151
197
  });
152
198
  if (!snap.ok) throw new Error(snap.error);
153
199
  await fs.mkdir(snap.path, { recursive: true });
package/docs/install.md CHANGED
@@ -54,7 +54,10 @@ also rejects canonicalization when the addon or its canonicalizer is unavailable
54
54
  Node and Windows use their existing runtime canonicalizers. On Windows, Bun's
55
55
  recursive directory creation receives an absolute spelling that preserves raw
56
56
  path components, working around its rejection of existing relative `.` and `..`
57
- directories. Public paths and caller-supplied filesystem adapters remain unchanged.
57
+ directories. Windows native descriptor-relative operations require Bun to expose
58
+ the paired libuv descriptor bridge from its host executable; a missing or partial
59
+ bridge fails explicitly with `ENOTSUP`. Public paths and caller-supplied filesystem
60
+ adapters remain unchanged.
58
61
 
59
62
  The upstream fix is tracked in [Bun #42374](https://github.com/oven-sh/bun/pull/42374).
60
63
  The adapter can be removed when the supported Bun baseline includes that fix.
@@ -70,10 +70,12 @@ normalization, and the decision to fall back.
70
70
 
71
71
  - Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`. Owned-tree cleanup enumerates and unlinks through retained directory descriptors and rejects device crossings.
72
72
  - macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace; owned-tree cleanup uses descriptor-relative `openat`/`unlinkat`.
73
- - Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer, and deletes owned trees through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed.
73
+ - Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer, and deletes owned trees through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed. Descriptors crossing N-API are converted only by the host executable's paired `uv_get_osfhandle` and `uv_open_osfhandle` exports. A runtime without both exports is unsupported for these native operations; the binding never guesses a raw HANDLE or uses a foreign CRT descriptor table.
74
74
 
75
75
  Native primitives back create-only and replacing pinned writes, async sidecar creation,
76
- guarded publication, archive acceleration, and direct Windows ACL operations.
76
+ guarded publication, archive acceleration, and direct Windows ACL operations. Windows
77
+ secure-file reads require descriptor-bound owner/DACL facts from the current helper;
78
+ they do not use the standalone pathname inspector's command fallback.
77
79
  Equivalent JavaScript paths remain available for documented fallback-capable
78
80
  features. See [Native architecture](native.md#javascript-fallback-guarantees-and-delta)
79
81
  for the exact difference.
package/docs/native.md CHANGED
@@ -55,7 +55,12 @@ whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
55
55
  `FILE_OPEN_REPARSE_POINT`, then explicitly rejects reparse points. Rename and
56
56
  hardlink operations stay rooted in already-open handles. Owner/DACL reads
57
57
  use `GetSecurityInfo`; private directories receive their protected DACL in
58
- the `CreateDirectoryW` call itself.
58
+ an exclusive, handle-relative `NtCreateFile` call. Their created handles remain
59
+ open through ACL and pathname-association checks and own any failure cleanup.
60
+ N-API descriptors cross into and out of
61
+ this layer only through the host executable's paired libuv descriptor bridge;
62
+ missing or partial exports fail with `ENOTSUP` instead of trying a raw HANDLE
63
+ or add-on CRT descriptor namespace.
59
64
 
60
65
  ## Archives
61
66
 
@@ -125,6 +130,13 @@ All routes preserve `wx` semantics and the same source/target identity and
125
130
  SHA-256 fencing. Native hashing and Linux whole-file copying run on N-API async
126
131
  workers rather than the JavaScript event loop.
127
132
 
133
+ Linux range copying confirms every zero-byte result with a positioned source
134
+ read at the current transfer offset, including after earlier calls copied data.
135
+ If readable bytes remain, automatic Root copying resumes its byte loop from
136
+ that offset; exclusive publication removes its partial target before retrying
137
+ the guarded byte-copy fallback. EOF checks preserve descriptor cursors and do
138
+ not bypass the byte limit.
139
+
128
140
  ## Mode semantics
129
141
 
130
142
  | Mode | Native loading | Fallback |
@@ -182,7 +194,7 @@ remain TypeScript-owned. What changes is the syscall strength or availability:
182
194
  | Zstd/bzip2 TAR | Supported. | Unsupported; typed `helper-unavailable`. |
183
195
  | Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
184
196
  | `rename-noreplace` | Atomic platform no-replace rename. | Unsupported; no emulation by check-then-rename. |
185
- | Windows DACL read | Direct `GetSecurityInfo`; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. | Structured .NET owner/DACL inspection for coarse permission checks; the public raw ACE facts API remains native-only. |
197
+ | Windows DACL read | Direct `GetSecurityInfo`; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. Secure-file reads query the borrowed open descriptor and compare its 32-bit volume serial and 64-bit file-index projection with Node's bigint receipt. | Structured .NET owner/DACL inspection remains available to standalone pathname reporting. Secure-file reads fail closed without the descriptor capability. |
186
198
  | Windows private directory | Creation-time protected DACL. | Unsupported; no weaker pathname-only substitute. |
187
199
 
188
200
  Use `off` in CI to keep the fallback contract exercised. Use `require` when a
package/docs/path.md CHANGED
@@ -36,7 +36,7 @@ isPathInside("/srv/uploads", "/srv/uploads-other/x"); // false
36
36
  isPathInside("/srv/uploads", "/srv/uploads"); // true (root itself counts)
37
37
  ```
38
38
 
39
- The check is platform-aware: on Windows, paths are normalized for case and separator before comparison.
39
+ The check is platform-aware: on Windows, paths are normalized for case and separator before comparison. This is deliberately a string-only, lexical answer; case folding does not prove that two differently cased prefixes name the same directory on a case-sensitive Windows directory. Root operations perform their own filesystem-identity admission when containment depends on case folding.
40
40
 
41
41
  ### `isPathInsideWithRealpath(rootDir, target, opts?)`
42
42
 
@@ -42,7 +42,7 @@ POSIX remediation strings shell-quote paths with whitespace or metacharacters
42
42
  and protect option-like paths with `--`, so they can be presented as commands
43
43
  without letting the inspected pathname add shell syntax.
44
44
 
45
- `inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below and also reports `ownerSid` plus `ownerTrusted` when ownership can be verified. `ownerTrusted` is true only for a local volume owned by the current user, LocalSystem, or built-in Administrators; remote filesystems fail closed. Secure reads and callers that protect credential-bearing execution require `ownerTrusted === true`.
45
+ `inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below and also reports `ownerSid` plus `ownerTrusted` when ownership can be verified. `ownerTrusted` is true only for a local volume owned by the current user, LocalSystem, or built-in Administrators; remote filesystems fail closed. This remains a pathname reporting API with the fallbacks described below. `readSecureFile()` does not use those pathname fallbacks on Windows: it requires descriptor-bound native owner/DACL facts for the exact handle it reads.
46
46
 
47
47
  ## Advanced Windows ACL helpers
48
48
 
@@ -178,6 +178,31 @@ await openSqlite(path.join(sqliteDirectory, "sessions.sqlite"));
178
178
  On Windows with native support, this creates the directory and applies a
179
179
  protected owner + LocalSystem + Administrators full-control DACL directly with
180
180
  an atomic security descriptor; no PowerShell or `icacls` process is launched.
181
+ The native operation retains the parent and exact created-directory handles
182
+ through ACL and final pathname validation. If validation fails, it attempts only
183
+ nonrecursive deletion through the created handle, preserving any pathname
184
+ replacement. If cleanup also fails, the error retains the original failure and
185
+ includes the cleanup failure.
186
+
187
+ Directory association checks compare the complete 64-bit volume serial and
188
+ 128-bit `FILE_ID_INFO` identity, including on ReFS. If that identity class is
189
+ unavailable, the operation fails closed without a narrower file-index fallback.
190
+ Validation confirms that the created directory is local, its DACL is protected
191
+ from inheritance, and its final public pathname opens the same local directory.
192
+
193
+ This is a point-in-time pathname association check. The function closes its
194
+ handles before returning; callers must keep the pathname's ancestry trusted
195
+ during subsequent use, including opening SQLite databases in the example above.
196
+ The immediate parent and final directory must not be reparse points. Earlier
197
+ ancestor reparse points can be followed; this API does not reject every reparse
198
+ point in the full ancestry.
199
+
200
+ Path components ending in a space or period are rejected before filesystem
201
+ operations to avoid differing Win32 and native pathname interpretations. This
202
+ also rejects explicit `.` and `..` components, including spellings such as
203
+ `.\private` and `parent\..\private`, as a compatibility restriction. Simple
204
+ relative names without these components remain supported.
205
+
181
206
  This API is Windows-only and native-only; it fails closed with
182
207
  `FsSafeError("helper-unavailable")` on other platforms, when native mode is off,
183
208
  or when the binding is unavailable. POSIX callers should create private
@@ -26,6 +26,11 @@ deprecated native-configuration bridge retains the `FsSafePythonConfig` type.
26
26
 
27
27
  ## `path` and `advanced`
28
28
 
29
+ `safePathSegmentHashedV2` encodes every trimmed install ID with domain-separated
30
+ SHA-256 into a fixed lowercase segment. The legacy `safePathSegmentHashed` keeps
31
+ its existing output but can alias distinct IDs. See [install paths](install-path.md)
32
+ for the exact encoding and migration contract.
33
+
29
34
  The lexical path surface additionally exports `isNodeError`,
30
35
  `isPathRelativeEscape`, `normalizeWindowsPathForComparison`,
31
36
  `resolveSafeRelativePath`, `splitSafeRelativePath`, and
package/docs/root.md CHANGED
@@ -100,7 +100,9 @@ The read methods also accept an absolute spelling that already resolves inside
100
100
  the root. `readAbsolute()` and `reader()` make that intent explicit and accept
101
101
  both the configured root spelling and its canonical real path when the Root was
102
102
  created through a directory symlink or Windows junction. An absolute path
103
- outside the root is still rejected.
103
+ outside the root is still rejected. On Windows, alternate casing is accepted
104
+ only when the differently cased Root prefix has the Root's exact directory
105
+ identity; the operation then continues under the trusted Root spelling.
104
106
 
105
107
  ### Writes
106
108
 
@@ -27,9 +27,11 @@ The helper:
27
27
  - enforces `maxBytes` before and after reading
28
28
  - closes the handle on success, error, and timeout
29
29
 
30
- On POSIX, unsafe permissions mean group/world writable, and group/world readable unless `permissions.allowReadableByOthers` is true. On Windows, the helper uses the ACL inspection helpers from [`permissions`](permissions.md) and refuses the read if ACLs cannot be verified.
30
+ On POSIX, unsafe permissions mean group/world writable, and group/world readable unless `permissions.allowReadableByOthers` is true. On Windows, the helper queries owner, DACL, and locality from the same open descriptor that supplies the bytes. The native query returns the 32-bit volume serial and 64-bit file-index projection used by Node, which must equal Node's bigint descriptor receipt before its ACL facts are trusted. This avoids JavaScript number rounding but does not represent the full 128-bit file identity available on ReFS. Only the current user, LocalSystem, and built-in Administrators are trusted owner classes.
31
31
 
32
- Descriptor, pathname, and realpath identity checks use lossless bigint stats internally. The returned `stat` remains a normal Node `Stats` object with numeric fields. A zero Windows device or inode is unverified, never a match: the helper re-inspects that identity once using the same descriptor or pathname, then rejects persistent ambiguity with `path-mismatch`. A definite mismatch rejects immediately; retries retain known identity components and still enforce symlink policy.
32
+ Windows secure reads require the matching current optional native package. A missing or stale helper, fd-to-handle conversion failure, denied `READ_CONTROL`, remote handle, incomplete descriptor, or unsupported ACE form rejects with `permission-unverified` before content is read. A malformed or different handle identity rejects with `path-mismatch`. There is no pathname-command fallback for `readSecureFile()`; the standalone reporting APIs in [`permissions`](permissions.md) retain their documented fallbacks. `permissions.allowInsecure` remains the explicit escape hatch and bypasses the ACL query.
33
+
34
+ Descriptor, pathname, and realpath identity checks use bigint stats internally to avoid JavaScript number rounding. The returned `stat` remains a normal Node `Stats` object with numeric fields. A zero Windows device or inode is unverified, never a match: the helper re-inspects that identity once using the same descriptor or pathname, then rejects persistent ambiguity with `path-mismatch`. A definite mismatch rejects immediately; retries retain known identity components and still enforce symlink policy.
33
35
 
34
36
  ## Options
35
37
 
@@ -62,6 +64,8 @@ type SecureFileReadOptions = {
62
64
 
63
65
  `permissions.allowInsecure` is a migration escape hatch. Prefer fixing permissions and using [`formatPermissionRemediation`](permissions.md) to show the user what to run. `trust.allowNetworkPath` is off by default because UNC paths are remote authority, not local filesystem input. `inject` is for tests and platform adapters; production callers usually leave it unset.
64
66
 
67
+ On an actual Windows process with effective `platform: "win32"`, `inject.env` and `inject.exec` do not replace descriptor inspection. They remain available to simulated Windows checks on non-Windows hosts.
68
+
65
69
  `permissions.allowInsecure` bypasses only permission checks. Neither it nor `inject.platform` changes filesystem identity verification, which always uses the actual process platform. `trust.allowSymlink` permits an alias but still requires its target and realpath to match the opened descriptor.
66
70
 
67
71
  ## Errors
@@ -77,23 +81,20 @@ type SecureFileReadOptions = {
77
81
  | `hardlink` | The descriptor, pathname, or realpath has more than one link. |
78
82
  | `path-mismatch` | The path or realpath changed between open and verification, or filesystem identity could not be verified after bounded re-inspection. |
79
83
  | `outside-workspace` | `realPath` is outside `trust.trustedDirs`. |
80
- | `permission-unverified` | Required mode/ACL checks could not be completed. |
84
+ | `permission-unverified` | Required mode/ACL checks could not be completed, including when descriptor-bound Windows inspection is unavailable. |
81
85
  | `insecure-permissions` | Mode bits or ACLs grant broader access than allowed. |
82
86
  | `not-owned` | POSIX owner uid is not the current process uid. |
83
87
  | `too-large` | File size or bytes read exceeded `maxBytes`. |
84
88
  | `timeout` | `timeoutMs` elapsed while reading. |
85
89
 
86
- Windows inspection failures remain operational `permission-unverified` errors
87
- and still refuse the read. Their message includes the underlying reason when
88
- available. `details` includes `ownerError` for owner-query failures and, when
89
- command diagnostics are available, `command`, `durationMs`, `timedOut`,
90
- `exitCode`, `signal`, and `stderr`. Reasons and stderr are control-character
91
- escaped and limited to 400 characters each (including a truncation marker).
92
- No stdout or target file contents are copied into these display diagnostics.
93
- The original inspection exception is retained as `cause`; built-in command
94
- errors also retain their original execFile exception in the cause chain.
95
- Treat causes as restricted local diagnostic data. No retries are performed,
96
- and verification order and rejection conditions are unchanged.
90
+ Windows descriptor-inspection failures are operational `permission-unverified`
91
+ errors and refuse the read. The original native exception is retained as
92
+ `cause`; treat causes as restricted local diagnostic data. No pathname or ACL
93
+ content is copied into the display message. Test adapters that simulate Windows
94
+ on another operating system retain the standalone pathname inspector's
95
+ structured command diagnostics (`ownerError`, `command`, `durationMs`,
96
+ `timedOut`, `exitCode`, `signal`, and bounded escaped `stderr`). Actual Windows
97
+ secure reads do not start those commands. No retries are performed.
97
98
 
98
99
  ## See also
99
100
 
@@ -45,7 +45,7 @@ If you need full sandboxing, run the worker under reduced privileges (uid, conta
45
45
 
46
46
  ### Path traversal and absolute paths
47
47
 
48
- Every path is resolved against the canonicalized real path of the root, then checked with `isPathInside`. Alias resolution walks components before applying a later `..`, so a symlink cannot change what that parent segment means after validation. Parent traversal, an absolute spelling, or any alias whose canonical result is outside the root throws `outside-workspace`; absolute spellings that remain inside the root are accepted.
48
+ Every path is resolved against the canonicalized real path of the root, then checked at that boundary. On Windows, an exact-case structural Root prefix stays on the lexical fast path; a prefix accepted only by case folding must have the Root's exact directory identity and is rebased onto the trusted Root spelling before use. Alias resolution walks components before applying a later `..`, so a symlink cannot change what that parent segment means after validation. Parent traversal, an absolute spelling, or any alias whose canonical result is outside the root throws `outside-workspace`; absolute spellings that remain inside the root are accepted.
49
49
 
50
50
  ### Symlinks (read side)
51
51
 
package/docs/store.md CHANGED
@@ -72,12 +72,33 @@ Loading serializes consumers for one ID through a sidecar lock, then creates `pr
72
72
 
73
73
  Queue and failed directory creation fsyncs every newly-created parent edge from the leaf toward the trusted root. Enqueue and migration writes fsync the temp file and parent; claim, acknowledgement, quarantine, delivered-marker cleanup, and retirement transitions fsync every affected directory and propagate real sync failures. A transition may already be visible when a post-mutation sync fails, so retry the same operation to complete its crash-recovery state. Acknowledgement retries resync the queue directory even when both `.processing` and `.delivered` marker names are already absent, before reporting completion or rejecting a newer pending generation; quarantine retries with only failed evidence resync that destination before repairing the vanished queue source.
74
74
 
75
- `writeJsonDurableQueueEntry()` and migrations share strict parent synchronization inside the atomic writer's retained descriptor and per-path serialization lifetime, followed by published-file identity verification. If sync fails after publication, the write rejects without rolling back the published JSON; retrying writes the entry again and must complete its own sync. This is not a rollback, deduplication, or exactly-once guarantee. The generic `replaceFileAtomic({ syncParentDir: true })` option remains best-effort.
75
+ `writeJsonDurableQueueEntry()` and migrations share strict parent synchronization inside the atomic writer's retained descriptor and per-path serialization lifetime, followed by published-file identity verification. If sync fails after publication, the write rejects without rolling back the published JSON; retrying `writeJsonDurableQueueEntry()` writes the entry again and must complete its own sync. Loader retries resync an existing processing claim's parent under the transfer lock before calling `read`, even when a version-dependent callback would no longer request migration. Fresh claims and same-directory source retirement already complete that sync. This is not a rollback, deduplication, or exactly-once guarantee. The generic `replaceFileAtomic({ syncParentDir: true })` option remains best-effort.
76
76
 
77
77
  Batch loading skips invalid entry names, malformed, oversized, or unreadable entry content, and caller `read` callback failures. Initially unowned pending entries (hardlinks or unverifiable identities), symlinks, non-files, and absent pending entries are also skipped. Claim, transfer-lock, retirement, and migration write/publication/durability failures reject the batch with the original error, even if earlier entries succeeded. Migration in both loaders strictly syncs the parent directory after successful publication. Visible transitions and earlier processing claims remain for retry; a rejected batch does not acknowledge or roll them back.
78
78
 
79
79
  Failed destinations are create-only. Quarantine publishes the claimed file by hardlink, so the queue and failed directories must share a filesystem with hardlink support. If `failed/<id>.json` already exists, quarantine rejects while preserving both that earlier evidence and the current claimed entry instead of overwriting either file. The `read` callback continues to receive the logical `.json` path even though bytes are read and migrations are written through the claimed path.
80
80
 
81
+ Migrations stay bound to the exact processing file opened for that load. The
82
+ read descriptor remains pinned while the callback runs outside the transfer
83
+ lock; after the callback returns, migration reacquires the lock and rechecks the
84
+ claim before publication. If another consumer acknowledged, quarantined, or
85
+ replaced that claim, the migration rejects with `FsSafeError("path-mismatch")`
86
+ and leaves the newer generation or failed evidence intact. A stale migration
87
+ rejects both single and batch loads; ordinary callback failures retain their
88
+ existing single-load rejection and batch-skip behavior.
89
+
90
+ On Windows, migration releases its read pin once at this publication boundary
91
+ because an open target can block replacement. It rechecks the exact pathname
92
+ identity after the asynchronous close while still holding the transfer lock.
93
+ POSIX retains the read pin through publication. Other readers keep ownership
94
+ of their handles; Windows sharing denials still reject and can be retried after
95
+ those readers close.
96
+
97
+ Generation arbitration requires consumers to use the transfer lock. As with
98
+ [atomic writes](atomic.md#beforerename), identity checks and pathname replacement
99
+ are separate operations; use a trusted writable parent or OS isolation against
100
+ processes that ignore the lock and mutate queue paths concurrently.
101
+
81
102
  Queue entry reads verify lossless file identities before opening, on the opened
82
103
  descriptor, and at the current pathname before reading bytes. POSIX opens are
83
104
  nonblocking, so a raced FIFO is rejected rather than stalling a consumer. On Windows, an
package/docs/temp.md CHANGED
@@ -269,6 +269,10 @@ files, hardlinks, and changes between the pre-open pathname, opened descriptor,
269
269
  and current pathname are rejected. The callback must finish and close its
270
270
  writer before returning. Its return value is preserved as `result`.
271
271
 
272
+ Generated temp filenames suffix Windows reserved-device basenames on every
273
+ platform. A completed sibling staging component that still resolves as a
274
+ Windows device alias rejects with `invalid-path` before hooks or producers run.
275
+
272
276
  The helper retains one descriptor through requested mode application, opt-in
273
277
  file synchronization, rename, and publication verification. It opens read-only
274
278
  unless file synchronization is requested, so closed read-only producer output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -167,13 +167,13 @@
167
167
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
168
168
  },
169
169
  "optionalDependencies": {
170
- "@openclaw/fs-safe-darwin-arm64": "0.11.0",
171
- "@openclaw/fs-safe-darwin-x64": "0.11.0",
172
- "@openclaw/fs-safe-linux-arm64-gnu": "0.11.0",
173
- "@openclaw/fs-safe-linux-arm64-musl": "0.11.0",
174
- "@openclaw/fs-safe-linux-x64-gnu": "0.11.0",
175
- "@openclaw/fs-safe-linux-x64-musl": "0.11.0",
176
- "@openclaw/fs-safe-win32-x64-msvc": "0.11.0",
170
+ "@openclaw/fs-safe-darwin-arm64": "0.12.0",
171
+ "@openclaw/fs-safe-darwin-x64": "0.12.0",
172
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.12.0",
173
+ "@openclaw/fs-safe-linux-arm64-musl": "0.12.0",
174
+ "@openclaw/fs-safe-linux-x64-gnu": "0.12.0",
175
+ "@openclaw/fs-safe-linux-x64-musl": "0.12.0",
176
+ "@openclaw/fs-safe-win32-x64-msvc": "0.12.0",
177
177
  "jszip": "^3.10.2"
178
178
  },
179
179
  "devDependencies": {