@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,3 +1,4 @@
1
+ import { isUtf8 } from "node:buffer";
1
2
  import fsSync from "node:fs";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
@@ -11,8 +12,25 @@ import { errorCauseOptions, rootPathChangedError } from "./root-errors.js";
11
12
  import { admitPathInsideRoot } from "./root-boundary.js";
12
13
  import { createSuppressedError } from "./suppressed-error.js";
13
14
  import { realpathSync } from "./realpath.js";
15
+ import { inspectStatObservationSync } from "./stat-observation.js";
14
16
  import { getFsSafeTestHooks } from "./test-hooks.js";
15
17
  const METADATA_BATCH_SIZE = 32;
18
+ function directoryEntryName(bytes) {
19
+ if (!isUtf8(bytes)) {
20
+ throw new FsSafeError("invalid-path", "directory entry name is not valid UTF-8");
21
+ }
22
+ return bytes.toString("utf8");
23
+ }
24
+ async function readDirectoryEntryName(handle) {
25
+ const entry = await handle.read();
26
+ // Bun returns the raw Buffer directly; Node returns a Dirent whose name is a Buffer.
27
+ // Node's Dir types still declare only string names.
28
+ return entry === null ? undefined
29
+ : directoryEntryName(Buffer.isBuffer(entry) ? entry : entry.name);
30
+ }
31
+ function openDirectoryNames(directory) {
32
+ return fs.opendir(directory, { bufferSize: 1, encoding: "buffer" });
33
+ }
16
34
  export function pathStatFromStats(stat) {
17
35
  const mtimeMs = typeof stat.mtimeMs === "bigint"
18
36
  ? "mtimeNs" in stat && typeof stat.mtimeNs === "bigint"
@@ -173,14 +191,7 @@ function normalizeInitialDirectoryError(error) {
173
191
  export async function listDirectoryPath(root, directory, withFileTypes, receipt) {
174
192
  let guard;
175
193
  if (receipt) {
176
- if (receipt.kind !== "directory" || receipt.targetPath !== directory ||
177
- receipt.directoryGuard.dir !== directory ||
178
- receipt.target !== receipt.directoryGuard ||
179
- (!isNativeDirectoryObservationGuard(receipt.directoryGuard) &&
180
- !receipt.directoryGuard.stat.isDirectory())) {
181
- throw new FsSafeError("path-mismatch", "directory observation receipt does not match target");
182
- }
183
- guard = receipt.directoryGuard;
194
+ guard = directoryGuardFromReceipt(directory, receipt);
184
195
  }
185
196
  else {
186
197
  try {
@@ -192,13 +203,23 @@ export async function listDirectoryPath(root, directory, withFileTypes, receipt)
192
203
  }
193
204
  return await listGuardedDirectoryPath(root, guard, withFileTypes, receipt);
194
205
  }
206
+ function directoryGuardFromReceipt(directory, receipt) {
207
+ if (receipt.kind !== "directory" || receipt.targetPath !== directory ||
208
+ receipt.directoryGuard.dir !== directory ||
209
+ receipt.target !== receipt.directoryGuard ||
210
+ (!isNativeDirectoryObservationGuard(receipt.directoryGuard) &&
211
+ !receipt.directoryGuard.stat.isDirectory())) {
212
+ throw new FsSafeError("path-mismatch", "directory observation receipt does not match target");
213
+ }
214
+ return receipt.directoryGuard;
215
+ }
195
216
  async function listGuardedDirectoryPath(root, guard, withFileTypes, receipt) {
196
217
  let entries;
197
218
  try {
198
219
  const beforeObservation = getFsSafeTestHooks()?.beforeRootListObservation;
199
220
  if (beforeObservation)
200
221
  await beforeObservation(guard.realPath, withFileTypes);
201
- const names = (await fs.readdir(guard.realPath)).sort();
222
+ const names = (await fs.readdir(guard.realPath, { encoding: "buffer" })).map(directoryEntryName).sort();
202
223
  entries = withFileTypes
203
224
  ? names.map(name => ({
204
225
  name,
@@ -221,19 +242,30 @@ async function listGuardedDirectoryPath(root, guard, withFileTypes, receipt) {
221
242
  await assertRootDirectoryObservationGuard(root, guard);
222
243
  return entries;
223
244
  }
224
- export async function openRootDirectoryListing(root, directory, options) {
225
- const admitted = await createRootDirectoryObservationGuard(root, directory).catch((error) => {
226
- throw normalizeDirectoryError(error);
227
- });
228
- // Retain exact admission identities; metadata rechecks can use numeric Stats
229
- // only when every identity component is losslessly representable.
230
- const guard = extendDirectoryObservationGuard({
231
- stat: admitted.stat,
232
- identity: { dev: admitted.stat.dev, ino: admitted.stat.ino },
233
- }, admitted.dir, admitted.realPath);
245
+ export async function openRootDirectoryListing(root, directory, options, receipt) {
246
+ if (options.exactIdentity && options.order !== "filesystem")
247
+ throw new TypeError("exact entry identities require filesystem order");
248
+ let guard;
249
+ if (receipt) {
250
+ guard = directoryGuardFromReceipt(directory, receipt);
251
+ }
252
+ else {
253
+ const admitted = await createRootDirectoryObservationGuard(root, directory).catch((error) => {
254
+ throw normalizeDirectoryError(error);
255
+ });
256
+ // Retain exact admission identities; metadata rechecks can use numeric Stats
257
+ // only when every identity component is losslessly representable.
258
+ guard = extendDirectoryObservationGuard({
259
+ stat: admitted.stat,
260
+ identity: { dev: admitted.stat.dev, ino: admitted.stat.ino },
261
+ }, admitted.dir, admitted.realPath);
262
+ }
234
263
  const assertCurrent = async () => {
235
264
  options.signal?.throwIfAborted();
236
- await assertRootDirectoryObservationGuard(root, guard);
265
+ if (receipt)
266
+ assertRootPathObservationReceiptCurrent(root, receipt);
267
+ else
268
+ await assertRootDirectoryObservationGuard(root, guard);
237
269
  options.signal?.throwIfAborted();
238
270
  };
239
271
  let handle;
@@ -248,23 +280,29 @@ export async function openRootDirectoryListing(root, directory, options) {
248
280
  const close = async () => {
249
281
  const owned = handle;
250
282
  handle = undefined;
251
- await owned?.close();
283
+ try {
284
+ await owned?.close();
285
+ }
286
+ catch (error) {
287
+ options.onCleanupFailure?.(error);
288
+ throw error;
289
+ }
252
290
  };
253
291
  try {
254
292
  await assertCurrent();
255
293
  if (options.order === "filesystem") {
256
294
  // A one-entry buffer keeps the truncation lookahead independent of width.
257
- handle = await fs.opendir(guard.realPath, { bufferSize: 1 });
295
+ handle = await openDirectoryNames(guard.realPath);
258
296
  }
259
297
  else if (options.snapshot) {
260
- snapshot = await listGuardedDirectoryPath(root, guard, true);
298
+ snapshot = await listGuardedDirectoryPath(root, guard, true, receipt);
261
299
  }
262
300
  else if (options.maxNames !== undefined) {
263
301
  names = [];
264
- handle = await fs.opendir(guard.realPath, { bufferSize: 1 });
302
+ handle = await openDirectoryNames(guard.realPath);
265
303
  while (true) {
266
304
  await assertCurrent();
267
- const name = (await handle.read())?.name;
305
+ const name = await readDirectoryEntryName(handle);
268
306
  await assertCurrent();
269
307
  if (name === undefined)
270
308
  break;
@@ -276,7 +314,7 @@ export async function openRootDirectoryListing(root, directory, options) {
276
314
  await close();
277
315
  }
278
316
  else {
279
- names = await fs.readdir(guard.realPath);
317
+ names = (await fs.readdir(guard.realPath, { encoding: "buffer" })).map(directoryEntryName);
280
318
  }
281
319
  await assertCurrent();
282
320
  names?.sort();
@@ -361,17 +399,30 @@ export async function openRootDirectoryListing(root, directory, options) {
361
399
  return { kind: "limit", name: pendingLimit };
362
400
  return;
363
401
  }
364
- await assertCurrent();
365
- const name = (await handle.read())?.name;
366
- await assertCurrent();
367
- if (name === undefined)
368
- return;
369
- if (!options.admitEntry())
370
- return { kind: "limit", name };
371
- // The stream's post-read fence is also the pre-stat fence in this owned operation.
372
- const stat = fsSync.lstatSync(path.join(guard.realPath, name));
373
- await assertCurrent();
374
- return { kind: "entry", entry: { name, ...pathStatFromStats(stat) } };
402
+ for (;;) {
403
+ await assertCurrent();
404
+ const name = await readDirectoryEntryName(handle);
405
+ await assertCurrent();
406
+ if (name === undefined)
407
+ return;
408
+ if (!options.admitEntry())
409
+ return { kind: "limit", name };
410
+ // The stream's post-read fence is also the pre-stat fence in this owned operation.
411
+ const pathname = path.join(guard.realPath, name);
412
+ let observed;
413
+ try {
414
+ observed = options.exactIdentity ? inspectStatObservationSync(bigint => bigint
415
+ ? fsSync.lstatSync(pathname, { bigint: true }) : fsSync.lstatSync(pathname)) : { stat: fsSync.lstatSync(pathname) };
416
+ }
417
+ catch (error) {
418
+ await assertCurrent();
419
+ if (options.skipVanished && isNotFoundPathError(error))
420
+ continue;
421
+ throw error;
422
+ }
423
+ await assertCurrent();
424
+ return { kind: "entry", entry: { name, ...pathStatFromStats(observed.stat) }, identity: observed.identity };
425
+ }
375
426
  }
376
427
  catch (error) {
377
428
  throw normalizeDirectoryError(error);
@@ -0,0 +1,4 @@
1
+ import type { RootContext } from "./root-context.js";
2
+ /** Internal registration; never reconstruct authority from public pathname fields. */
3
+ export declare function registerRootHandleContext(handle: object, context: RootContext): void;
4
+ export declare function rootHandleContext(handle: object): RootContext;
@@ -0,0 +1,12 @@
1
+ import { FsSafeError } from "./errors.js";
2
+ const contexts = new WeakMap();
3
+ /** Internal registration; never reconstruct authority from public pathname fields. */
4
+ export function registerRootHandleContext(handle, context) {
5
+ contexts.set(handle, context);
6
+ }
7
+ export function rootHandleContext(handle) {
8
+ const context = contexts.get(handle);
9
+ if (!context)
10
+ throw new FsSafeError("invalid-path", "watch requires a genuine fs-safe Root");
11
+ return context;
12
+ }
@@ -5,7 +5,7 @@ import { type ReadResult } from "./read-opened-file.js";
5
5
  import { type RootEntriesOptions } from "./root-entries.js";
6
6
  import { type RootContext } from "./root-context.js";
7
7
  import type { DirEntry, PathStat } from "./types.js";
8
- import { type RootWalkEntry, type RootWalkOptions } from "./root-walk.js";
8
+ import { type RootWalkEntry, type RootWalkOptions, type RootWalkSymlinkPolicy } from "./root-walk.js";
9
9
  import { type RootAppendOptions, type RootCopyOptions, type RootCopySource, type RootCreateJsonOptions, type RootCreateOptions, type RootCreateStreamOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions } from "./root-options.js";
10
10
  export { DEFAULT_ROOT_MAX_BYTES } from "./root-options.js";
11
11
  export { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
@@ -54,7 +54,7 @@ export interface Root {
54
54
  }): Promise<DirEntry[]>;
55
55
  entries(relativePath: string, options?: RootEntriesOptions): AsyncIterableIterator<DirEntry>;
56
56
  move(fromRelative: string, toRelative: string, options?: RootMoveOptions): Promise<void>;
57
- walk(relativePath: string, options: RootWalkOptions): AsyncIterableIterator<RootWalkEntry>;
57
+ walk<Policy extends RootWalkSymlinkPolicy>(relativePath: string, options: RootWalkOptions<Policy>): AsyncIterableIterator<RootWalkEntry<Policy>>;
58
58
  }
59
59
  export declare class RootHandle implements Root {
60
60
  private readonly context;
@@ -97,7 +97,7 @@ export declare class RootHandle implements Root {
97
97
  }): Promise<DirEntry[]>;
98
98
  move(fromRelative: string, toRelative: string, options?: RootMoveOptions): Promise<void>;
99
99
  entries(relativePath: string, options?: RootEntriesOptions): AsyncIterableIterator<DirEntry>;
100
- walk(relativePath: string, options: RootWalkOptions): AsyncIterableIterator<RootWalkEntry>;
100
+ walk<Policy extends RootWalkSymlinkPolicy>(relativePath: string, options: RootWalkOptions<Policy>): AsyncIterableIterator<RootWalkEntry<Policy>>;
101
101
  }
102
102
  export declare function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
103
103
  export declare function rootFromDirectoryGuard(guard: {
package/dist/root-impl.js CHANGED
@@ -51,6 +51,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
53
  import { randomUUID } from "node:crypto";
54
+ import { registerRootHandleContext } from "./root-handle-context.js";
54
55
  import fsSync, { constants as fsConstants } from "node:fs";
55
56
  import fs from "node:fs/promises";
56
57
  import path from "node:path";
@@ -85,7 +86,7 @@ import { listDirectoryPath, openRootDirectoryListing } from "./root-directory-li
85
86
  import { statResolvedPathInRoot } from "./root-path-stat.js";
86
87
  import { entriesInRoot } from "./root-entries.js";
87
88
  import { assertMoveMutationAllowed } from "./root-move-preflight.js";
88
- import { assertRootIdentityCurrent, assertRootIdentityCurrentSync, assertValidRootDestinationPath, assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, rootRelativeReadPath, } from "./root-context.js";
89
+ import { assertRootIdentityCurrent, assertRootIdentityCurrentSync, assertValidRootDestinationPath, assertValidRootRelativePath, createRootObservationGuard, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, rootRelativeReadPath, } from "./root-context.js";
89
90
  import { errorCauseOptions, fileNotFoundError, hardlinkedPathNotAllowedError, isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, outsideWorkspaceError, } from "./root-errors.js";
90
91
  import { getFsSafeTestHooks } from "./test-hooks.js";
91
92
  import { stringifyJsonDocument } from "./json-stringify.js";
@@ -199,6 +200,7 @@ export class RootHandle {
199
200
  this.rootWithSep = context.rootWithSep;
200
201
  this.defaults = defaults;
201
202
  registerFileLockSyncRootAdapter(this, context, defaults);
203
+ registerRootHandleContext(this, context);
202
204
  }
203
205
  mutationOptions(options) {
204
206
  return {
@@ -409,9 +411,13 @@ export class RootHandle {
409
411
  assertValidRootRelativePath(relativePath);
410
412
  return walkRoot({
411
413
  rootReal: this.context.rootReal,
414
+ observeRoot: () => createRootObservationGuard(this.context),
412
415
  stat: relative => this.stat(relative),
413
- list: async (relative, listingOptions) => {
416
+ list: async (relative, listingOptions, receipt) => {
414
417
  validatePinnedRelativePath(relative);
418
+ if (receipt) {
419
+ return await openRootDirectoryListing(this.context, receipt.targetPath, listingOptions, receipt);
420
+ }
415
421
  const resolved = await resolvePinnedPathInRoot(this.context, { relativePath: relative, allowRoot: true });
416
422
  return await openRootDirectoryListing(this.context, resolved.resolved, listingOptions);
417
423
  },
@@ -4,7 +4,7 @@ import { assertAsyncDirectoryGuard, assertSyncDirectoryGuard } from "./directory
4
4
  import { FsSafeError } from "./errors.js";
5
5
  import { assertMutationNotDenied } from "./deny-mutations.js";
6
6
  import { openNativeParentAdmission, openNativeRootAdmission, } from "./native-parent-admission.js";
7
- import { getNativeBinding } from "./native.js";
7
+ import { requireNativeBinding } from "./native.js";
8
8
  import { isNotFoundPathError } from "./path.js";
9
9
  import { assertRootIdentityCurrent, assertRootIdentityCurrentSync } from "./root-context.js";
10
10
  import { resolveRootPathSync } from "./root-path.js";
@@ -78,8 +78,8 @@ export async function movePathNoReplaceNative(root, params, paths) {
78
78
  throw error;
79
79
  // Advisory fast rejection only. renameNoReplace owns the collision decision.
80
80
  }
81
- const binding = getNativeBinding();
82
- if (!binding || typeof binding.renameNoReplace !== "function") {
81
+ const binding = requireNativeBinding();
82
+ if (typeof binding.renameNoReplace !== "function") {
83
83
  throw new FsSafeError("helper-unavailable", "native no-replace move is unavailable");
84
84
  }
85
85
  const rootAdmission = await openNativeRootAdmission(binding, {
@@ -1,17 +1,21 @@
1
+ import type { DirectoryObservationGuard } from "./directory-guard.js";
2
+ import { type RootPathObservationReceipt } from "./root-path.js";
1
3
  import type { RootDirectoryListing, RootDirectoryListingOptions } from "./root-directory-list.js";
2
4
  import type { PathStat } from "./types.js";
3
- export type RootWalkSymlinkPolicy = "skip" | "follow-within-root";
5
+ export type RootWalkSymlinkPolicy = "skip" | "follow-within-root" | "include";
6
+ type LegacyRootWalkSymlinkPolicy = Exclude<RootWalkSymlinkPolicy, "include">;
4
7
  export type RootWalkLimitBehavior = "truncate" | "throw";
5
8
  export type RootWalkDirectoryErrorBehavior = "throw" | "skip-and-report";
6
9
  export type RootWalkEntryFilterResult = "include" | "skip" | "skip-subtree";
7
- export type RootWalkDataEntryKind = "file" | "directory" | "other";
8
- export type RootWalkEntryKind = RootWalkDataEntryKind | "directory-error" | "truncated";
9
- export type RootWalkDataEntry = {
10
+ export type RootWalkDataEntryKind<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = "file" | "directory" | "other" | ("include" extends Policy ? "symlink" : never);
11
+ export type RootWalkEntryKind<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = RootWalkDataEntryKind<Policy> | "directory-error" | "truncated";
12
+ type WalkEntryOfKind<Kind extends RootWalkDataEntryKind<RootWalkSymlinkPolicy>> = {
10
13
  relativePath: string;
11
- kind: RootWalkDataEntryKind;
14
+ kind: Kind;
12
15
  size: number;
13
16
  };
14
- export type RootWalkEntry = RootWalkDataEntry | {
17
+ export type RootWalkDataEntry<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = WalkEntryOfKind<RootWalkDataEntryKind> | ("include" extends Policy ? WalkEntryOfKind<"symlink"> : never);
18
+ export type RootWalkEntry<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = RootWalkDataEntry<Policy> | {
15
19
  relativePath: string;
16
20
  kind: "truncated";
17
21
  size: 0;
@@ -21,21 +25,24 @@ export type RootWalkEntry = RootWalkDataEntry | {
21
25
  size: 0;
22
26
  error: unknown;
23
27
  };
24
- export type RootWalkEntryFilter = (entry: RootWalkDataEntry) => RootWalkEntryFilterResult | Promise<RootWalkEntryFilterResult>;
25
- export type RootWalkOptions = {
28
+ export type RootWalkEntryFilter<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = (entry: RootWalkDataEntry<Policy>) => RootWalkEntryFilterResult | Promise<RootWalkEntryFilterResult>;
29
+ export type RootWalkOptions<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = {
26
30
  maxDepth?: number;
27
31
  maxEntries?: number;
28
32
  order?: "sorted" | "filesystem";
29
- symlinkPolicy: RootWalkSymlinkPolicy;
33
+ symlinkPolicy: Policy;
30
34
  signal?: AbortSignal;
31
35
  limitBehavior?: RootWalkLimitBehavior;
32
- entryFilter?: RootWalkEntryFilter;
36
+ entryFilter?: RootWalkEntryFilter<Policy>;
33
37
  onDirectoryError?: RootWalkDirectoryErrorBehavior;
34
38
  };
39
+ type AnyRootWalkOptions = RootWalkOptions | RootWalkOptions<"include"> | RootWalkOptions<RootWalkSymlinkPolicy>;
40
+ type AnyRootWalkEntry = RootWalkEntry<RootWalkSymlinkPolicy>;
35
41
  type RootWalkCapability = {
36
42
  rootReal: string;
43
+ observeRoot(): Promise<DirectoryObservationGuard | undefined>;
37
44
  stat(relativePath: string): Promise<PathStat>;
38
- list(relativePath: string, options: RootDirectoryListingOptions): Promise<RootDirectoryListing>;
45
+ list(relativePath: string, options: RootDirectoryListingOptions, receipt?: RootPathObservationReceipt): Promise<RootDirectoryListing>;
39
46
  };
40
- export declare function walkRoot(root: RootWalkCapability, relativePath: string, options: RootWalkOptions): AsyncGenerator<RootWalkEntry>;
47
+ export declare function walkRoot(root: RootWalkCapability, relativePath: string, options: AnyRootWalkOptions): AsyncGenerator<AnyRootWalkEntry>;
41
48
  export {};
package/dist/root-walk.js CHANGED
@@ -1,8 +1,14 @@
1
1
  import path from "node:path";
2
2
  import { FsSafeError } from "./errors.js";
3
3
  import { expandRelativePathWithHome } from "./root-context.js";
4
- import { resolveRootPath, ROOT_PATH_ALIAS_POLICIES } from "./root-path.js";
4
+ import { resolveRootPath, resolveRootPathWithObservation, ROOT_PATH_ALIAS_POLICIES, RootPathObservationError, } from "./root-path.js";
5
5
  import { createSuppressedError } from "./suppressed-error.js";
6
+ function filterEntry(options, entry) {
7
+ if (entry.kind === "symlink") {
8
+ return options.symlinkPolicy === "include" ? options.entryFilter?.(entry) ?? "include" : "skip";
9
+ }
10
+ return options.entryFilter?.(entry) ?? "include";
11
+ }
6
12
  function validateBudget(name, value) {
7
13
  if (value === undefined)
8
14
  return Number.POSITIVE_INFINITY;
@@ -24,7 +30,7 @@ function limitEntry(relativePath) {
24
30
  return { relativePath, kind: "truncated", size: 0 };
25
31
  }
26
32
  export async function* walkRoot(root, relativePath, options) {
27
- if (!["skip", "follow-within-root"].includes(options.symlinkPolicy)) {
33
+ if (!["skip", "follow-within-root", "include"].includes(options.symlinkPolicy)) {
28
34
  throw new TypeError(`invalid root walk symlink policy: ${String(options.symlinkPolicy)}`);
29
35
  }
30
36
  if (options.order !== undefined && !["sorted", "filesystem"].includes(options.order)) {
@@ -62,24 +68,50 @@ export async function* walkRoot(root, relativePath, options) {
62
68
  throw error;
63
69
  return { relativePath: directory, kind: "directory-error", size: 0, error };
64
70
  };
65
- async function* visit(directory, depth) {
71
+ async function* visit(directory, depth, admittedChildPath) {
66
72
  options.signal?.throwIfAborted();
67
73
  let listing;
74
+ let canonicalDirectory;
68
75
  try {
69
76
  const expandedDirectory = depth === 0 ? await expandRelativePathWithHome(directory) : directory;
70
- const skipChildSymlinks = depth > 0 && options.symlinkPolicy === "skip";
71
- const resolvedDirectory = await resolveRootPath({
72
- absolutePath: path.resolve(root.rootReal, expandedDirectory),
77
+ const noFollowChildSymlinks = depth > 0 && options.symlinkPolicy !== "follow-within-root";
78
+ const includeChild = depth > 0 && options.symlinkPolicy === "include";
79
+ const resolution = {
80
+ absolutePath: admittedChildPath ?? path.resolve(root.rootReal, expandedDirectory),
73
81
  rootPath: root.rootReal,
74
82
  rootCanonicalPath: root.rootReal,
75
83
  boundaryLabel: "root walk",
76
- policy: skipChildSymlinks ? ROOT_PATH_ALIAS_POLICIES.unlinkTarget : undefined,
77
- });
78
- if (skipChildSymlinks && resolvedDirectory.kind === "symlink")
79
- return;
84
+ policy: noFollowChildSymlinks ? ROOT_PATH_ALIAS_POLICIES.unlinkTarget : undefined,
85
+ };
86
+ let receipt;
87
+ let resolvedDirectory;
88
+ if (includeChild) {
89
+ const rootGuard = await root.observeRoot();
90
+ if (!rootGuard) {
91
+ throw new FsSafeError("helper-unavailable", "root walk requires an exact directory observation");
92
+ }
93
+ const observed = await resolveRootPathWithObservation({
94
+ ...resolution,
95
+ rootIdentity: rootGuard.identity,
96
+ }, { kind: "directory", rootGuard });
97
+ resolvedDirectory = observed.resolved;
98
+ receipt = observed.receipt;
99
+ }
100
+ else {
101
+ resolvedDirectory = await resolveRootPath(resolution);
102
+ }
103
+ if (noFollowChildSymlinks && resolvedDirectory.kind === "symlink") {
104
+ if (options.symlinkPolicy === "skip")
105
+ return;
106
+ throw new FsSafeError("path-mismatch", `root walk directory became a symlink: ${directory}`);
107
+ }
80
108
  if (!resolvedDirectory.exists || resolvedDirectory.kind !== "directory") {
81
109
  throw new FsSafeError("not-file", `root walk path is not a directory: ${directory || "."}`);
82
110
  }
111
+ if (includeChild && (!receipt || receipt.directoryGuard.realPath !== resolvedDirectory.canonicalPath)) {
112
+ throw new FsSafeError("path-mismatch", "root walk directory observation was not retained");
113
+ }
114
+ canonicalDirectory = resolvedDirectory.canonicalPath;
83
115
  if (visitedDirectories.has(resolvedDirectory.canonicalPath)) {
84
116
  return;
85
117
  }
@@ -97,10 +129,10 @@ export async function* walkRoot(root, relativePath, options) {
97
129
  signal: options.signal,
98
130
  snapshot: maxEntries === Number.POSITIVE_INFINITY,
99
131
  admitEntry,
100
- });
132
+ }, receipt);
101
133
  }
102
134
  catch (error) {
103
- yield onDirectoryError(directory, error);
135
+ yield onDirectoryError(directory, error instanceof RootPathObservationError ? error.error : error);
104
136
  return;
105
137
  }
106
138
  // A thrown undefined still needs to be retained if closing also fails.
@@ -131,10 +163,9 @@ export async function* walkRoot(root, relativePath, options) {
131
163
  const entry = next.entry;
132
164
  let kind = entryKind(entry);
133
165
  let size = entry.size;
134
- if (kind === "symlink") {
135
- if (options.symlinkPolicy === "skip") {
136
- continue;
137
- }
166
+ if (kind === "symlink" && options.symlinkPolicy === "skip")
167
+ continue;
168
+ if (kind === "symlink" && options.symlinkPolicy === "follow-within-root") {
138
169
  const resolved = await resolveRootPath({
139
170
  absolutePath: path.resolve(root.rootReal, child),
140
171
  rootPath: root.rootReal,
@@ -149,7 +180,7 @@ export async function* walkRoot(root, relativePath, options) {
149
180
  size = target.size;
150
181
  }
151
182
  const walkEntry = { relativePath: child, kind, size };
152
- let filterResult = options.entryFilter?.(walkEntry) ?? "include";
183
+ let filterResult = filterEntry(options, walkEntry);
153
184
  if (typeof filterResult !== "string") {
154
185
  filterResult = (await filterResult) ?? "include";
155
186
  options.signal?.throwIfAborted();
@@ -177,7 +208,7 @@ export async function* walkRoot(root, relativePath, options) {
177
208
  yield onLimit(child);
178
209
  return;
179
210
  }
180
- yield* visit(child, depth + 1);
211
+ yield* visit(child, depth + 1, options.symlinkPolicy === "include" ? path.join(canonicalDirectory, name) : undefined);
181
212
  if (truncated)
182
213
  return;
183
214
  }
@@ -290,9 +290,9 @@ export async function acquireSidecarLock(options, context) {
290
290
  }
291
291
  await handle.writeFile(raw, "utf8");
292
292
  }
293
- const snapshot = { raw, payload, stat: fsSync.fstatSync(handle.fd), ownershipToken };
293
+ const snapshot = { raw, payload, stat: fsSync.fstatSync(handle.fd, { bigint: true }), ownershipToken };
294
294
  createdSnapshot = snapshot;
295
- if (snapshot.stat.nlink === 0) {
295
+ if (snapshot.stat.nlink === 0n) {
296
296
  await handle.close();
297
297
  handle = null;
298
298
  await waitForRetry();
@@ -361,7 +361,7 @@ export async function acquireSidecarLock(options, context) {
361
361
  if (handle) {
362
362
  failedSnapshot ??= { payload: null };
363
363
  try {
364
- failedSnapshot.stat = fsSync.fstatSync(handle.fd);
364
+ failedSnapshot.stat = fsSync.fstatSync(handle.fd, { bigint: true });
365
365
  }
366
366
  catch {
367
367
  // Best-effort cleanup of a failed exclusive create.
@@ -1,4 +1,4 @@
1
- import { type Stats } from "node:fs";
1
+ import { type BigIntStats, type Stats } from "node:fs";
2
2
  import type { Root } from "./root-impl.js";
3
3
  export type SidecarLockStaleSnapshot = {
4
4
  lockPath: string;
@@ -9,7 +9,7 @@ export type SidecarLockStaleSnapshot = {
9
9
  export type SidecarLockSnapshot = {
10
10
  raw?: string;
11
11
  payload: unknown;
12
- stat?: Stats;
12
+ stat?: Stats | BigIntStats;
13
13
  ownershipToken?: string;
14
14
  };
15
15
  type SidecarLockRawSnapshot = Omit<SidecarLockSnapshot, "payload"> & {
@@ -90,7 +90,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
90
90
  const raw = (await readFileHandleBounded(opened.handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
91
91
  return {
92
92
  raw,
93
- stat: opened.stat,
93
+ stat: fsSync.fstatSync(opened.handle.fd, { bigint: true }),
94
94
  };
95
95
  }
96
96
  finally {
@@ -99,7 +99,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
99
99
  }
100
100
  let before;
101
101
  try {
102
- before = fsSync.lstatSync(lockPath);
102
+ before = fsSync.lstatSync(lockPath, { bigint: true });
103
103
  }
104
104
  catch (error) {
105
105
  before = missingSnapshotPath(error);
@@ -132,7 +132,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
132
132
  options.onOpenFailure?.(error);
133
133
  throw error;
134
134
  }
135
- const opened = fsSync.fstatSync(handle.fd);
135
+ const opened = fsSync.fstatSync(handle.fd, { bigint: true });
136
136
  if (!opened.isFile()) {
137
137
  if (options.rejectNonFile) {
138
138
  throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
@@ -144,7 +144,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
144
144
  const raw = (await readFileHandleBounded(handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
145
145
  let after;
146
146
  try {
147
- after = fsSync.lstatSync(lockPath);
147
+ after = fsSync.lstatSync(lockPath, { bigint: true });
148
148
  }
149
149
  catch (error) {
150
150
  after = missingSnapshotPath(error);
@@ -159,7 +159,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
159
159
  }
160
160
  function lstatSidecarLockSync(lockPath) {
161
161
  try {
162
- return fsSync.lstatSync(lockPath);
162
+ return fsSync.lstatSync(lockPath, { bigint: true });
163
163
  }
164
164
  catch (error) {
165
165
  return missingSnapshotPath(error);
@@ -189,7 +189,7 @@ export function readSidecarLockRawSnapshotSync(lockPath, options = {}) {
189
189
  options.onOpenFailure?.(error);
190
190
  throw error;
191
191
  }
192
- const opened = fsSync.fstatSync(fd);
192
+ const opened = fsSync.fstatSync(fd, { bigint: true });
193
193
  if (!opened.isFile()) {
194
194
  if (options.rejectNonFile) {
195
195
  throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
@@ -50,7 +50,7 @@ function resolveManagerState(key) {
50
50
  function snapshotMatchesSync(lockPath, observed) {
51
51
  let fd;
52
52
  try {
53
- const beforeStat = fsSync.lstatSync(lockPath);
53
+ const beforeStat = fsSync.lstatSync(lockPath, { bigint: true });
54
54
  if (!beforeStat.isFile()) {
55
55
  return false;
56
56
  }
@@ -60,17 +60,17 @@ function snapshotMatchesSync(lockPath, observed) {
60
60
  : 0) |
61
61
  (typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0);
62
62
  fd = fsSync.openSync(lockPath, openFlags);
63
- const openedStat = fsSync.fstatSync(fd);
63
+ const openedStat = fsSync.fstatSync(fd, { bigint: true });
64
64
  // Token-owned files can have different descriptor/path identities on VirtioFS.
65
65
  // Require a known descriptor identity without rejecting that supported drift.
66
66
  if (!openedStat.isFile() || !sameFileIdentityForCleanup(openedStat, openedStat)) {
67
67
  return false;
68
68
  }
69
- if (observed.raw !== undefined && openedStat.size !== Buffer.byteLength(observed.raw)) {
69
+ if (observed.raw !== undefined && openedStat.size !== BigInt(Buffer.byteLength(observed.raw))) {
70
70
  return false;
71
71
  }
72
72
  const raw = fsSync.readFileSync(fd, "utf8");
73
- const afterStat = fsSync.lstatSync(lockPath);
73
+ const afterStat = fsSync.lstatSync(lockPath, { bigint: true });
74
74
  if (!afterStat.isFile() || !sameFileIdentityForCleanup(beforeStat, afterStat)) {
75
75
  return false;
76
76
  }