@openclaw/fs-safe 0.19.0 → 0.21.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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +24 -6
  3. package/dist/advanced.d.ts +4 -0
  4. package/dist/advanced.js +2 -0
  5. package/dist/archive-plan.d.ts +2 -7
  6. package/dist/archive-read.js +9 -18
  7. package/dist/archive-zip-entry.d.ts +11 -11
  8. package/dist/archive-zip-entry.js +3 -35
  9. package/dist/archive-zip-integrity.d.ts +2 -2
  10. package/dist/archive-zip-integrity.js +2 -12
  11. package/dist/archive-zip-loader.d.ts +7 -3
  12. package/dist/archive-zip-loader.js +10 -9
  13. package/dist/archive-zip-preflight.d.ts +2 -1
  14. package/dist/archive-zip-preflight.js +16 -7
  15. package/dist/archive.js +17 -16
  16. package/dist/atomic.d.ts +1 -1
  17. package/dist/directory-receipt.js +5 -7
  18. package/dist/effective-uid.js +1 -4
  19. package/dist/errors.d.ts +3 -1
  20. package/dist/errors.js +3 -2
  21. package/dist/file-lock-sync-root-held.js +1 -4
  22. package/dist/file-store.d.ts +4 -7
  23. package/dist/json-document-store.d.ts +4 -9
  24. package/dist/local-file-access.js +2 -5
  25. package/dist/move-path-cleanup.js +4 -4
  26. package/dist/native-binding.d.ts +28 -1
  27. package/dist/native-staged-symlink.d.ts +13 -0
  28. package/dist/native-staged-symlink.js +303 -0
  29. package/dist/owner-dacl-batch-worker.d.ts +1 -0
  30. package/dist/owner-dacl-batch-worker.js +54 -0
  31. package/dist/owner-dacl-batch.d.ts +5 -0
  32. package/dist/owner-dacl-batch.js +64 -0
  33. package/dist/owner-dacl.d.ts +2 -0
  34. package/dist/owner-dacl.js +3 -0
  35. package/dist/path.js +17 -1
  36. package/dist/permission-exec.js +3 -6
  37. package/dist/permissions-public.d.ts +1 -0
  38. package/dist/permissions-public.js +1 -0
  39. package/dist/pinned-mutation-admission.d.ts +0 -1
  40. package/dist/pinned-open.d.ts +0 -1
  41. package/dist/pinned-open.js +1 -2
  42. package/dist/publish-copy-stage.js +4 -0
  43. package/dist/read-opened-file.d.ts +2 -5
  44. package/dist/regular-file.js +3 -3
  45. package/dist/replace-file-buffer.d.ts +4 -0
  46. package/dist/replace-file-buffer.js +36 -0
  47. package/dist/replace-file-copy-fallback.d.ts +3 -2
  48. package/dist/replace-file-copy-fallback.js +66 -38
  49. package/dist/replace-file-descriptor.d.ts +4 -0
  50. package/dist/replace-file-descriptor.js +9 -1
  51. package/dist/replace-file-destination.d.ts +17 -0
  52. package/dist/replace-file-destination.js +61 -0
  53. package/dist/replace-file-mutation.d.ts +26 -0
  54. package/dist/replace-file-mutation.js +47 -0
  55. package/dist/replace-file-temp-owner.d.ts +2 -2
  56. package/dist/replace-file-temp-owner.js +16 -4
  57. package/dist/replace-file-types.d.ts +55 -0
  58. package/dist/replace-file-types.js +1 -0
  59. package/dist/replace-file.d.ts +3 -55
  60. package/dist/replace-file.js +29 -10
  61. package/dist/retained-file-types.d.ts +61 -0
  62. package/dist/retained-file-types.js +1 -0
  63. package/dist/retained-file.d.ts +3 -0
  64. package/dist/retained-file.js +121 -0
  65. package/dist/root-directory-entry.d.ts +9 -0
  66. package/dist/root-directory-entry.js +28 -0
  67. package/dist/root-directory-list.d.ts +7 -1
  68. package/dist/root-directory-list.js +48 -23
  69. package/dist/root-handle-context.d.ts +4 -0
  70. package/dist/root-handle-context.js +12 -0
  71. package/dist/root-impl.d.ts +3 -3
  72. package/dist/root-impl.js +8 -5
  73. package/dist/root-observed-path.d.ts +0 -1
  74. package/dist/root-observed-path.js +0 -3
  75. package/dist/root-path-observation.d.ts +4 -11
  76. package/dist/root-path.js +7 -10
  77. package/dist/root-walk.d.ts +19 -12
  78. package/dist/root-walk.js +49 -18
  79. package/dist/root-write-admission.js +0 -2
  80. package/dist/safe-path-segment.d.ts +1 -0
  81. package/dist/safe-path-segment.js +8 -2
  82. package/dist/secure-file.js +3 -2
  83. package/dist/sidecar-lock.js +5 -3
  84. package/dist/staged-symlink-types.d.ts +49 -0
  85. package/dist/staged-symlink-types.js +1 -0
  86. package/dist/symlink-parents.js +58 -7
  87. package/dist/temp-target.js +5 -2
  88. package/dist/temp-workspace-admission.js +22 -21
  89. package/dist/temp-workspace-child-admission.d.ts +1 -1
  90. package/dist/temp-workspace-child-admission.js +14 -9
  91. package/dist/temp-workspace-owner.js +4 -9
  92. package/dist/temp-workspace-ownership.d.ts +8 -0
  93. package/dist/temp-workspace-ownership.js +52 -0
  94. package/dist/test-hooks.d.ts +3 -0
  95. package/dist/text-atomic.d.ts +2 -1
  96. package/dist/text-atomic.js +2 -0
  97. package/dist/trash.js +27 -1
  98. package/dist/walk.d.ts +2 -5
  99. package/dist/watch-alias.d.ts +6 -0
  100. package/dist/watch-alias.js +80 -0
  101. package/dist/watch-hints.d.ts +8 -0
  102. package/dist/watch-hints.js +77 -0
  103. package/dist/watch-native.d.ts +32 -0
  104. package/dist/watch-native.js +56 -0
  105. package/dist/watch-scan.d.ts +24 -0
  106. package/dist/watch-scan.js +269 -0
  107. package/dist/watch-types.d.ts +58 -0
  108. package/dist/watch-types.js +1 -0
  109. package/dist/watch.d.ts +5 -0
  110. package/dist/watch.js +502 -0
  111. package/dist/windows-owner.d.ts +0 -1
  112. package/dist/windows-owner.js +0 -1
  113. package/dist/windows-security-bridge.cs +6 -4
  114. package/dist/windows-security-bridge.ps1 +78 -3
  115. package/dist/windows-security-command.d.ts +8 -0
  116. package/dist/windows-security-command.js +66 -12
  117. package/dist/windows-security-facts.d.ts +3 -0
  118. package/dist/windows-security-facts.js +4 -0
  119. package/docs/advanced.md +4 -2
  120. package/docs/archive.md +8 -0
  121. package/docs/atomic.md +72 -3
  122. package/docs/contributing.md +35 -0
  123. package/docs/durability.md +7 -0
  124. package/docs/index.md +1 -0
  125. package/docs/install.md +28 -0
  126. package/docs/native-helper.md +14 -4
  127. package/docs/native.md +45 -1
  128. package/docs/permissions.md +66 -0
  129. package/docs/public-api.md +7 -1
  130. package/docs/retained-file.md +113 -0
  131. package/docs/root.md +6 -1
  132. package/docs/security-model.md +4 -1
  133. package/docs/sidecar-lock.md +2 -0
  134. package/docs/staged-symlink.md +123 -0
  135. package/docs/store.md +3 -1
  136. package/docs/temp.md +24 -4
  137. package/docs/testing.md +88 -0
  138. package/docs/types.md +6 -0
  139. package/docs/walk.md +22 -1
  140. package/docs/watch.md +184 -0
  141. package/docs/writing.md +10 -0
  142. package/package.json +13 -9
package/dist/watch.js ADDED
@@ -0,0 +1,502 @@
1
+ import { FsSafeError } from "./errors.js";
2
+ import { assertSynchronousCallbackResult } from "./mutation-authority.js";
3
+ import { assertRootIdentityCurrent } from "./root-context.js";
4
+ import { rootHandleContext } from "./root-handle-context.js";
5
+ import { createSuppressedError } from "./suppressed-error.js";
6
+ import { getFsSafeTestHooks } from "./test-hooks.js";
7
+ import { admittedNativeChanges } from "./watch-alias.js";
8
+ import { changedEntries, guardedHintChanges, scopedChanges } from "./watch-hints.js";
9
+ import { watchBinding, NativeWatchBackend } from "./watch-native.js";
10
+ import { getFsSafeNativeConfig } from "./native-config.js";
11
+ import { isWatchPathError, scanWatch, watchScopes } from "./watch-scan.js";
12
+ function deferred() {
13
+ let settled = false;
14
+ let resolve;
15
+ let reject;
16
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
17
+ // Ownership exists even when a consumer closes without awaiting startup.
18
+ void promise.catch(() => { });
19
+ return { promise, get settled() { return settled; },
20
+ resolve() { settled = true; resolve(); },
21
+ reject(error) { settled = true; reject(error); },
22
+ };
23
+ }
24
+ function budget(value, fallback, name, max = 1_000_000) {
25
+ const result = value ?? fallback;
26
+ if (!Number.isSafeInteger(result) || result < 1 || result > max)
27
+ throw new RangeError("invalid watch " + name);
28
+ return result;
29
+ }
30
+ const retired = () => new DOMException("Watch generation retired", "AbortError");
31
+ const coalesceMs = 25;
32
+ /** Advisory observation only. Hints never grant filesystem authority. */
33
+ export function watch(root, input) {
34
+ const context = rootHandleContext(root);
35
+ const options = { ...input };
36
+ const persistent = options.persistent !== false;
37
+ if (!["auto", "events", "poll"].includes(options.mode))
38
+ throw new TypeError("invalid watch mode");
39
+ let binding;
40
+ let selectionFailure;
41
+ try {
42
+ binding = watchBinding(options.mode);
43
+ }
44
+ catch (error) {
45
+ selectionFailure = error;
46
+ }
47
+ let mode = binding || options.mode === "events" ? "events" : "poll";
48
+ let intervalMs = budget(options.intervalMs, mode === "events" ? 30_000 : 1000, "intervalMs", 2_147_483_647);
49
+ if (intervalMs < 20)
50
+ throw new RangeError("watch intervalMs must be at least 20");
51
+ const maxDirectories = budget(options.maxDirectories, 4096, "maxDirectories");
52
+ const maxEntries = budget(options.maxEntries, 100_000, "maxEntries");
53
+ const maxPendingPaths = budget(options.maxPendingPaths, 256, "maxPendingPaths", 4096);
54
+ if (typeof options.onInvalidate !== "function")
55
+ throw new TypeError("watch requires onInvalidate");
56
+ const makeGeneration = (scopes) => ({
57
+ scopes: watchScopes(scopes), abort: new AbortController(), waiter: deferred(),
58
+ });
59
+ let current = makeGeneration(options.scopes);
60
+ const ready = current.waiter.promise;
61
+ let state = "starting";
62
+ let terminal = false;
63
+ let failure;
64
+ let failureInfo;
65
+ let retirementFailure;
66
+ const retirementErrors = new Set();
67
+ let closing;
68
+ let active;
69
+ let backend;
70
+ const registered = new Map();
71
+ let observedDirectories = 0;
72
+ let snapshot;
73
+ let resetRequested = false;
74
+ let refreshBackend = false;
75
+ let pending = false;
76
+ let pendingWaiter;
77
+ let runningWaiter;
78
+ let pendingHint = false;
79
+ let pendingChanges = new Map();
80
+ let timer;
81
+ let hintTimer;
82
+ const combinedFailure = () => {
83
+ if (!retirementFailure)
84
+ return failure;
85
+ const error = retirementFailure.error;
86
+ if (failure === undefined || error === failure || error?.suppressed === failure)
87
+ return error;
88
+ return createSuppressedError(error, failure, "watch observation and retirement failed");
89
+ };
90
+ const retainRetirement = (error) => {
91
+ if (retirementErrors.has(error))
92
+ return;
93
+ retirementErrors.add(error);
94
+ retirementFailure = { error: retirementFailure
95
+ ? createSuppressedError(error, retirementFailure.error, "watch retirement failed more than once") : error };
96
+ };
97
+ const health = () => Object.freeze({
98
+ state, mode, directories: observedDirectories,
99
+ ...(failure === undefined && !retirementFailure ? {} : {
100
+ failure: retirementFailure ? Object.freeze({ operation: "close", error: combinedFailure() }) : failureInfo,
101
+ }),
102
+ });
103
+ const check = (g) => {
104
+ g.abort.signal.throwIfAborted();
105
+ if (terminal || current !== g)
106
+ throw retired();
107
+ if (failure !== undefined)
108
+ throw failure;
109
+ if (retirementFailure)
110
+ throw retirementFailure.error;
111
+ };
112
+ const clearTimers = () => {
113
+ clearTimeout(timer);
114
+ clearTimeout(hintTimer);
115
+ timer = undefined;
116
+ hintTimer = undefined;
117
+ pending = false;
118
+ pendingHint = false;
119
+ pendingChanges = new Map();
120
+ };
121
+ const notifyHealth = () => {
122
+ try {
123
+ const result = options.onHealth?.(health());
124
+ assertSynchronousCallbackResult(result, "watch onHealth");
125
+ }
126
+ catch (cause) {
127
+ throw new FsSafeError("helper-failed", "watch health callback failed", { cause, details: { operation: "callback" } });
128
+ }
129
+ };
130
+ const dirty = (g, reason, changes) => {
131
+ check(g);
132
+ try {
133
+ const result = options.onInvalidate(Object.freeze({ reason, changes: changes && Object.freeze(changes) }));
134
+ assertSynchronousCallbackResult(result, "watch onInvalidate");
135
+ }
136
+ catch (cause) {
137
+ throw new FsSafeError("helper-failed", "watch dirty callback failed", { cause, details: { operation: "callback" } });
138
+ }
139
+ check(g);
140
+ };
141
+ const retain = (error) => {
142
+ if (failure === undefined) {
143
+ failure = error;
144
+ failureInfo = Object.freeze({ operation: "close", error });
145
+ }
146
+ else if (error !== failure)
147
+ failure = createSuppressedError(error, failure, "watch and retirement both failed");
148
+ };
149
+ const retireBackend = async () => {
150
+ const old = backend;
151
+ try {
152
+ await old?.close();
153
+ }
154
+ catch (error) {
155
+ retainRetirement(error);
156
+ }
157
+ if (backend === old) {
158
+ backend = undefined;
159
+ registered.clear();
160
+ observedDirectories = 0;
161
+ }
162
+ if (retirementFailure)
163
+ throw retirementFailure.error;
164
+ };
165
+ const lose = (error, operation = "scan") => {
166
+ if (terminal || failure !== undefined)
167
+ return;
168
+ if (error === undefined)
169
+ error = new FsSafeError("helper-failed", "watch operation failed without an error value", { details: { operation } });
170
+ retain(error);
171
+ const details = error instanceof FsSafeError ? error.details : undefined;
172
+ const code = details?.code ?? error?.code;
173
+ failureInfo = Object.freeze({ error, operation: details?.operation === "watch" ? "watch" : details?.operation === "callback" ? "callback" : details?.operation === "close" ? "close" : operation, ...(typeof code === "string" ? { code } : {}) });
174
+ current.abort.abort(error);
175
+ current.waiter.reject(error);
176
+ pendingWaiter?.reject(error);
177
+ runningWaiter?.reject(error);
178
+ pendingWaiter = undefined;
179
+ runningWaiter = undefined;
180
+ clearTimers();
181
+ state = "unavailable";
182
+ // Stop backend admission before notifying. Its join remains owned by active/close.
183
+ try {
184
+ backend?.close();
185
+ }
186
+ catch (error) {
187
+ retainRetirement(error);
188
+ }
189
+ try {
190
+ notifyHealth();
191
+ }
192
+ catch (callbackError) {
193
+ retain(callbackError);
194
+ }
195
+ };
196
+ const scheduleInterval = () => {
197
+ if (terminal || failure !== undefined)
198
+ return;
199
+ timer = setTimeout(() => { timer = undefined; void request().catch(() => { }); }, intervalMs);
200
+ if (!persistent)
201
+ timer.unref();
202
+ };
203
+ const onHint = (g, batch) => {
204
+ if (terminal || current !== g || g.abort.signal.aborted || failure !== undefined)
205
+ return;
206
+ if (batch.error === "ESTALE")
207
+ refreshBackend = true;
208
+ else if (batch.error) {
209
+ lose(new FsSafeError("helper-failed", "native watch failed", { details: { operation: "watch", code: batch.error } }));
210
+ return;
211
+ }
212
+ if (batch.overflow)
213
+ pendingChanges = undefined;
214
+ else if (pendingChanges)
215
+ for (const hint of batch.hints) {
216
+ const key = JSON.stringify([hint.directory, hint.name]);
217
+ if (!pendingChanges.has(key) && pendingChanges.size >= maxPendingPaths) {
218
+ pendingChanges = undefined;
219
+ break;
220
+ }
221
+ const previous = pendingChanges.get(key);
222
+ pendingChanges.set(key, previous?.event === "rename" ? previous : hint);
223
+ }
224
+ pendingHint = true;
225
+ pending = true;
226
+ if (hintTimer)
227
+ return;
228
+ hintTimer = setTimeout(() => {
229
+ hintTimer = undefined;
230
+ if (terminal || current !== g || failure !== undefined)
231
+ return;
232
+ // Raw backend filenames stay private. Reconcile before publishing detail.
233
+ void request().catch(() => { });
234
+ }, coalesceMs);
235
+ if (!persistent)
236
+ hintTimer.unref();
237
+ };
238
+ const fallBack = (error) => {
239
+ if (options.mode !== "auto" || getFsSafeNativeConfig().mode === "require" ||
240
+ !(error instanceof FsSafeError) || error.code !== "helper-unavailable")
241
+ return false;
242
+ mode = "poll";
243
+ binding = undefined;
244
+ intervalMs = options.intervalMs ?? 1000;
245
+ return true;
246
+ };
247
+ const observe = async (g) => {
248
+ check(g);
249
+ if (selectionFailure !== undefined)
250
+ throw new FsSafeError("helper-unavailable", "native watch events are unavailable", { cause: selectionFailure, details: { operation: "watch" } });
251
+ if (refreshBackend) {
252
+ await retireBackend();
253
+ check(g);
254
+ refreshBackend = false;
255
+ }
256
+ if (resetRequested) {
257
+ await retireBackend();
258
+ check(g);
259
+ snapshot = undefined;
260
+ resetRequested = false;
261
+ }
262
+ state = snapshot ? "reconciling" : "starting";
263
+ notifyHealth();
264
+ check(g);
265
+ const started = performance.now();
266
+ const hadHints = pendingHint;
267
+ const hints = pendingChanges;
268
+ pendingHint = false;
269
+ pendingChanges = new Map();
270
+ clearTimeout(hintTimer);
271
+ hintTimer = undefined;
272
+ if (mode === "events" && !backend && g.scopes.length) {
273
+ try {
274
+ const candidate = new NativeWatchBackend(binding, context, batch => {
275
+ if (backend === candidate)
276
+ onHint(g, batch);
277
+ }, maxPendingPaths, persistent);
278
+ backend = candidate;
279
+ const hookResult = getFsSafeTestHooks()?.afterWatchBackendCreated?.(context.rootReal, batch => {
280
+ if (backend === candidate)
281
+ onHint(g, batch);
282
+ }, (path, flags) => candidate.testEvent(path, flags));
283
+ assertSynchronousCallbackResult(hookResult, "afterWatchBackendCreated");
284
+ }
285
+ catch (error) {
286
+ if (!fallBack(error))
287
+ throw error;
288
+ }
289
+ check(g);
290
+ }
291
+ const next = await scanWatch(context, g.scopes, { exclude: options.exclude, maxDirectories, maxEntries, maxPendingPaths, admitting: !snapshot }, g.abort.signal, async (name, identity, guard) => {
292
+ check(g);
293
+ const existing = registered.get(name);
294
+ const acquire = !existing || existing.dev !== identity.dev || existing.ino !== identity.ino;
295
+ await getFsSafeTestHooks()?.beforeWatchRegistration?.(guard.realPath);
296
+ check(g);
297
+ if (acquire) {
298
+ try {
299
+ backend?.add(name, identity);
300
+ }
301
+ catch (error) {
302
+ if (snapshot || !fallBack(error))
303
+ throw error;
304
+ await retireBackend();
305
+ check(g);
306
+ }
307
+ }
308
+ await getFsSafeTestHooks()?.afterWatchRegistration?.(guard.realPath);
309
+ check(g);
310
+ if (acquire)
311
+ registered.set(name, identity);
312
+ }, retainRetirement);
313
+ check(g);
314
+ // Retire stale inventory before the next pass; that crawl installs fresh anchors first.
315
+ if ([...registered.keys()].some(name => !next.directories.has(name))) {
316
+ refreshBackend = true;
317
+ }
318
+ observedDirectories = next.directories.size;
319
+ const initial = !snapshot;
320
+ let observed = next.overflow ? undefined : changedEntries(snapshot, next, maxPendingPaths);
321
+ if (observed) {
322
+ const changes = new Map(observed.map(change => [change.path, change]));
323
+ for (const name of next.structural ?? [])
324
+ for (const change of scopedChanges(g.scopes, { path: name, type: "structural" }))
325
+ changes.set(change.path, change);
326
+ observed = changes.size > maxPendingPaths ? undefined : [...changes.values()];
327
+ }
328
+ let admittedHints = [];
329
+ try {
330
+ if (hadHints)
331
+ admittedHints = await admittedNativeChanges(context, g.scopes, snapshot, next, {
332
+ hints: hints ? [...hints.values()] : [], overflow: !hints,
333
+ }, g.abort.signal, maxPendingPaths);
334
+ }
335
+ catch (error) {
336
+ check(g);
337
+ await assertRootIdentityCurrent(context);
338
+ if (!isWatchPathError(error))
339
+ throw error;
340
+ admittedHints = undefined;
341
+ }
342
+ check(g);
343
+ await assertRootIdentityCurrent(context);
344
+ check(g);
345
+ let details = hadHints
346
+ ? guardedHintChanges(g.scopes, snapshot, next, admittedHints, observed, maxPendingPaths)
347
+ : observed;
348
+ const behind = (hadHints || pendingHint) && performance.now() - started > coalesceMs;
349
+ if (behind)
350
+ details = undefined;
351
+ snapshot = next;
352
+ // Publish before readiness; callbacks may synchronously retire this generation.
353
+ if (initial || !details || details.length)
354
+ dirty(g, initial ? "reconcile" : !details ? "overflow" : hadHints ? "event" : "reconcile", initial ? undefined : details);
355
+ check(g);
356
+ state = "ready";
357
+ notifyHealth();
358
+ check(g);
359
+ };
360
+ const pump = () => {
361
+ if (active || terminal || failure !== undefined)
362
+ return;
363
+ // Enroll before any callback. There is one active pass and one coalesced request.
364
+ active = Promise.resolve().then(async () => {
365
+ do {
366
+ const g = current;
367
+ pending = false;
368
+ if (pendingWaiter) {
369
+ // A scope replacement keeps reconcile requests alive until its baseline completes.
370
+ const previous = runningWaiter;
371
+ runningWaiter = pendingWaiter;
372
+ pendingWaiter = undefined;
373
+ if (previous)
374
+ void runningWaiter.promise.then(() => previous.resolve(), error => previous.reject(error));
375
+ }
376
+ try {
377
+ await observe(g);
378
+ check(g);
379
+ g.waiter.resolve();
380
+ runningWaiter?.resolve();
381
+ runningWaiter = undefined;
382
+ }
383
+ catch (error) {
384
+ g.waiter.reject(error);
385
+ if (!g.abort.signal.aborted && current === g && !terminal)
386
+ lose(error);
387
+ try {
388
+ await retireBackend();
389
+ }
390
+ catch (closeError) {
391
+ retainRetirement(closeError);
392
+ }
393
+ }
394
+ if (current !== g) {
395
+ await retireBackend();
396
+ snapshot = undefined;
397
+ pending = true;
398
+ }
399
+ } while (pending && !terminal && failure === undefined);
400
+ }).catch(lose).finally(() => {
401
+ active = undefined;
402
+ if (!terminal && failure === undefined) {
403
+ if (pending || !current.waiter.settled)
404
+ pump();
405
+ else
406
+ scheduleInterval();
407
+ }
408
+ });
409
+ };
410
+ const request = () => {
411
+ if (terminal)
412
+ return Promise.reject(retired());
413
+ if (failure !== undefined)
414
+ return Promise.reject(failure);
415
+ clearTimeout(timer);
416
+ timer = undefined;
417
+ pending = true;
418
+ pendingWaiter ??= deferred();
419
+ const result = pendingWaiter.promise;
420
+ pump();
421
+ return result;
422
+ };
423
+ const close = () => {
424
+ if (closing)
425
+ return closing;
426
+ terminal = true;
427
+ current.abort.abort(retired());
428
+ current.waiter.reject(retired());
429
+ pendingWaiter?.reject(retired());
430
+ runningWaiter?.reject(retired());
431
+ pendingWaiter = undefined;
432
+ runningWaiter = undefined;
433
+ clearTimers();
434
+ options.signal?.removeEventListener("abort", abort);
435
+ // Start physical stop immediately, without waiting behind an in-flight scan.
436
+ try {
437
+ backend?.close();
438
+ }
439
+ catch (error) {
440
+ retainRetirement(error);
441
+ }
442
+ closing = Promise.resolve().then(async () => {
443
+ await active;
444
+ try {
445
+ await retireBackend();
446
+ }
447
+ catch (error) {
448
+ retainRetirement(error);
449
+ }
450
+ state = "closed";
451
+ if (retirementFailure)
452
+ throw combinedFailure();
453
+ });
454
+ void closing.catch(() => { });
455
+ return closing;
456
+ };
457
+ const abort = () => { void close(); };
458
+ const subscription = {
459
+ ready, health, close, [Symbol.asyncDispose]: close,
460
+ reconcile: request,
461
+ setScopes(scopes) {
462
+ if (terminal)
463
+ return Promise.reject(retired());
464
+ if (failure !== undefined)
465
+ return Promise.reject(failure);
466
+ const previous = current;
467
+ const next = makeGeneration(scopes);
468
+ // Scope accessors may synchronously close this owner during validation.
469
+ if (terminal || failure !== undefined || current !== previous) {
470
+ next.waiter.reject(failure ?? retired());
471
+ return next.waiter.promise;
472
+ }
473
+ current.abort.abort(retired());
474
+ current.waiter.reject(retired());
475
+ current = next;
476
+ resetRequested = true;
477
+ try {
478
+ backend?.close();
479
+ }
480
+ catch (error) {
481
+ retainRetirement(error);
482
+ }
483
+ clearTimers();
484
+ state = "starting";
485
+ // If idle, retire the old backend before the next scan can admit anything.
486
+ if (!active) {
487
+ active = retireBackend().catch(error => lose(error, "close")).finally(() => {
488
+ active = undefined;
489
+ snapshot = undefined;
490
+ pump();
491
+ });
492
+ }
493
+ return next.waiter.promise;
494
+ },
495
+ };
496
+ options.signal?.addEventListener("abort", abort, { once: true });
497
+ if (options.signal?.aborted)
498
+ abort();
499
+ else
500
+ pump();
501
+ return subscription;
502
+ }
@@ -9,7 +9,6 @@ export type WindowsOwnerSummary = Omit<PermissionFailureFields & {
9
9
  daclPresent?: boolean;
10
10
  aces?: WindowsOwnerAce[];
11
11
  aclError?: string;
12
- remote?: boolean;
13
12
  trusted?: boolean;
14
13
  }, never>;
15
14
  export type WindowsOwnerAce = {
@@ -87,7 +87,6 @@ export async function inspectWindowsOwner(params) {
87
87
  sid: ownerSid,
88
88
  currentUserSid,
89
89
  ...parseWindowsAclFacts(parsed),
90
- remote,
91
90
  trusted: !remote && (ownerSid === currentUserSid || TRUSTED_OWNER_SIDS.has(ownerSid)),
92
91
  };
93
92
  }
@@ -312,6 +312,10 @@ public static partial class FsSafeWindowsBridge {
312
312
  }
313
313
  }
314
314
  }
315
+ static object InspectPath(string path) {
316
+ // Raw reporting retains ACL facts when locality is unknown; admission remains strict.
317
+ using(var handle=Open(path,0x00020080,false)) return Security(handle,false);
318
+ }
315
319
  public static object Execute(string operation,string path) {
316
320
  try {
317
321
  object result;
@@ -323,10 +327,8 @@ public static partial class FsSafeWindowsBridge {
323
327
  Require(!handle.IsInvalid,"EBADF","inherited file handle is unavailable");
324
328
  result=Row("identity",Identity(handle),"security",Security(handle));
325
329
  }
326
- } else if(operation=="path") {
327
- // Raw reporting retains ACL facts when locality is unknown; admission remains strict.
328
- using(var handle=Open(path,0x00020080,false)) result=Security(handle,false);
329
- } else throw new Failure("EINVAL","unknown Windows security operation");
330
+ } else if(operation=="path") result=InspectPath(path);
331
+ else throw new Failure("EINVAL","unknown Windows security operation");
330
332
  return Row("ok",true,"result",result);
331
333
  } catch(Failure error) { return Row("ok",false,"code",error.Code,"message",error.Message); }
332
334
  catch(Exception) { return Row("ok",false,"code","EIO","message","Windows security descriptor processing failed"); }
@@ -1,6 +1,6 @@
1
1
  param(
2
2
  [Parameter(Mandatory = $true)]
3
- [ValidateSet('path', 'descriptor', 'create', 'directory', 'protect-file', 'verify-file')]
3
+ [ValidateSet('path', 'paths', 'descriptor', 'create', 'directory', 'protect-file', 'verify-file')]
4
4
  [string] $Operation
5
5
  )
6
6
 
@@ -11,5 +11,80 @@ $env:PSModulePath = [IO.Path]::Combine($PSHOME, 'Modules')
11
11
  [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
12
12
 
13
13
  Microsoft.PowerShell.Utility\Add-Type -LiteralPath ([IO.Path]::Combine($PSScriptRoot, 'windows-security-bridge.cs'))
14
- $targetPath = [Environment]::GetEnvironmentVariable('FS_SAFE_WINDOWS_SECURITY_PATH')
15
- [FsSafeWindowsBridge]::Execute($Operation, $targetPath) | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 8 -Compress
14
+ if ($Operation -eq 'paths') {
15
+ $reply = $null
16
+ $limit = 16 * 1024 * 1024
17
+ try {
18
+ $inputStream = [Console]::OpenStandardInput()
19
+ $inputBytes = [IO.MemoryStream]::new()
20
+ try {
21
+ $buffer = [byte[]]::new(8192)
22
+ while (($read = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
23
+ if ($inputBytes.Length + $read -gt $limit) {
24
+ throw 'Windows security path batch exceeds the input budget'
25
+ }
26
+ $inputBytes.Write($buffer, 0, $read)
27
+ }
28
+ $inputJson = [Text.UTF8Encoding]::new($false, $true).GetString($inputBytes.ToArray())
29
+ } finally {
30
+ $inputBytes.Dispose()
31
+ $inputStream.Dispose()
32
+ }
33
+ if (-not $inputJson.TrimStart().StartsWith('[')) {
34
+ throw 'Windows security paths must be a JSON array'
35
+ }
36
+ # Validate the whole document before wrapping it; the wrapper keeps empty,
37
+ # singleton, and nested arrays intact on Windows PowerShell 5.1.
38
+ $null = Microsoft.PowerShell.Utility\ConvertFrom-Json -InputObject $inputJson
39
+ $request = Microsoft.PowerShell.Utility\ConvertFrom-Json -InputObject ('{"paths":' + $inputJson + '}')
40
+ if ($request.paths -isnot [array]) {
41
+ throw 'Windows security paths must be a JSON array'
42
+ }
43
+ for ($index = 0; $index -lt $request.paths.Length; $index++) {
44
+ $value = $request.paths[$index]
45
+ if ($value -isnot [string] -or $value.Length -eq 0 -or $value.IndexOf([char]0) -ge 0) {
46
+ throw 'Windows security paths must be nonempty strings without NUL bytes'
47
+ }
48
+ }
49
+ } catch {
50
+ $reply = @{ ok = $false; code = 'EINVAL'; message = 'Invalid Windows security path batch' }
51
+ }
52
+ if ($null -eq $reply) {
53
+ $utf8 = [Console]::OutputEncoding
54
+ $prefix = '{"ok":true,"result":['
55
+ $suffix = ']}'
56
+ $bytes = $utf8.GetByteCount($prefix) + $utf8.GetByteCount($suffix)
57
+ $encoded = [Text.StringBuilder]::new($prefix)
58
+ $separator = ''
59
+ foreach ($pathname in $request.paths) {
60
+ try {
61
+ $reply = [FsSafeWindowsBridge]::Execute('path', $pathname)
62
+ } catch {
63
+ $reply = @{ ok = $false; code = 'EINVAL'; message = 'Invalid Windows security path batch' }
64
+ }
65
+ if (-not $reply.ok) { break }
66
+ $rowJson = Microsoft.PowerShell.Utility\ConvertTo-Json -InputObject ([ordered]@{ path = $pathname; security = $reply.result }) -Depth 8 -Compress
67
+ $reply = $null
68
+ $nextBytes = $bytes + $separator.Length + $utf8.GetByteCount($rowJson)
69
+ if ($nextBytes -gt $limit) {
70
+ $rowJson = $null
71
+ $reply = @{ ok = $false; code = 'too-large'; message = 'Windows security batch exceeded its output budget' }
72
+ break
73
+ }
74
+ [void]$encoded.Append($separator).Append($rowJson)
75
+ $bytes = $nextBytes
76
+ $separator = ','
77
+ $rowJson = $null
78
+ }
79
+ if ($null -eq $reply) {
80
+ [void]$encoded.Append($suffix)
81
+ [Console]::Write($encoded.ToString())
82
+ return
83
+ }
84
+ $encoded = $null
85
+ }
86
+ } else {
87
+ $targetPath = [Environment]::GetEnvironmentVariable('FS_SAFE_WINDOWS_SECURITY_PATH')
88
+ $reply = [FsSafeWindowsBridge]::Execute($Operation, $targetPath)
89
+ }
90
+ $reply | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 8 -Compress
@@ -1,6 +1,14 @@
1
1
  import type { NativeWindowsDescriptorSecurityFacts, NativeWindowsSecurityFacts } from "./native-binding.js";
2
+ import type { FsSafeNativeMode } from "./native-config.js";
3
+ import { type DescriptorFacts } from "./windows-security-facts.js";
4
+ export declare const WINDOWS_SECURITY_BATCH_MAX_BYTES: number;
2
5
  /** Cleanup must preserve stages still reachable by an unsettled command. */
3
6
  export declare function hasUnsettledWindowsSecurityCommand(error: unknown): boolean;
7
+ export declare function readWindowsSecurityFactsBatch(paths: readonly string[], options: {
8
+ timeoutMs: number;
9
+ native: boolean;
10
+ mode: FsSafeNativeMode;
11
+ }): Promise<DescriptorFacts[]>;
4
12
  export declare function readWindowsSecurityFactsCommand(targetPath: string): NativeWindowsSecurityFacts;
5
13
  export declare function inspectWindowsDescriptorCommand(fd: number): Promise<NativeWindowsDescriptorSecurityFacts>;
6
14
  export declare function createPrivateWindowsDirectoryCommand(targetPath: string, expectedParentIdentity?: string): Promise<{