@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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { FsSafeError } from "./errors.js";
|
|
3
|
+
import { isNotFoundPathError } from "./path.js";
|
|
4
|
+
import { assertRootIdentityCurrent, resolvePathInRoot } from "./root-context.js";
|
|
5
|
+
import { createRootDirectoryObservationGuard, assertRootDirectoryObservationGuard } from "./root-directory-list.js";
|
|
6
|
+
import { lookupRootDirectoryEntry } from "./root-directory-entry.js";
|
|
7
|
+
import { nativeChanges, scopedChanges } from "./watch-hints.js";
|
|
8
|
+
/** Resolve native spelling aliases without treating case folding as identity. */
|
|
9
|
+
export async function admittedNativeChanges(root, scopes, before, after, batch, signal, limit) {
|
|
10
|
+
if (!nativeChanges(scopes, before, batch, limit))
|
|
11
|
+
return undefined;
|
|
12
|
+
const result = new Map();
|
|
13
|
+
const candidates = new Map([...before?.targets ?? [], ...after.targets, ...after.directories]);
|
|
14
|
+
const add = (change) => {
|
|
15
|
+
if (!result.has(change.path) && result.size >= limit)
|
|
16
|
+
return false;
|
|
17
|
+
const prior = result.get(change.path);
|
|
18
|
+
result.set(change.path, prior?.type === "structural" ? prior : change);
|
|
19
|
+
return true;
|
|
20
|
+
};
|
|
21
|
+
for (const hint of batch.hints) {
|
|
22
|
+
signal.throwIfAborted();
|
|
23
|
+
const name = hint.name; // nativeChanges rejected unknown or non-literal names.
|
|
24
|
+
let parent = hint.directory;
|
|
25
|
+
let guard;
|
|
26
|
+
let expected = after.directories.get(parent);
|
|
27
|
+
if (!expected) {
|
|
28
|
+
// Native recursion observes one Root handle and may report descendants of
|
|
29
|
+
// unselected/entry-only directories. Admit a parent alias by exact identity,
|
|
30
|
+
// not by lowercasing, and do not turn unselected descendants into events.
|
|
31
|
+
try {
|
|
32
|
+
const resolved = await resolvePathInRoot(root, parent ? "./" + parent : ".", { rejectSymlinks: true });
|
|
33
|
+
guard = await createRootDirectoryObservationGuard(root, resolved.resolved);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
await assertRootIdentityCurrent(root);
|
|
37
|
+
if (isNotFoundPathError(error) || (error instanceof FsSafeError && ["not-found", "path-alias", "outside-workspace", "symlink", "not-file"].includes(error.code)))
|
|
38
|
+
continue;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
const admitted = [...after.directories].find(([, identity]) => identity.dev === guard.stat.dev && identity.ino === guard.stat.ino);
|
|
42
|
+
if (!admitted)
|
|
43
|
+
continue;
|
|
44
|
+
[parent, expected] = admitted;
|
|
45
|
+
}
|
|
46
|
+
const candidate = parent ? path.join(parent, name) : name;
|
|
47
|
+
const selected = scopedChanges(scopes, { path: candidate,
|
|
48
|
+
type: hint.event === "change" && before?.entries.get(candidate)?.startsWith("file:") ? "content" : "structural" });
|
|
49
|
+
if (selected.length) {
|
|
50
|
+
for (const change of selected)
|
|
51
|
+
if (!add(change))
|
|
52
|
+
return undefined;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!guard) {
|
|
56
|
+
const resolved = await resolvePathInRoot(root, parent ? "./" + parent : ".", { rejectSymlinks: true });
|
|
57
|
+
guard = await createRootDirectoryObservationGuard(root, resolved.resolved);
|
|
58
|
+
}
|
|
59
|
+
if (guard.stat.dev !== expected.dev || guard.stat.ino !== expected.ino) {
|
|
60
|
+
throw new FsSafeError("path-mismatch", "watch hint parent changed during reconciliation");
|
|
61
|
+
}
|
|
62
|
+
const found = await lookupRootDirectoryEntry(root, guard, name);
|
|
63
|
+
signal.throwIfAborted();
|
|
64
|
+
if (!found)
|
|
65
|
+
return undefined; // Could be a deleted short-name/case alias.
|
|
66
|
+
for (const [relative, identity] of candidates) {
|
|
67
|
+
if (!relative || (path.dirname(relative) === "." ? "" : path.dirname(relative)) !== parent)
|
|
68
|
+
continue;
|
|
69
|
+
if (identity.dev !== found.identity.dev || identity.ino !== found.identity.ino)
|
|
70
|
+
continue;
|
|
71
|
+
for (const change of scopedChanges(scopes, { path: relative, type: "structural" }))
|
|
72
|
+
if (!add(change))
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
await assertRootDirectoryObservationGuard(root, guard);
|
|
76
|
+
}
|
|
77
|
+
await assertRootIdentityCurrent(root);
|
|
78
|
+
signal.throwIfAborted();
|
|
79
|
+
return [...result.values()];
|
|
80
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { NativeWatchBatch } from "./watch-native.js";
|
|
2
|
+
import type { WatchSnapshot } from "./watch-scan.js";
|
|
3
|
+
import type { WatchChange, WatchScope } from "./watch-types.js";
|
|
4
|
+
export declare function scopedChanges(scopes: readonly WatchScope[], change: WatchChange): WatchChange[];
|
|
5
|
+
export declare function nativeChanges(scopes: readonly WatchScope[], snapshot: WatchSnapshot | undefined, batch: NativeWatchBatch, limit?: number): WatchChange[] | undefined;
|
|
6
|
+
export declare function changedEntries(before: WatchSnapshot | undefined, after: WatchSnapshot, limit: number): WatchChange[] | undefined;
|
|
7
|
+
/** Backend names never establish authority to publish a pathname. */
|
|
8
|
+
export declare function guardedHintChanges(scopes: readonly WatchScope[], before: WatchSnapshot | undefined, after: WatchSnapshot, hints: readonly WatchChange[] | undefined, observed: readonly WatchChange[] | undefined, limit: number): WatchChange[] | undefined;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
function below(parent, child) {
|
|
3
|
+
return parent === "" ? child !== "" : child.startsWith(parent + path.sep);
|
|
4
|
+
}
|
|
5
|
+
function distance(parent, child) {
|
|
6
|
+
return (parent === "" ? child : child.slice(parent.length + 1)).split(path.sep).length;
|
|
7
|
+
}
|
|
8
|
+
export function scopedChanges(scopes, change) {
|
|
9
|
+
const result = new Map();
|
|
10
|
+
for (const scope of scopes) {
|
|
11
|
+
if (scope.path === change.path || (scope.kind === "tree" && below(scope.path, change.path) && distance(scope.path, change.path) <= scope.depth)) {
|
|
12
|
+
result.set(change.path, change);
|
|
13
|
+
}
|
|
14
|
+
else if (below(change.path, scope.path)) {
|
|
15
|
+
// A changed ancestor invalidates the requested target, not authority outside it.
|
|
16
|
+
result.set(scope.path, { path: scope.path, type: "structural" });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return [...result.values()];
|
|
20
|
+
}
|
|
21
|
+
export function nativeChanges(scopes, snapshot, batch, limit = 256) {
|
|
22
|
+
if (batch.overflow)
|
|
23
|
+
return undefined;
|
|
24
|
+
const result = new Map();
|
|
25
|
+
for (const hint of batch.hints) {
|
|
26
|
+
const name = hint.name;
|
|
27
|
+
// Backend filenames are untrusted hints. Never resolve or perform I/O on them.
|
|
28
|
+
if (typeof name !== "string" || !name || name === "." || name === ".." || name.includes("\0") || name.includes("/") || (process.platform === "win32" && /[\\:]/.test(name)))
|
|
29
|
+
return undefined;
|
|
30
|
+
const relative = hint.directory ? path.join(hint.directory, name) : name;
|
|
31
|
+
for (const change of scopedChanges(scopes, {
|
|
32
|
+
path: relative,
|
|
33
|
+
type: hint.event === "change" && snapshot?.entries.get(relative)?.startsWith("file:") ? "content" : "structural",
|
|
34
|
+
})) {
|
|
35
|
+
if (!result.has(change.path) && result.size >= limit)
|
|
36
|
+
return undefined;
|
|
37
|
+
const prior = result.get(change.path);
|
|
38
|
+
result.set(change.path, prior?.type === "structural" ? prior : change);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return [...result.values()];
|
|
42
|
+
}
|
|
43
|
+
export function changedEntries(before, after, limit) {
|
|
44
|
+
if (!before)
|
|
45
|
+
return undefined;
|
|
46
|
+
const changes = [];
|
|
47
|
+
for (const name of new Set([...before.entries.keys(), ...after.entries.keys()])) {
|
|
48
|
+
const left = before.entries.get(name);
|
|
49
|
+
const right = after.entries.get(name);
|
|
50
|
+
if (left === right)
|
|
51
|
+
continue;
|
|
52
|
+
if (changes.length >= limit)
|
|
53
|
+
return undefined;
|
|
54
|
+
const sameFile = left?.startsWith("file:") && right?.startsWith("file:") &&
|
|
55
|
+
left.split(":").slice(0, 3).join(":") === right.split(":").slice(0, 3).join(":");
|
|
56
|
+
changes.push(Object.freeze({ path: name, type: sameFile ? "content" : "structural" }));
|
|
57
|
+
}
|
|
58
|
+
return changes;
|
|
59
|
+
}
|
|
60
|
+
/** Backend names never establish authority to publish a pathname. */
|
|
61
|
+
export function guardedHintChanges(scopes, before, after, hints, observed, limit) {
|
|
62
|
+
if (!hints || !observed)
|
|
63
|
+
return undefined;
|
|
64
|
+
const result = new Map(observed.map(change => [change.path, change]));
|
|
65
|
+
for (const hint of hints) {
|
|
66
|
+
// Only publish an independently observed name (including a deletion from
|
|
67
|
+
// the previous guarded snapshot), or a target explicitly supplied by caller.
|
|
68
|
+
// A stale/misdirected inode watch may report outside names: erase its detail.
|
|
69
|
+
if (!before?.entries.has(hint.path) && !after.entries.has(hint.path) && !scopes.some(scope => scope.path === hint.path))
|
|
70
|
+
return undefined;
|
|
71
|
+
if (!result.has(hint.path) && result.size >= limit)
|
|
72
|
+
return undefined;
|
|
73
|
+
const prior = result.get(hint.path);
|
|
74
|
+
result.set(hint.path, Object.freeze(prior?.type === "structural" ? prior : hint));
|
|
75
|
+
}
|
|
76
|
+
return [...result.values()];
|
|
77
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type NativeBinding } from "./native.js";
|
|
2
|
+
import type { RootContext } from "./root-context.js";
|
|
3
|
+
import type { DirectoryIdentity } from "./watch-scan.js";
|
|
4
|
+
export type NativeWatchHint = {
|
|
5
|
+
directory: string;
|
|
6
|
+
name: string;
|
|
7
|
+
event: "rename" | "change";
|
|
8
|
+
};
|
|
9
|
+
export type NativeWatchBatch = {
|
|
10
|
+
hints: NativeWatchHint[];
|
|
11
|
+
overflow: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
};
|
|
14
|
+
export type NativeWatchWireBatch = {
|
|
15
|
+
hints: {
|
|
16
|
+
directory: string;
|
|
17
|
+
name: string;
|
|
18
|
+
structural: boolean;
|
|
19
|
+
}[];
|
|
20
|
+
overflow: boolean;
|
|
21
|
+
error?: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function watchBinding(mode: "auto" | "events" | "poll"): NativeBinding | undefined;
|
|
24
|
+
export declare class NativeWatchBackend {
|
|
25
|
+
private binding;
|
|
26
|
+
private root;
|
|
27
|
+
private id;
|
|
28
|
+
constructor(binding: NativeBinding, root: RootContext, callback: (batch: NativeWatchBatch) => void, limit: number, persistent: boolean);
|
|
29
|
+
add(name: string, identity: DirectoryIdentity): void;
|
|
30
|
+
testEvent(path: string, flags: number): void;
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { FsSafeError } from "./errors.js";
|
|
2
|
+
import { getNativeBinding } from "./native.js";
|
|
3
|
+
import { getFsSafeNativeConfig } from "./native-config.js";
|
|
4
|
+
export function watchBinding(mode) {
|
|
5
|
+
if (mode === "poll")
|
|
6
|
+
return;
|
|
7
|
+
const binding = getNativeBinding(); // Preserves require + missing-addon failure.
|
|
8
|
+
// Bun TSFN teardown remains unqualified; guarded native scans remain usable.
|
|
9
|
+
if (binding?.watchRegister && !process.versions.bun && !process.versions.deno && ["linux", "darwin", "win32"].includes(process.platform))
|
|
10
|
+
return binding;
|
|
11
|
+
if (mode === "events" || getFsSafeNativeConfig().mode === "require") {
|
|
12
|
+
throw new FsSafeError("helper-unavailable", "native watch events are unavailable", { details: { operation: "watch" } });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class NativeWatchBackend {
|
|
16
|
+
binding;
|
|
17
|
+
root;
|
|
18
|
+
id;
|
|
19
|
+
constructor(binding, root, callback, limit, persistent) {
|
|
20
|
+
this.binding = binding;
|
|
21
|
+
this.root = root;
|
|
22
|
+
try {
|
|
23
|
+
this.id = binding.watchRegister(root.rootReal, limit, batch => {
|
|
24
|
+
if (this.id !== undefined)
|
|
25
|
+
callback({ overflow: batch.overflow, error: batch.error, hints: batch.hints.map(hint => ({
|
|
26
|
+
directory: hint.directory, name: hint.name, event: hint.structural ? "rename" : "change",
|
|
27
|
+
})) });
|
|
28
|
+
}, persistent);
|
|
29
|
+
}
|
|
30
|
+
catch (cause) {
|
|
31
|
+
throw watchError(cause);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
add(name, identity) {
|
|
35
|
+
try {
|
|
36
|
+
this.binding.watchAdd(this.id, { root: this.root.rootReal, relative: name,
|
|
37
|
+
rootDev: BigInt(this.root.rootIdentity.dev), rootIno: BigInt(this.root.rootIdentity.ino), ...identity });
|
|
38
|
+
}
|
|
39
|
+
catch (cause) {
|
|
40
|
+
throw watchError(cause);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
testEvent(path, flags) { this.binding.watchTestEvent(this.id, path, flags); }
|
|
44
|
+
close() {
|
|
45
|
+
const id = this.id;
|
|
46
|
+
this.id = undefined; // Fence queued TSFN callbacks before synchronous native join.
|
|
47
|
+
if (id !== undefined)
|
|
48
|
+
this.binding.watchUnregister(id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function watchError(cause) {
|
|
52
|
+
const code = cause?.code;
|
|
53
|
+
return new FsSafeError(code === "ENOTSUP" ? "helper-unavailable" : ["ESTALE", "ENOTDIR", "ELOOP"].includes(code ?? "") ? "path-mismatch" : code === "ENOENT" ? "not-found" : "helper-failed", "native watch registration failed", {
|
|
54
|
+
cause, details: { operation: "watch", code },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type RootContext } from "./root-context.js";
|
|
2
|
+
import { type RootDirectoryObservationGuard } from "./root-directory-list.js";
|
|
3
|
+
import type { WatchOptions, WatchScope } from "./watch-types.js";
|
|
4
|
+
export type DirectoryIdentity = Readonly<{
|
|
5
|
+
dev: bigint;
|
|
6
|
+
ino: bigint;
|
|
7
|
+
}>;
|
|
8
|
+
export type WatchSnapshot = {
|
|
9
|
+
entries: Map<string, string>;
|
|
10
|
+
directories: Map<string, DirectoryIdentity>;
|
|
11
|
+
targets: Map<string, DirectoryIdentity>;
|
|
12
|
+
scanned: number;
|
|
13
|
+
structural?: Set<string>;
|
|
14
|
+
overflow?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export declare function watchScopes(input: readonly WatchScope[]): readonly WatchScope[];
|
|
17
|
+
/** A descendant's unavailable metadata never grants it authority or retires the Root. */
|
|
18
|
+
export declare function isWatchPathError(error: unknown): boolean;
|
|
19
|
+
export declare function scanWatch(root: RootContext, scopes: readonly WatchScope[], options: Pick<WatchOptions, "exclude"> & {
|
|
20
|
+
maxEntries: number;
|
|
21
|
+
maxDirectories: number;
|
|
22
|
+
maxPendingPaths: number;
|
|
23
|
+
admitting: boolean;
|
|
24
|
+
}, signal: AbortSignal, register: (name: string, identity: DirectoryIdentity, guard: RootDirectoryObservationGuard) => Promise<void>, onCleanupFailure?: (error: unknown) => void): Promise<WatchSnapshot>;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isWindowsReservedDeviceName } from "./device-path.js";
|
|
3
|
+
import { isNotFoundPathError } from "./path.js";
|
|
4
|
+
import { FsSafeError } from "./errors.js";
|
|
5
|
+
import { assertSynchronousCallbackResult } from "./mutation-authority.js";
|
|
6
|
+
import { validatePinnedRelativePath } from "./pinned-operation.js";
|
|
7
|
+
import { assertRootIdentityCurrent, assertValidRootRelativePath, resolvePathInRoot } from "./root-context.js";
|
|
8
|
+
import { createRootDirectoryObservationGuard, assertRootDirectoryObservationGuard, openRootDirectoryListing, pathStatFromStats } from "./root-directory-list.js";
|
|
9
|
+
import { createSuppressedError } from "./suppressed-error.js";
|
|
10
|
+
import { lookupRootDirectoryEntry } from "./root-directory-entry.js";
|
|
11
|
+
export function watchScopes(input) {
|
|
12
|
+
if (!Array.isArray(input) || input.length > 128)
|
|
13
|
+
throw new RangeError("watch accepts at most 128 scopes");
|
|
14
|
+
return Object.freeze(input.map(scope => {
|
|
15
|
+
const { path: suppliedPath, kind: suppliedKind, depth: suppliedDepth } = scope;
|
|
16
|
+
if (typeof suppliedPath !== "string" || path.isAbsolute(suppliedPath))
|
|
17
|
+
throw new FsSafeError("invalid-path", "watch scopes must be relative");
|
|
18
|
+
validatePinnedRelativePath(suppliedPath);
|
|
19
|
+
assertValidRootRelativePath(suppliedPath);
|
|
20
|
+
if (process.platform === "win32" && suppliedPath.split(/[\\/]/).some(component => component !== "." &&
|
|
21
|
+
(component.endsWith(".") || component.endsWith(" ") || isWindowsReservedDeviceName(component)))) {
|
|
22
|
+
throw new FsSafeError("invalid-path", "watch scopes must use literal Windows names");
|
|
23
|
+
}
|
|
24
|
+
if (suppliedKind !== "entry" && suppliedKind !== "tree")
|
|
25
|
+
throw new TypeError("invalid watch scope kind");
|
|
26
|
+
const depth = suppliedDepth ?? 32;
|
|
27
|
+
if (!Number.isSafeInteger(depth) || depth < 0 || depth > 128)
|
|
28
|
+
throw new RangeError("watch depth must be between 0 and 128");
|
|
29
|
+
// Normalize only admitted input, then remove the separator normalize preserves.
|
|
30
|
+
const spelling = path.normalize(suppliedPath);
|
|
31
|
+
const normalized = spelling.endsWith(path.sep) ? spelling.slice(0, -1) : spelling;
|
|
32
|
+
return Object.freeze({ path: normalized === "." ? "" : normalized, kind: suppliedKind, depth });
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
function kind(entry) {
|
|
36
|
+
return entry.isSymbolicLink ? "symlink" : entry.isDirectory ? "directory" : entry.isFile ? "file" : "other";
|
|
37
|
+
}
|
|
38
|
+
function fingerprint(entry, identity) {
|
|
39
|
+
const { dev, ino } = identity;
|
|
40
|
+
// Metadata is advisory; identity bits are never rounded through PathStat. Directory size/mtime
|
|
41
|
+
// reflect children, not changes to an entry-only scope.
|
|
42
|
+
return entry.isDirectory
|
|
43
|
+
? [kind(entry), dev, ino, entry.mode].join(":")
|
|
44
|
+
: [kind(entry), dev, ino, entry.size, entry.mtimeMs, entry.mode].join(":");
|
|
45
|
+
}
|
|
46
|
+
/** A descendant's unavailable metadata never grants it authority or retires the Root. */
|
|
47
|
+
export function isWatchPathError(error) {
|
|
48
|
+
if (isNotFoundPathError(error))
|
|
49
|
+
return true;
|
|
50
|
+
if (error instanceof FsSafeError) {
|
|
51
|
+
if (error.details?.operation === "watch" && ["EACCES", "EPERM", "EBUSY"].includes(String(error.details.code)))
|
|
52
|
+
return true;
|
|
53
|
+
return ["not-found", "path-mismatch", "not-file", "symlink", "outside-workspace", "path-alias"].includes(error.code);
|
|
54
|
+
}
|
|
55
|
+
return ["ENOTDIR", "EACCES", "EPERM", "EBUSY", "ESTALE", "EIO", "ELOOP"].includes(error?.code ?? "");
|
|
56
|
+
}
|
|
57
|
+
export async function scanWatch(root, scopes, options, signal, register, onCleanupFailure) {
|
|
58
|
+
const result = { entries: new Map(), directories: new Map(), targets: new Map(), scanned: 0 };
|
|
59
|
+
const attempts = new Map();
|
|
60
|
+
const guards = new Map();
|
|
61
|
+
const walked = new Map();
|
|
62
|
+
const structural = new Set();
|
|
63
|
+
result.structural = structural;
|
|
64
|
+
const invalidate = (relative) => {
|
|
65
|
+
if (structural.size < options.maxPendingPaths)
|
|
66
|
+
structural.add(relative);
|
|
67
|
+
else
|
|
68
|
+
result.overflow = true;
|
|
69
|
+
// Discard partially observed names when their enclosing directory lost admission.
|
|
70
|
+
const below = (name) => name === relative || !relative || name.startsWith(relative + path.sep);
|
|
71
|
+
for (const map of [result.entries, result.targets, result.directories, guards, walked]) {
|
|
72
|
+
for (const name of map.keys())
|
|
73
|
+
if (below(name))
|
|
74
|
+
map.delete(name);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const recover = async (relative, error) => {
|
|
78
|
+
signal.throwIfAborted();
|
|
79
|
+
await assertRootIdentityCurrent(root);
|
|
80
|
+
const code = error instanceof FsSafeError ? error.details?.code ?? error.code : error?.code;
|
|
81
|
+
if (!isWatchPathError(error) || (!relative && (code === "EACCES" || code === "EPERM")))
|
|
82
|
+
throw error;
|
|
83
|
+
invalidate(relative);
|
|
84
|
+
};
|
|
85
|
+
const examined = () => {
|
|
86
|
+
if (++result.scanned > options.maxEntries)
|
|
87
|
+
throw new FsSafeError("too-large", "watch entry budget exceeded", { details: { operation: "scan" } });
|
|
88
|
+
};
|
|
89
|
+
const excluded = (name, entry) => {
|
|
90
|
+
let value;
|
|
91
|
+
try {
|
|
92
|
+
value = options.exclude?.({ path: name, kind: kind(entry) });
|
|
93
|
+
assertSynchronousCallbackResult(value, "watch exclude");
|
|
94
|
+
}
|
|
95
|
+
catch (cause) {
|
|
96
|
+
throw new FsSafeError("helper-failed", "watch exclusion callback failed", { cause, details: { operation: "callback" } });
|
|
97
|
+
}
|
|
98
|
+
signal.throwIfAborted();
|
|
99
|
+
return value;
|
|
100
|
+
};
|
|
101
|
+
const directory = async (relative) => {
|
|
102
|
+
signal.throwIfAborted();
|
|
103
|
+
const prior = guards.get(relative);
|
|
104
|
+
if (prior) {
|
|
105
|
+
try {
|
|
106
|
+
await assertRootDirectoryObservationGuard(root, prior);
|
|
107
|
+
return prior;
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
await recover(relative, error);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!attempts.has(relative) && attempts.size >= options.maxDirectories) {
|
|
114
|
+
throw new FsSafeError("too-large", "watch directory budget exceeded", { details: { operation: "scan" } });
|
|
115
|
+
}
|
|
116
|
+
let registrationError;
|
|
117
|
+
while ((attempts.get(relative) ?? 0) < 3) {
|
|
118
|
+
attempts.set(relative, (attempts.get(relative) ?? 0) + 1);
|
|
119
|
+
try {
|
|
120
|
+
const resolved = await resolvePathInRoot(root, relative ? "./" + relative : ".", { rejectSymlinks: true });
|
|
121
|
+
const guard = await createRootDirectoryObservationGuard(root, resolved.resolved);
|
|
122
|
+
const identity = { dev: guard.stat.dev, ino: guard.stat.ino };
|
|
123
|
+
result.directories.set(relative, identity);
|
|
124
|
+
await register(relative, identity, guard);
|
|
125
|
+
signal.throwIfAborted();
|
|
126
|
+
await assertRootDirectoryObservationGuard(root, guard);
|
|
127
|
+
guards.set(relative, guard);
|
|
128
|
+
return guard;
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
registrationError = error;
|
|
132
|
+
await recover(relative, error);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!relative)
|
|
136
|
+
throw new FsSafeError("helper-failed", "watch Root registration could not be established", {
|
|
137
|
+
cause: registrationError, details: { operation: "watch", code: "registration-failed" },
|
|
138
|
+
});
|
|
139
|
+
throw new FsSafeError("path-mismatch", "watch directory changed during registration");
|
|
140
|
+
};
|
|
141
|
+
const tree = async (relative, depth) => {
|
|
142
|
+
if ((walked.get(relative) ?? 0) >= depth)
|
|
143
|
+
return;
|
|
144
|
+
walked.set(relative, depth);
|
|
145
|
+
let guard;
|
|
146
|
+
let listing;
|
|
147
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
148
|
+
try {
|
|
149
|
+
guard = await directory(relative);
|
|
150
|
+
listing = await openRootDirectoryListing(root, guard.realPath, {
|
|
151
|
+
order: "filesystem", snapshot: false, signal, exactIdentity: true, onCleanupFailure, admitEntry: () => { examined(); return true; },
|
|
152
|
+
});
|
|
153
|
+
// The listing has its own guard. Both identities must agree before reading names.
|
|
154
|
+
await assertRootDirectoryObservationGuard(root, guard);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
await listing?.[Symbol.asyncDispose]();
|
|
159
|
+
listing = undefined;
|
|
160
|
+
await recover(relative, error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!listing)
|
|
164
|
+
return;
|
|
165
|
+
let failed = false;
|
|
166
|
+
let operationError;
|
|
167
|
+
try {
|
|
168
|
+
while (true) {
|
|
169
|
+
const next = await listing.next();
|
|
170
|
+
signal.throwIfAborted();
|
|
171
|
+
if (!next)
|
|
172
|
+
break;
|
|
173
|
+
if (next.kind === "limit")
|
|
174
|
+
throw new FsSafeError("too-large", "watch entry budget exceeded");
|
|
175
|
+
if (!next.identity)
|
|
176
|
+
throw new FsSafeError("path-mismatch", "watch listing lacks exact identity");
|
|
177
|
+
const entry = next.entry;
|
|
178
|
+
const name = relative ? path.join(relative, entry.name) : entry.name;
|
|
179
|
+
if (excluded(name, entry))
|
|
180
|
+
continue;
|
|
181
|
+
result.entries.set(name, fingerprint(entry, next.identity));
|
|
182
|
+
if (entry.isDirectory && !entry.isSymbolicLink && depth > 1)
|
|
183
|
+
await tree(name, depth - 1);
|
|
184
|
+
}
|
|
185
|
+
await listing.assertCurrent();
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
failed = true;
|
|
189
|
+
operationError = error;
|
|
190
|
+
await recover(relative, error);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
try {
|
|
194
|
+
await listing[Symbol.asyncDispose]();
|
|
195
|
+
}
|
|
196
|
+
catch (closeError) {
|
|
197
|
+
if (failed)
|
|
198
|
+
throw createSuppressedError(closeError, operationError, "watch scan and directory disposal both failed");
|
|
199
|
+
throw closeError;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
await assertRootDirectoryObservationGuard(root, guard);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
await recover(relative, error);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
await assertRootIdentityCurrent(root);
|
|
210
|
+
for (const scope of scopes) {
|
|
211
|
+
signal.throwIfAborted();
|
|
212
|
+
if (!scope.path) {
|
|
213
|
+
const guard = await directory("");
|
|
214
|
+
result.entries.set("", fingerprint({ name: "", ...pathStatFromStats(guard.stat) }, guard.stat));
|
|
215
|
+
if (scope.kind === "tree" && scope.depth > 0)
|
|
216
|
+
await tree("", scope.depth);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const segments = scope.path.split(path.sep);
|
|
220
|
+
let relative = "";
|
|
221
|
+
try {
|
|
222
|
+
for (let i = 0; i < segments.length; i++) {
|
|
223
|
+
const guard = await directory(relative);
|
|
224
|
+
examined();
|
|
225
|
+
// Filesystem lookup, not lowercase/prefix matching, owns case, Unicode and
|
|
226
|
+
// short-name aliases. It also preserves case-sensitive Windows directories.
|
|
227
|
+
const found = await lookupRootDirectoryEntry(root, guard, segments[i]);
|
|
228
|
+
signal.throwIfAborted();
|
|
229
|
+
if (!found)
|
|
230
|
+
break;
|
|
231
|
+
const name = relative ? path.join(relative, segments[i]) : segments[i];
|
|
232
|
+
if (excluded(name, found.entry))
|
|
233
|
+
break;
|
|
234
|
+
if (i === segments.length - 1) {
|
|
235
|
+
result.entries.set(name, fingerprint(found.entry, found.identity));
|
|
236
|
+
result.targets.set(name, found.identity);
|
|
237
|
+
if (scope.kind === "tree" && found.entry.isDirectory && !found.entry.isSymbolicLink && scope.depth > 0)
|
|
238
|
+
await tree(name, scope.depth);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
if (found.entry.isSymbolicLink) {
|
|
242
|
+
if (options.admitting)
|
|
243
|
+
throw new FsSafeError("symlink", "watch scope traverses a symbolic link; admit its target separately", { details: { operation: "scope" } });
|
|
244
|
+
invalidate(name);
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
if (!found.entry.isDirectory)
|
|
248
|
+
break;
|
|
249
|
+
relative = name;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (error instanceof FsSafeError && error.details?.operation === "scope")
|
|
254
|
+
throw error;
|
|
255
|
+
await recover(relative, error);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const [relative, guard] of guards) {
|
|
259
|
+
try {
|
|
260
|
+
await assertRootDirectoryObservationGuard(root, guard);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
await recover(relative, error);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
await assertRootIdentityCurrent(root);
|
|
267
|
+
signal.throwIfAborted();
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Literal Root-relative names. Trees include their entry; depth defaults to 32 (max 128). */
|
|
2
|
+
export type WatchScope = Readonly<{
|
|
3
|
+
path: string;
|
|
4
|
+
kind: "entry" | "tree";
|
|
5
|
+
depth?: number;
|
|
6
|
+
}>;
|
|
7
|
+
export type WatchEntry = Readonly<{
|
|
8
|
+
path: string;
|
|
9
|
+
kind: "file" | "directory" | "symlink" | "other";
|
|
10
|
+
}>;
|
|
11
|
+
export type WatchChange = Readonly<{
|
|
12
|
+
path: string;
|
|
13
|
+
type: "content" | "structural";
|
|
14
|
+
}>;
|
|
15
|
+
export type WatchInvalidation = Readonly<{
|
|
16
|
+
reason: "event" | "reconcile" | "overflow";
|
|
17
|
+
/** Bounded advisory detail. undefined => invalidate every configured scope. */
|
|
18
|
+
changes?: readonly WatchChange[];
|
|
19
|
+
}>;
|
|
20
|
+
export type WatchFailure = Readonly<{
|
|
21
|
+
operation: "watch" | "scan" | "callback" | "close";
|
|
22
|
+
code?: string;
|
|
23
|
+
error: unknown;
|
|
24
|
+
}>;
|
|
25
|
+
export type WatchHealth = Readonly<{
|
|
26
|
+
state: "starting" | "ready" | "reconciling" | "unavailable" | "closed";
|
|
27
|
+
mode: "events" | "poll";
|
|
28
|
+
/** Directories currently observed, for pressure warnings. */
|
|
29
|
+
directories: number;
|
|
30
|
+
failure?: WatchFailure;
|
|
31
|
+
}>;
|
|
32
|
+
export type WatchOptions = {
|
|
33
|
+
scopes: readonly WatchScope[];
|
|
34
|
+
/** Required. auto selects events when available, otherwise poll. */
|
|
35
|
+
mode: "auto" | "events" | "poll";
|
|
36
|
+
/** Keep the Node event loop alive while open. Defaults to true. */
|
|
37
|
+
persistent?: boolean;
|
|
38
|
+
/** Guarded reconciliation interval: events 30000, poll 1000; minimum 20 ms. */
|
|
39
|
+
intervalMs?: number;
|
|
40
|
+
exclude?: (entry: WatchEntry) => boolean;
|
|
41
|
+
maxDirectories?: number;
|
|
42
|
+
maxEntries?: number;
|
|
43
|
+
maxPendingPaths?: number;
|
|
44
|
+
onInvalidate: (invalidation: WatchInvalidation) => void;
|
|
45
|
+
onHealth?: (health: WatchHealth) => void;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
};
|
|
48
|
+
export type WatchSubscription = {
|
|
49
|
+
readonly ready: Promise<void>;
|
|
50
|
+
/** Immediately fences the old generation. Superseded calls reject AbortError. */
|
|
51
|
+
setScopes(scopes: readonly WatchScope[]): Promise<void>;
|
|
52
|
+
/** Completes a pass started after this call; concurrent pending requests coalesce. */
|
|
53
|
+
reconcile(): Promise<void>;
|
|
54
|
+
health(): WatchHealth;
|
|
55
|
+
/** Terminal, idempotent, joined; rejects only retirement failures. */
|
|
56
|
+
close(): Promise<void>;
|
|
57
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
58
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Root } from "./root.js";
|
|
2
|
+
import type { WatchOptions, WatchSubscription } from "./watch-types.js";
|
|
3
|
+
export type * from "./watch-types.js";
|
|
4
|
+
/** Advisory observation only. Hints never grant filesystem authority. */
|
|
5
|
+
export declare function watch(root: Root, input: WatchOptions): WatchSubscription;
|