@openclaw/fs-safe 0.20.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 +23 -0
- package/README.md +9 -1
- package/dist/advanced.d.ts +2 -0
- package/dist/advanced.js +1 -0
- package/dist/atomic.d.ts +1 -1
- package/dist/native-binding.d.ts +22 -0
- 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 +2 -0
- 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 -2
- package/dist/root-walk.d.ts +19 -12
- package/dist/root-walk.js +49 -18
- package/dist/temp-target.js +3 -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-ownership.d.ts +8 -0
- package/dist/temp-workspace-ownership.js +52 -0
- package/dist/test-hooks.d.ts +3 -0
- 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/docs/advanced.md +1 -0
- package/docs/atomic.md +61 -0
- package/docs/contributing.md +5 -0
- package/docs/durability.md +7 -0
- package/docs/index.md +1 -0
- package/docs/native-helper.md +9 -0
- package/docs/retained-file.md +113 -0
- package/docs/root.md +6 -1
- package/docs/temp.md +24 -4
- package/docs/testing.md +60 -0
- package/docs/types.md +6 -0
- package/docs/walk.md +22 -1
- package/docs/watch.md +184 -0
- package/package.json +12 -8
|
@@ -3,6 +3,7 @@ import { inspectDirectoryIdentitySync, observeDirectoryIdentitySync } from "./di
|
|
|
3
3
|
import { pinNodeDirectoryForMode, pinNodeDirectoryForModeSync } from "./directory-mode-node.js";
|
|
4
4
|
import { FsSafeError } from "./errors.js";
|
|
5
5
|
import { fileIdentityMismatchError, inspectFileIdentitySync } from "./strict-file-identity.js";
|
|
6
|
+
import { classifyTempWorkspaceOwner, warnUnmappedTempWorkspaceAncestor } from "./temp-workspace-ownership.js";
|
|
6
7
|
export const TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY = process.platform === "linux" || process.platform === "darwin";
|
|
7
8
|
export function projectTempWorkspaceNumericIdentity(identity) {
|
|
8
9
|
const dev = Number(identity.dev);
|
|
@@ -42,16 +43,16 @@ export function validateTempWorkspaceDirMode(mode) {
|
|
|
42
43
|
throw new FsSafeError("insecure-permissions", "temp workspace must not be group/world writable");
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
export function assertTrustedTempWorkspaceDirectory(stat, uid,
|
|
46
|
+
export function assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory = false) {
|
|
46
47
|
if (uid === undefined)
|
|
47
48
|
return;
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
throw new FsSafeError("not-owned", "temp workspace directory has an untrusted owner");
|
|
49
|
+
const ownership = classifyTempWorkspaceOwner(stat, uid);
|
|
50
|
+
if (ownership === "foreign" || (privateDirectory && ownership !== "user")) {
|
|
51
|
+
throw new FsSafeError("not-owned", "temp workspace directory has an untrusted owner; use a private temp root owned by the effective user under a trusted directory hierarchy");
|
|
52
52
|
}
|
|
53
|
-
//
|
|
54
|
-
//
|
|
53
|
+
// Unmapped host owners are unverifiable; trust the host directory hierarchy.
|
|
54
|
+
// Admit sticky ancestors so PrivateUsers keeps host /tmp and PrivateTmp usable.
|
|
55
|
+
// Leaf roots stay euid-owned/private; non-sticky writable ancestors are rejected.
|
|
55
56
|
const writable = typeof stat.mode === "bigint"
|
|
56
57
|
? (stat.mode & 18n) !== 0n
|
|
57
58
|
: Number.isSafeInteger(stat.mode) && stat.mode >= 0 && (stat.mode & 0o022) !== 0;
|
|
@@ -61,9 +62,13 @@ export function assertTrustedTempWorkspaceDirectory(stat, uid, child = false) {
|
|
|
61
62
|
if (typeof stat.mode !== "bigint" && (!Number.isSafeInteger(stat.mode) || stat.mode < 0)) {
|
|
62
63
|
throw new FsSafeError("insecure-permissions", "temp workspace directory permissions are invalid");
|
|
63
64
|
}
|
|
64
|
-
if (writable && (
|
|
65
|
-
throw new FsSafeError("insecure-permissions",
|
|
65
|
+
if (writable && (privateDirectory || !sticky)) {
|
|
66
|
+
throw new FsSafeError("insecure-permissions", privateDirectory
|
|
67
|
+
? "temp workspace root and child must not be group/world writable; use a private temp directory"
|
|
68
|
+
: "temp workspace ancestor is group/world writable without sticky protection");
|
|
66
69
|
}
|
|
70
|
+
if (ownership === "unmapped")
|
|
71
|
+
warnUnmappedTempWorkspaceAncestor();
|
|
67
72
|
}
|
|
68
73
|
const WINDOWS = process.platform === "win32";
|
|
69
74
|
export function assertTempWorkspaceChildState(stat, ownerUid) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type DirectoryOwner = {
|
|
2
|
+
uid: number | bigint;
|
|
3
|
+
gid: number | bigint;
|
|
4
|
+
};
|
|
5
|
+
export type TempWorkspaceOwnership = "user" | "root" | "unmapped" | "foreign";
|
|
6
|
+
export declare function classifyTempWorkspaceOwner(stat: DirectoryOwner, uid: number): TempWorkspaceOwnership;
|
|
7
|
+
export declare function warnUnmappedTempWorkspaceAncestor(): void;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
let warned = false;
|
|
3
|
+
function overflowId(kind) {
|
|
4
|
+
try {
|
|
5
|
+
const value = Number(fs.readFileSync(`/proc/sys/kernel/overflow${kind}`, "utf8").trim());
|
|
6
|
+
if (Number.isSafeInteger(value) && value >= 0 && value < 0xffffffff)
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// Linux defaults when the sysctl files are unavailable in the service mount.
|
|
11
|
+
}
|
|
12
|
+
return 65534;
|
|
13
|
+
}
|
|
14
|
+
function unmappedId(kind) {
|
|
15
|
+
const mappings = fs.readFileSync(`/proc/self/${kind}_map`, "utf8").trim()
|
|
16
|
+
.split("\n").map((line) => line.trim().split(/\s+/).map(Number));
|
|
17
|
+
if (!mappings.every((row) => row.length === 3 && row.every((id) => Number.isSafeInteger(id) && id >= 0 && id <= 0xffffffff) && row[2] > 0))
|
|
18
|
+
return null;
|
|
19
|
+
if (mappings.length === 1 && mappings[0][0] === 0 &&
|
|
20
|
+
mappings[0][1] === 0 && mappings[0][2] === 0xffffffff)
|
|
21
|
+
return null;
|
|
22
|
+
const id = overflowId(kind);
|
|
23
|
+
// Recheck every admission: an explicitly mapped overflow-number ID is real ownership.
|
|
24
|
+
return mappings.some(([start, , count]) => id >= start && id < start + count) ? null : id;
|
|
25
|
+
}
|
|
26
|
+
export function classifyTempWorkspaceOwner(stat, uid) {
|
|
27
|
+
if (stat.uid === uid || stat.uid === BigInt(uid))
|
|
28
|
+
return "user";
|
|
29
|
+
if (stat.uid === 0 || stat.uid === 0n)
|
|
30
|
+
return "root";
|
|
31
|
+
if (process.platform !== "linux")
|
|
32
|
+
return "foreign";
|
|
33
|
+
try {
|
|
34
|
+
const overflowUid = unmappedId("uid");
|
|
35
|
+
const overflowGid = unmappedId("gid");
|
|
36
|
+
return overflowUid !== null && overflowGid !== null &&
|
|
37
|
+
(stat.uid === overflowUid || stat.uid === BigInt(overflowUid)) &&
|
|
38
|
+
(stat.gid === overflowGid || stat.gid === BigInt(overflowGid))
|
|
39
|
+
? "unmapped"
|
|
40
|
+
: "foreign";
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Unavailable mapping evidence cannot authorize the namespace exception.
|
|
44
|
+
return "foreign";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function warnUnmappedTempWorkspaceAncestor() {
|
|
48
|
+
if (warned)
|
|
49
|
+
return;
|
|
50
|
+
warned = true;
|
|
51
|
+
process.emitWarning("Temp workspace ancestor ownership is unverifiable in this user namespace; trusting the host directory hierarchy while enforcing ancestor modes and a process-owned private root.", { code: "FS_SAFE_UNMAPPED_TEMP_ANCESTOR", type: "FsSafeWarning" });
|
|
52
|
+
}
|
package/dist/test-hooks.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { FileHandle } from "node:fs/promises";
|
|
2
2
|
import type { FileIdentityStat } from "./file-identity.js";
|
|
3
3
|
export type FsSafeTestHooks = {
|
|
4
|
+
beforeWatchRegistration?: (path: string) => void | Promise<void>;
|
|
5
|
+
afterWatchRegistration?: (path: string) => void | Promise<void>;
|
|
6
|
+
afterWatchBackendCreated?: (root: string, emit: (batch: import("./watch-native.js").NativeWatchBatch) => void, nativeEvent?: (path: string, flags: number) => void) => void;
|
|
4
7
|
afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
|
|
5
8
|
beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
|
|
6
9
|
afterOpen?: (filePath: string, handle: FileHandle) => Promise<void> | void;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type RootContext } from "./root-context.js";
|
|
2
|
+
import type { NativeWatchBatch } from "./watch-native.js";
|
|
3
|
+
import type { WatchSnapshot } from "./watch-scan.js";
|
|
4
|
+
import type { WatchChange, WatchScope } from "./watch-types.js";
|
|
5
|
+
/** Resolve native spelling aliases without treating case folding as identity. */
|
|
6
|
+
export declare function admittedNativeChanges(root: RootContext, scopes: readonly WatchScope[], before: WatchSnapshot | undefined, after: WatchSnapshot, batch: NativeWatchBatch, signal: AbortSignal, limit: number): Promise<WatchChange[] | undefined>;
|
|
@@ -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>;
|