@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.
- package/CHANGELOG.md +50 -0
- package/README.md +24 -6
- package/dist/advanced.d.ts +4 -0
- package/dist/advanced.js +2 -0
- package/dist/archive-plan.d.ts +2 -7
- package/dist/archive-read.js +9 -18
- package/dist/archive-zip-entry.d.ts +11 -11
- package/dist/archive-zip-entry.js +3 -35
- package/dist/archive-zip-integrity.d.ts +2 -2
- package/dist/archive-zip-integrity.js +2 -12
- package/dist/archive-zip-loader.d.ts +7 -3
- package/dist/archive-zip-loader.js +10 -9
- package/dist/archive-zip-preflight.d.ts +2 -1
- package/dist/archive-zip-preflight.js +16 -7
- package/dist/archive.js +17 -16
- package/dist/atomic.d.ts +1 -1
- package/dist/directory-receipt.js +5 -7
- package/dist/effective-uid.js +1 -4
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +3 -2
- package/dist/file-lock-sync-root-held.js +1 -4
- package/dist/file-store.d.ts +4 -7
- package/dist/json-document-store.d.ts +4 -9
- package/dist/local-file-access.js +2 -5
- package/dist/move-path-cleanup.js +4 -4
- package/dist/native-binding.d.ts +28 -1
- package/dist/native-staged-symlink.d.ts +13 -0
- package/dist/native-staged-symlink.js +303 -0
- package/dist/owner-dacl-batch-worker.d.ts +1 -0
- package/dist/owner-dacl-batch-worker.js +54 -0
- package/dist/owner-dacl-batch.d.ts +5 -0
- package/dist/owner-dacl-batch.js +64 -0
- package/dist/owner-dacl.d.ts +2 -0
- package/dist/owner-dacl.js +3 -0
- package/dist/path.js +17 -1
- package/dist/permission-exec.js +3 -6
- package/dist/permissions-public.d.ts +1 -0
- package/dist/permissions-public.js +1 -0
- package/dist/pinned-mutation-admission.d.ts +0 -1
- package/dist/pinned-open.d.ts +0 -1
- package/dist/pinned-open.js +1 -2
- package/dist/publish-copy-stage.js +4 -0
- package/dist/read-opened-file.d.ts +2 -5
- package/dist/regular-file.js +3 -3
- package/dist/replace-file-buffer.d.ts +4 -0
- package/dist/replace-file-buffer.js +36 -0
- package/dist/replace-file-copy-fallback.d.ts +3 -2
- package/dist/replace-file-copy-fallback.js +66 -38
- package/dist/replace-file-descriptor.d.ts +4 -0
- package/dist/replace-file-descriptor.js +9 -1
- package/dist/replace-file-destination.d.ts +17 -0
- package/dist/replace-file-destination.js +61 -0
- package/dist/replace-file-mutation.d.ts +26 -0
- package/dist/replace-file-mutation.js +47 -0
- package/dist/replace-file-temp-owner.d.ts +2 -2
- package/dist/replace-file-temp-owner.js +16 -4
- package/dist/replace-file-types.d.ts +55 -0
- package/dist/replace-file-types.js +1 -0
- package/dist/replace-file.d.ts +3 -55
- package/dist/replace-file.js +29 -10
- package/dist/retained-file-types.d.ts +61 -0
- package/dist/retained-file-types.js +1 -0
- package/dist/retained-file.d.ts +3 -0
- package/dist/retained-file.js +121 -0
- package/dist/root-directory-entry.d.ts +9 -0
- package/dist/root-directory-entry.js +28 -0
- package/dist/root-directory-list.d.ts +7 -1
- package/dist/root-directory-list.js +48 -23
- package/dist/root-handle-context.d.ts +4 -0
- package/dist/root-handle-context.js +12 -0
- package/dist/root-impl.d.ts +3 -3
- package/dist/root-impl.js +8 -5
- package/dist/root-observed-path.d.ts +0 -1
- package/dist/root-observed-path.js +0 -3
- package/dist/root-path-observation.d.ts +4 -11
- package/dist/root-path.js +7 -10
- package/dist/root-walk.d.ts +19 -12
- package/dist/root-walk.js +49 -18
- package/dist/root-write-admission.js +0 -2
- package/dist/safe-path-segment.d.ts +1 -0
- package/dist/safe-path-segment.js +8 -2
- package/dist/secure-file.js +3 -2
- package/dist/sidecar-lock.js +5 -3
- package/dist/staged-symlink-types.d.ts +49 -0
- package/dist/staged-symlink-types.js +1 -0
- package/dist/symlink-parents.js +58 -7
- package/dist/temp-target.js +5 -2
- package/dist/temp-workspace-admission.js +22 -21
- package/dist/temp-workspace-child-admission.d.ts +1 -1
- package/dist/temp-workspace-child-admission.js +14 -9
- package/dist/temp-workspace-owner.js +4 -9
- package/dist/temp-workspace-ownership.d.ts +8 -0
- package/dist/temp-workspace-ownership.js +52 -0
- package/dist/test-hooks.d.ts +3 -0
- package/dist/text-atomic.d.ts +2 -1
- package/dist/text-atomic.js +2 -0
- package/dist/trash.js +27 -1
- package/dist/walk.d.ts +2 -5
- package/dist/watch-alias.d.ts +6 -0
- package/dist/watch-alias.js +80 -0
- package/dist/watch-hints.d.ts +8 -0
- package/dist/watch-hints.js +77 -0
- package/dist/watch-native.d.ts +32 -0
- package/dist/watch-native.js +56 -0
- package/dist/watch-scan.d.ts +24 -0
- package/dist/watch-scan.js +269 -0
- package/dist/watch-types.d.ts +58 -0
- package/dist/watch-types.js +1 -0
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +502 -0
- package/dist/windows-owner.d.ts +0 -1
- package/dist/windows-owner.js +0 -1
- package/dist/windows-security-bridge.cs +6 -4
- package/dist/windows-security-bridge.ps1 +78 -3
- package/dist/windows-security-command.d.ts +8 -0
- package/dist/windows-security-command.js +66 -12
- package/dist/windows-security-facts.d.ts +3 -0
- package/dist/windows-security-facts.js +4 -0
- package/docs/advanced.md +4 -2
- package/docs/archive.md +8 -0
- package/docs/atomic.md +72 -3
- package/docs/contributing.md +35 -0
- package/docs/durability.md +7 -0
- package/docs/index.md +1 -0
- package/docs/install.md +28 -0
- package/docs/native-helper.md +14 -4
- package/docs/native.md +45 -1
- package/docs/permissions.md +66 -0
- package/docs/public-api.md +7 -1
- package/docs/retained-file.md +113 -0
- package/docs/root.md +6 -1
- package/docs/security-model.md +4 -1
- package/docs/sidecar-lock.md +2 -0
- package/docs/staged-symlink.md +123 -0
- package/docs/store.md +3 -1
- package/docs/temp.md +24 -4
- package/docs/testing.md +88 -0
- package/docs/types.md +6 -0
- package/docs/walk.md +22 -1
- package/docs/watch.md +184 -0
- package/docs/writing.md +10 -0
- 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
|
+
}
|
package/dist/windows-owner.d.ts
CHANGED
package/dist/windows-owner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
$
|
|
15
|
-
|
|
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<{
|