@openclaw/fs-safe 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +9 -1
  3. package/dist/advanced.d.ts +2 -0
  4. package/dist/advanced.js +1 -0
  5. package/dist/archive-zip-directory.js +4 -0
  6. package/dist/archive-zip-loader.js +3 -1
  7. package/dist/archive-zip-manifest.js +3 -1
  8. package/dist/atomic.d.ts +1 -1
  9. package/dist/copy-publication.d.ts +1 -3
  10. package/dist/copy-publication.js +2 -6
  11. package/dist/deny-mutation-match.d.ts +2 -0
  12. package/dist/deny-mutation-match.js +71 -0
  13. package/dist/deny-mutations.js +4 -3
  14. package/dist/file-identity.js +10 -2
  15. package/dist/file-lock-sync-admission.js +5 -1
  16. package/dist/file-lock-sync-root-io.d.ts +2 -2
  17. package/dist/file-lock-sync-root-io.js +1 -1
  18. package/dist/file-lock-sync.js +2 -2
  19. package/dist/file-store-prune.js +4 -4
  20. package/dist/mutation-authority.js +5 -0
  21. package/dist/native-binding.d.ts +33 -0
  22. package/dist/native-pinned-write.js +8 -2
  23. package/dist/pinned-mutation-admission.js +4 -2
  24. package/dist/replace-file-buffer.d.ts +4 -0
  25. package/dist/replace-file-buffer.js +36 -0
  26. package/dist/replace-file-copy-fallback.d.ts +2 -0
  27. package/dist/replace-file-copy-fallback.js +66 -38
  28. package/dist/replace-file-descriptor.d.ts +4 -0
  29. package/dist/replace-file-descriptor.js +9 -1
  30. package/dist/replace-file-destination.d.ts +21 -0
  31. package/dist/replace-file-destination.js +123 -0
  32. package/dist/replace-file-mutation.d.ts +26 -0
  33. package/dist/replace-file-mutation.js +47 -0
  34. package/dist/replace-file-temp-owner.d.ts +2 -2
  35. package/dist/replace-file-temp-owner.js +16 -4
  36. package/dist/replace-file-types.d.ts +55 -0
  37. package/dist/replace-file-types.js +1 -0
  38. package/dist/replace-file.d.ts +3 -55
  39. package/dist/replace-file.js +31 -11
  40. package/dist/retained-file-types.d.ts +50 -0
  41. package/dist/retained-file-types.js +1 -0
  42. package/dist/retained-file.d.ts +3 -0
  43. package/dist/retained-file.js +121 -0
  44. package/dist/root-directory-entry.d.ts +9 -0
  45. package/dist/root-directory-entry.js +28 -0
  46. package/dist/root-directory-list.d.ts +9 -1
  47. package/dist/root-directory-list.js +88 -37
  48. package/dist/root-handle-context.d.ts +4 -0
  49. package/dist/root-handle-context.js +12 -0
  50. package/dist/root-impl.d.ts +3 -3
  51. package/dist/root-impl.js +8 -2
  52. package/dist/root-move-noreplace.js +3 -3
  53. package/dist/root-walk.d.ts +19 -12
  54. package/dist/root-walk.js +49 -18
  55. package/dist/sidecar-lock-acquire.js +3 -3
  56. package/dist/sidecar-lock-reclaim.d.ts +2 -2
  57. package/dist/sidecar-lock-reclaim.js +6 -6
  58. package/dist/sidecar-lock.js +4 -4
  59. package/dist/staged-symlink-types.d.ts +4 -14
  60. package/dist/temp-target.js +3 -2
  61. package/dist/temp-workspace-admission.js +22 -21
  62. package/dist/temp-workspace-child-admission.d.ts +1 -1
  63. package/dist/temp-workspace-child-admission.js +14 -9
  64. package/dist/temp-workspace-ownership.d.ts +8 -0
  65. package/dist/temp-workspace-ownership.js +52 -0
  66. package/dist/test-hooks.d.ts +4 -0
  67. package/dist/watch-alias.d.ts +6 -0
  68. package/dist/watch-alias.js +88 -0
  69. package/dist/watch-hints.d.ts +9 -0
  70. package/dist/watch-hints.js +93 -0
  71. package/dist/watch-native.d.ts +36 -0
  72. package/dist/watch-native.js +73 -0
  73. package/dist/watch-scan.d.ts +28 -0
  74. package/dist/watch-scan.js +300 -0
  75. package/dist/watch-stream.d.ts +8 -0
  76. package/dist/watch-stream.js +32 -0
  77. package/dist/watch-types.d.ts +60 -0
  78. package/dist/watch-types.js +1 -0
  79. package/dist/watch.d.ts +5 -0
  80. package/dist/watch.js +530 -0
  81. package/docs/advanced.md +1 -0
  82. package/docs/archive.md +6 -0
  83. package/docs/atomic.md +82 -0
  84. package/docs/contributing.md +74 -6
  85. package/docs/durability.md +7 -0
  86. package/docs/index.md +1 -0
  87. package/docs/install.md +2 -0
  88. package/docs/native-helper.md +9 -0
  89. package/docs/native.md +24 -6
  90. package/docs/public-api.md +23 -2
  91. package/docs/retained-file.md +115 -0
  92. package/docs/root.md +25 -8
  93. package/docs/sidecar-lock.md +1 -1
  94. package/docs/staged-symlink.md +2 -1
  95. package/docs/temp.md +24 -4
  96. package/docs/testing.md +186 -4
  97. package/docs/types.md +6 -0
  98. package/docs/walk.md +22 -1
  99. package/docs/watch.md +251 -0
  100. package/package.json +12 -8
@@ -1,20 +1,13 @@
1
- import type { StagedFileReceipt } from "./staged-file-types.js";
1
+ import type { PublishedFileReceipt, StagedFileCleanupReceipt, StagedFileReceipt } from "./staged-file-types.js";
2
2
  /** Caller-captured admission evidence, never a same-target ownership heuristic. */
3
- export type StagedSymlinkExpected = Readonly<{
4
- dev: bigint;
5
- ino: bigint;
6
- uid: number;
7
- gid: number;
8
- ctimeNs: bigint;
3
+ export type StagedSymlinkExpected = Readonly<Pick<StagedFileReceipt["identity"], "dev" | "ino" | "uid" | "gid" | "ctimeNs"> & {
9
4
  target: string;
10
5
  }>;
11
6
  export type StagedSymlinkReceipt = StagedFileReceipt & Readonly<{
12
7
  target: string;
13
8
  }>;
14
- export type PublishedSymlinkReceipt = Readonly<{
15
- status: "published";
9
+ export type PublishedSymlinkReceipt = Readonly<Pick<PublishedFileReceipt, "status" | "basename"> & {
16
10
  staged: StagedSymlinkReceipt;
17
- basename: string;
18
11
  overwrite: false;
19
12
  }>;
20
13
  export type StagedSymlinkPublication = Readonly<{
@@ -25,11 +18,8 @@ export type StagedSymlinkPublication = Readonly<{
25
18
  overwrite: false;
26
19
  }>;
27
20
  export type StagedSymlinkRemoval = "removed" | "name-absent" | "preserved";
28
- export type StagedSymlinkCleanupReceipt = Readonly<{
29
- temporaryBasename: string;
21
+ export type StagedSymlinkCleanupReceipt = Readonly<Pick<StagedFileCleanupReceipt, "temporaryBasename" | "status" | "resources"> & {
30
22
  publication: StagedSymlinkPublication;
31
- status: StagedSymlinkRemoval | "failed" | "not-needed";
32
- resources: "closed" | "close-failed";
33
23
  }>;
34
24
  export type StagedSymlinkFailureDetails = Readonly<{
35
25
  phase: "prepare" | "publish" | "remove-published" | "cleanup";
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { suffixWindowsReservedDeviceName } from "./filename.js";
6
6
  import { sameFileIdentityForCleanup } from "./file-identity.js";
7
- import { assertSafePathSegment, normalizeSafePathSegment, sanitizeSafePathSegment, trimHyphenEdges, } from "./safe-path-segment.js";
7
+ import { assertSafePathSegment, isSafePathSegment, normalizeSafePathSegment, trimHyphenEdges, } from "./safe-path-segment.js";
8
8
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
9
9
  import { hasNodeErrorCode } from "./path.js";
10
10
  import { registerTempPathForExit } from "./temp-cleanup.js";
@@ -62,7 +62,8 @@ function sanitizeExtension(extension) {
62
62
  export function sanitizeTempFileName(fileName) {
63
63
  // Suffix reserved stems before admission so CON.txt stays CON_.txt.
64
64
  const suffixed = suffixWindowsReservedDeviceName(normalizeSafePathSegment(path.basename(fileName)));
65
- return sanitizeSafePathSegment(suffixed) ?? "download.bin";
65
+ // The suffix only inserts "_", so this value is already normalized.
66
+ return isSafePathSegment(suffixed, { allowDotPrefix: true }) ? suffixed : "download.bin";
66
67
  }
67
68
  export function buildRandomTempFilePath(params) {
68
69
  const rootDir = resolveTempRoot(params.rootDir);
@@ -36,20 +36,21 @@ function copyExactDirectoryObservation(stat) {
36
36
  dev: stat.dev,
37
37
  ino: stat.ino,
38
38
  uid: stat.uid,
39
+ gid: stat.gid,
39
40
  mode: stat.mode,
40
41
  directory: stat.isDirectory(),
41
42
  symbolicLink: stat.isSymbolicLink(),
42
43
  });
43
44
  }
44
- function snapshotFromExactObservation(dir, uid, realPath, stat) {
45
+ function snapshotFromExactObservation(dir, uid, realPath, stat, privateDirectory = false) {
45
46
  if (stat.symbolicLink || !stat.directory) {
46
47
  throw new FsSafeError("not-file", "temp workspace root component must be a real directory");
47
48
  }
48
- assertTrustedTempWorkspaceDirectory(stat, uid);
49
+ assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory);
49
50
  const identity = Object.freeze({ dev: stat.dev, ino: stat.ino });
50
51
  // Retain copied immutable scalars, never a mutable Stats object supplied by
51
52
  // an observation hook. Every later permission check uses a fresh snapshot.
52
- return Object.freeze({ dir, identity, numericIdentity: safeNumericIdentity(stat), realPath });
53
+ return Object.freeze({ dir, identity, numericIdentity: safeNumericIdentity(stat), realPath, privateDirectory });
53
54
  }
54
55
  function canonicalTempWorkspacePath(dir) {
55
56
  assertNoWindowsPathAlias(dir, "filesystem");
@@ -57,10 +58,10 @@ function canonicalTempWorkspacePath(dir) {
57
58
  assertNoWindowsPathAlias(canonical, "filesystem");
58
59
  return canonical;
59
60
  }
60
- function snapshot(dir, uid, realPath = canonicalTempWorkspacePath(dir)) {
61
+ function snapshot(dir, uid, realPath = canonicalTempWorkspacePath(dir), privateDirectory = false) {
61
62
  const stat = inspectDirectoryIdentitySync(dir);
62
63
  const observation = copyExactDirectoryObservation(stat);
63
- return { entry: snapshotFromExactObservation(dir, uid, realPath, observation), stat };
64
+ return { entry: snapshotFromExactObservation(dir, uid, realPath, observation, privateDirectory), stat };
64
65
  }
65
66
  function hasCompleteExactIdentity(stat) {
66
67
  return typeof stat.dev === "bigint" && typeof stat.ino === "bigint" &&
@@ -105,16 +106,16 @@ function inspectSnapshotIdentity(entry) {
105
106
  }
106
107
  function assertSnapshot(entry, uid) {
107
108
  const stat = inspectSnapshotIdentity(entry);
108
- assertTrustedTempWorkspaceDirectory(stat, uid);
109
+ assertTrustedTempWorkspaceDirectory(stat, uid, entry.privateDirectory);
109
110
  assertCanonicalRoot(entry);
110
111
  }
111
- function snapshotMissingRootComponent(parent, dir, ownerUid) {
112
+ function snapshotMissingRootComponent(parent, dir, ownerUid, privateDirectory) {
112
113
  if (parent.dir !== parent.realPath) {
113
114
  assertSnapshot(parent, ownerUid);
114
- return snapshot(dir, ownerUid);
115
+ return snapshot(dir, ownerUid, undefined, privateDirectory);
115
116
  }
116
117
  const current = inspectSnapshotIdentity(parent);
117
- assertTrustedTempWorkspaceDirectory(current, ownerUid);
118
+ assertTrustedTempWorkspaceDirectory(current, ownerUid, parent.privateDirectory);
118
119
  let realPath;
119
120
  try {
120
121
  realPath = canonicalTempWorkspacePath(dir);
@@ -126,16 +127,16 @@ function snapshotMissingRootComponent(parent, dir, ownerUid) {
126
127
  }
127
128
  if (path.dirname(realPath) !== parent.realPath) {
128
129
  assertCanonicalRoot(parent);
129
- return snapshot(dir, ownerUid);
130
+ return snapshot(dir, ownerUid, undefined, privateDirectory);
130
131
  }
131
132
  // The child's canonical parent confirms the same parent name at this phase.
132
133
  // Its exact non-symlink/owner observation still precedes mode initialization.
133
- return snapshot(dir, ownerUid, realPath);
134
+ return snapshot(dir, ownerUid, realPath, privateDirectory);
134
135
  }
135
136
  function assertChain(chain, uid) {
136
137
  for (const entry of chain) {
137
138
  const current = inspectSnapshotIdentity(entry);
138
- assertTrustedTempWorkspaceDirectory(current, uid);
139
+ assertTrustedTempWorkspaceDirectory(current, uid, entry.privateDirectory);
139
140
  }
140
141
  const last = chain[chain.length - 1];
141
142
  assertCanonicalRoot(last);
@@ -158,7 +159,7 @@ function associateTempWorkspaceRoot(entry, ownerUid, descriptorFd) {
158
159
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
159
160
  throw new FsSafeError("not-file", "temp workspace cleanup parent must be a real directory");
160
161
  }
161
- assertTrustedTempWorkspaceDirectory(stat, ownerUid);
162
+ assertTrustedTempWorkspaceDirectory(stat, ownerUid, entry.privateDirectory);
162
163
  }
163
164
  function discoverMissingTempWorkspaceAncestor(ancestor, missing, missingError) {
164
165
  // The caller already classified the first ENOENT. Keep this handoff out of
@@ -219,14 +220,14 @@ function rootPlan(rootDir) {
219
220
  const existingCanonicalRoot = missing.length === 0 && observedAncestor === ancestor &&
220
221
  !initial.symbolicLink && initial.directory && hasCompleteExactIdentity(initial);
221
222
  if (existingCanonicalRoot) {
222
- const discovery = snapshotFromExactObservation(ancestor, ownerUid, ancestor, initial);
223
+ const discovery = snapshotFromExactObservation(ancestor, ownerUid, ancestor, initial, true);
223
224
  return {
224
225
  admission: canonicalRootAdmission(discovery, ownerUid),
225
226
  missing,
226
227
  ownerUid,
227
228
  };
228
229
  }
229
- const chain = canonicalAncestry(ancestor).map((dir) => snapshot(dir, ownerUid, dir).entry);
230
+ const chain = canonicalAncestry(ancestor).map((dir) => snapshot(dir, ownerUid, dir, missing.length === 0 && dir === ancestor).entry);
230
231
  // Missing-component creation needs a replay before its first mutation. When
231
232
  // the complete root already exists through an alias, retain the historical
232
233
  // guarded route because discovery and mutation use different path spellings.
@@ -286,10 +287,10 @@ function canonicalRootAdmission(discovery, ownerUid) {
286
287
  const ancestry = canonicalAncestry(discovery.dir);
287
288
  const candidate = ancestry.map((dir, index) => {
288
289
  if (!TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY || index !== ancestry.length - 1) {
289
- return snapshot(dir, ownerUid, dir).entry;
290
+ return snapshot(dir, ownerUid, dir, index === ancestry.length - 1 && discovery.privateDirectory).entry;
290
291
  }
291
292
  const current = inspectSnapshotIdentity(discovery);
292
- assertTrustedTempWorkspaceDirectory(current, ownerUid);
293
+ assertTrustedTempWorkspaceDirectory(current, ownerUid, discovery.privateDirectory);
293
294
  return discovery;
294
295
  });
295
296
  const current = candidate[candidate.length - 1];
@@ -332,7 +333,7 @@ export async function admitTempWorkspaceRoot(rootDir) {
332
333
  if (admission)
333
334
  return admission;
334
335
  const guardedChain = chain;
335
- for (const segment of missing) {
336
+ for (const [index, segment] of missing.entries()) {
336
337
  const parentEntry = guardedChain[guardedChain.length - 1];
337
338
  const parent = rootAdmission(guardedChain, ownerUid);
338
339
  const dir = path.join(parent.dir, segment);
@@ -347,7 +348,7 @@ export async function admitTempWorkspaceRoot(rootDir) {
347
348
  if (error.code !== "EEXIST")
348
349
  throw error;
349
350
  }
350
- const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid);
351
+ const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid, index === missing.length - 1);
351
352
  if (created) {
352
353
  const modeInitialization = admitTempWorkspaceChild(dir, observed.stat, parent, 0o700);
353
354
  if (modeInitialization)
@@ -362,7 +363,7 @@ export function admitTempWorkspaceRootSync(rootDir) {
362
363
  if (admission)
363
364
  return admission;
364
365
  const guardedChain = chain;
365
- for (const segment of missing) {
366
+ for (const [index, segment] of missing.entries()) {
366
367
  const parentEntry = guardedChain[guardedChain.length - 1];
367
368
  const parent = rootAdmission(guardedChain, ownerUid);
368
369
  const dir = path.join(parent.dir, segment);
@@ -377,7 +378,7 @@ export function admitTempWorkspaceRootSync(rootDir) {
377
378
  if (error.code !== "EEXIST")
378
379
  throw error;
379
380
  }
380
- const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid);
381
+ const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid, index === missing.length - 1);
381
382
  if (created)
382
383
  admitTempWorkspaceChildSync(dir, observed.stat, parent, 0o700);
383
384
  guardedChain.push(observed.entry);
@@ -14,7 +14,7 @@ export declare function projectTempWorkspaceNumericIdentity(identity: TempWorksp
14
14
  export declare function inspectTempWorkspaceDescriptorIdentitySync(fd: number, expected: TempWorkspaceIdentity, numeric: TempWorkspaceNumericIdentity | undefined): TempWorkspaceIdentityStat;
15
15
  export declare function inspectTempWorkspaceDirectoryIdentitySync(dir: string, expected: TempWorkspaceIdentity, numeric: TempWorkspaceNumericIdentity | undefined): TempWorkspaceIdentityStat;
16
16
  export declare function validateTempWorkspaceDirMode(mode: number): void;
17
- export declare function assertTrustedTempWorkspaceDirectory(stat: Pick<BigIntStats, "uid" | "mode"> | Pick<Stats, "uid" | "mode">, uid: number | undefined, child?: boolean): void;
17
+ export declare function assertTrustedTempWorkspaceDirectory(stat: Pick<BigIntStats, "uid" | "gid" | "mode"> | Pick<Stats, "uid" | "gid" | "mode">, uid: number | undefined, privateDirectory?: boolean): void;
18
18
  export declare function assertTempWorkspaceChildState(stat: BigIntStats | Stats, ownerUid: number | undefined): void;
19
19
  export declare function validateInitialTempWorkspaceChild(stat: BigIntStats, ownerUid: number | undefined, mode: number): boolean;
20
20
  export declare function childHasRequestedMode(stat: BigIntStats | Stats, mode: number): boolean;
@@ -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, child = false) {
46
+ export function assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory = false) {
46
47
  if (uid === undefined)
47
48
  return;
48
- const ownedByUser = typeof stat.uid === "bigint" ? stat.uid === BigInt(uid) : stat.uid === uid;
49
- const ownedByRoot = typeof stat.uid === "bigint" ? stat.uid === 0n : stat.uid === 0;
50
- if (!ownedByUser && (child || !ownedByRoot)) {
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
- // A root/current-user-owned sticky directory protects children owned by us,
54
- // including the usual shared system temp directory. Never chmod that parent.
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 && (child || !sticky)) {
65
- throw new FsSafeError("insecure-permissions", "temp workspace directory is group/world writable without sticky protection");
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
+ }
@@ -1,6 +1,10 @@
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
+ afterWatchBackendOverflow?: (root: string, phase: "received" | "reconciled") => void;
7
+ afterWatchBackendCreated?: (root: string, emit: (batch: import("./watch-native.js").NativeWatchBatch) => void, nativeEvent?: (path: string, flags: number) => void) => void;
4
8
  afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
5
9
  beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
6
10
  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,88 @@
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 { excludedWatchPath, 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, after))
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
+ const relative = parent ? path.join(parent, name) : name;
26
+ if (excludedWatchPath(before, relative) || excludedWatchPath(after, relative))
27
+ continue;
28
+ let guard;
29
+ let expected = after.directories.get(parent);
30
+ if (!expected) {
31
+ // Native recursion observes one Root handle and may report descendants of
32
+ // unselected/entry-only directories. Admit a parent alias by exact identity,
33
+ // not by lowercasing, and do not turn unselected descendants into events.
34
+ try {
35
+ const resolved = await resolvePathInRoot(root, parent ? "./" + parent : ".", { rejectSymlinks: true });
36
+ guard = await createRootDirectoryObservationGuard(root, resolved.resolved);
37
+ }
38
+ catch (error) {
39
+ await assertRootIdentityCurrent(root);
40
+ if (isNotFoundPathError(error) || (error instanceof FsSafeError && ["not-found", "path-alias", "outside-workspace", "symlink", "not-file"].includes(error.code)))
41
+ continue;
42
+ throw error;
43
+ }
44
+ const admitted = [...after.directories].find(([, identity]) => identity.dev === guard.stat.dev && identity.ino === guard.stat.ino);
45
+ if (!admitted)
46
+ continue;
47
+ [parent, expected] = admitted;
48
+ }
49
+ const candidate = parent ? path.join(parent, name) : name;
50
+ if (excludedWatchPath(before, candidate) || excludedWatchPath(after, candidate))
51
+ continue;
52
+ const selected = scopedChanges(scopes, { path: candidate,
53
+ type: hint.event === "change" && before?.entries.get(candidate)?.startsWith("file:") ? "content" : "structural" });
54
+ if (selected.length) {
55
+ for (const change of selected)
56
+ if (!add(change))
57
+ return undefined;
58
+ continue;
59
+ }
60
+ if (!guard) {
61
+ const resolved = await resolvePathInRoot(root, parent ? "./" + parent : ".", { rejectSymlinks: true });
62
+ guard = await createRootDirectoryObservationGuard(root, resolved.resolved);
63
+ }
64
+ if (guard.stat.dev !== expected.dev || guard.stat.ino !== expected.ino) {
65
+ throw new FsSafeError("path-mismatch", "watch hint parent changed during reconciliation");
66
+ }
67
+ const found = await lookupRootDirectoryEntry(root, guard, name);
68
+ signal.throwIfAborted();
69
+ // A missing unselected sibling is not evidence of lost selected detail.
70
+ // Previously observed selected deletions remain visible in the snapshot diff;
71
+ // an unseen, already-gone name has no identity proving a selected alias.
72
+ if (!found)
73
+ continue;
74
+ for (const [relative, identity] of candidates) {
75
+ if (!relative || (path.dirname(relative) === "." ? "" : path.dirname(relative)) !== parent)
76
+ continue;
77
+ if (identity.dev !== found.identity.dev || identity.ino !== found.identity.ino)
78
+ continue;
79
+ for (const change of scopedChanges(scopes, { path: relative, type: "structural" }))
80
+ if (!add(change))
81
+ return undefined;
82
+ }
83
+ await assertRootDirectoryObservationGuard(root, guard);
84
+ }
85
+ await assertRootIdentityCurrent(root);
86
+ signal.throwIfAborted();
87
+ return [...result.values()];
88
+ }
@@ -0,0 +1,9 @@
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 excludedWatchPath(snapshot: WatchSnapshot | undefined, name: string): boolean;
5
+ export declare function scopedChanges(scopes: readonly WatchScope[], change: WatchChange): WatchChange[];
6
+ export declare function nativeChanges(scopes: readonly WatchScope[], snapshot: WatchSnapshot | undefined, batch: NativeWatchBatch, limit?: number, after?: WatchSnapshot): WatchChange[] | undefined;
7
+ export declare function changedEntries(before: WatchSnapshot | undefined, after: WatchSnapshot, limit: number): WatchChange[] | undefined;
8
+ /** Backend names never establish authority to publish a pathname. */
9
+ 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,93 @@
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 excludedWatchPath(snapshot, name) {
9
+ const exclusions = snapshot?.excluded;
10
+ if (exclusions?.has(name))
11
+ return true;
12
+ while (name) {
13
+ const parent = path.dirname(name);
14
+ name = parent === "." ? "" : parent;
15
+ if (exclusions?.get(name) === "directory")
16
+ return true;
17
+ }
18
+ return false;
19
+ }
20
+ export function scopedChanges(scopes, change) {
21
+ const result = new Map();
22
+ for (const scope of scopes) {
23
+ if (scope.path === change.path || (scope.kind === "tree" && below(scope.path, change.path) && distance(scope.path, change.path) <= scope.depth)) {
24
+ result.set(change.path, change);
25
+ }
26
+ else if (below(change.path, scope.path)) {
27
+ // A changed ancestor invalidates the requested target, not authority outside it.
28
+ result.set(scope.path, { path: scope.path, type: "structural" });
29
+ }
30
+ }
31
+ return [...result.values()];
32
+ }
33
+ export function nativeChanges(scopes, snapshot, batch, limit = 256, after) {
34
+ if (batch.overflow)
35
+ return undefined;
36
+ const result = new Map();
37
+ for (const hint of batch.hints) {
38
+ const name = hint.name;
39
+ // Backend filenames are untrusted hints. Never resolve or perform I/O on them.
40
+ if (typeof name !== "string" || !name || name === "." || name === ".." || name.includes("\0") || name.includes("/") || (process.platform === "win32" && /[\\:]/.test(name)))
41
+ return undefined;
42
+ const relative = hint.directory ? path.join(hint.directory, name) : name;
43
+ if (excludedWatchPath(snapshot, relative) || excludedWatchPath(after, relative))
44
+ continue;
45
+ for (const change of scopedChanges(scopes, {
46
+ path: relative,
47
+ type: hint.event === "change" && snapshot?.entries.get(relative)?.startsWith("file:") ? "content" : "structural",
48
+ })) {
49
+ if (!result.has(change.path) && result.size >= limit)
50
+ return undefined;
51
+ const prior = result.get(change.path);
52
+ result.set(change.path, prior?.type === "structural" ? prior : change);
53
+ }
54
+ }
55
+ return [...result.values()];
56
+ }
57
+ export function changedEntries(before, after, limit) {
58
+ if (!before)
59
+ return undefined;
60
+ const changes = [];
61
+ for (const name of new Set([...before.entries.keys(), ...after.entries.keys()])) {
62
+ const left = before.entries.get(name);
63
+ const right = after.entries.get(name);
64
+ if (left === right)
65
+ continue;
66
+ if (changes.length >= limit)
67
+ return undefined;
68
+ const sameFile = left?.startsWith("file:") && right?.startsWith("file:") &&
69
+ left.split(":").slice(0, 3).join(":") === right.split(":").slice(0, 3).join(":");
70
+ changes.push(Object.freeze({ path: name, type: sameFile ? "content" : "structural" }));
71
+ }
72
+ return changes;
73
+ }
74
+ /** Backend names never establish authority to publish a pathname. */
75
+ export function guardedHintChanges(scopes, before, after, hints, observed, limit) {
76
+ if (!hints || !observed)
77
+ return undefined;
78
+ const result = new Map(observed.map(change => [change.path, change]));
79
+ for (const hint of hints) {
80
+ // Only publish an independently observed name (including a deletion from
81
+ // the previous guarded snapshot), or a target explicitly supplied by caller.
82
+ // A stale/misdirected inode watch may report outside names: erase its detail.
83
+ if (!before?.entries.has(hint.path) && !after.entries.has(hint.path) && !scopes.some(scope => scope.path === hint.path))
84
+ return undefined;
85
+ // Equal snapshots cannot exclude an intermediate change and restoration (ABA).
86
+ // Preserve admitted hints, including setup activity delivered after ready.
87
+ if (!result.has(hint.path) && result.size >= limit)
88
+ return undefined;
89
+ const prior = result.get(hint.path);
90
+ result.set(hint.path, Object.freeze(prior?.type === "structural" ? prior : hint));
91
+ }
92
+ return [...result.values()];
93
+ }
@@ -0,0 +1,36 @@
1
+ import { type NativeBinding } from "./native.js";
2
+ import type { RootContext } from "./root-context.js";
3
+ import type { DirectoryIdentity } from "./watch-scan.js";
4
+ import type { WatchStreamPaths } from "./watch-stream.js";
5
+ export type NativeWatchHint = {
6
+ directory: string;
7
+ name: string;
8
+ event: "rename" | "change";
9
+ };
10
+ export type NativeWatchBatch = {
11
+ hints: NativeWatchHint[];
12
+ overflow: boolean;
13
+ error?: string;
14
+ };
15
+ export type NativeWatchWireBatch = {
16
+ hints: {
17
+ directory: string;
18
+ name: string;
19
+ structural: boolean;
20
+ flags?: number;
21
+ }[];
22
+ overflow: boolean;
23
+ error?: string;
24
+ };
25
+ export declare function watchBinding(mode: "auto" | "events" | "poll"): NativeBinding | undefined;
26
+ export declare class NativeWatchBackend {
27
+ private binding;
28
+ private root;
29
+ private id;
30
+ private streamPaths;
31
+ constructor(binding: NativeBinding, root: RootContext, callback: (batch: NativeWatchBatch) => void, limit: number, persistent: boolean);
32
+ add(name: string, identity: DirectoryIdentity): void;
33
+ testEvent(path: string, flags: number): void;
34
+ configure(paths: WatchStreamPaths): boolean;
35
+ close(): void;
36
+ }