@openclaw/fs-safe 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +24 -6
  3. package/dist/advanced.d.ts +4 -0
  4. package/dist/advanced.js +2 -0
  5. package/dist/archive-plan.d.ts +2 -7
  6. package/dist/archive-read.js +9 -18
  7. package/dist/archive-zip-entry.d.ts +11 -11
  8. package/dist/archive-zip-entry.js +3 -35
  9. package/dist/archive-zip-integrity.d.ts +2 -2
  10. package/dist/archive-zip-integrity.js +2 -12
  11. package/dist/archive-zip-loader.d.ts +7 -3
  12. package/dist/archive-zip-loader.js +10 -9
  13. package/dist/archive-zip-preflight.d.ts +2 -1
  14. package/dist/archive-zip-preflight.js +16 -7
  15. package/dist/archive.js +17 -16
  16. package/dist/atomic.d.ts +1 -1
  17. package/dist/directory-receipt.js +5 -7
  18. package/dist/effective-uid.js +1 -4
  19. package/dist/errors.d.ts +3 -1
  20. package/dist/errors.js +3 -2
  21. package/dist/file-lock-sync-root-held.js +1 -4
  22. package/dist/file-store.d.ts +4 -7
  23. package/dist/json-document-store.d.ts +4 -9
  24. package/dist/local-file-access.js +2 -5
  25. package/dist/move-path-cleanup.js +4 -4
  26. package/dist/native-binding.d.ts +28 -1
  27. package/dist/native-staged-symlink.d.ts +13 -0
  28. package/dist/native-staged-symlink.js +303 -0
  29. package/dist/owner-dacl-batch-worker.d.ts +1 -0
  30. package/dist/owner-dacl-batch-worker.js +54 -0
  31. package/dist/owner-dacl-batch.d.ts +5 -0
  32. package/dist/owner-dacl-batch.js +64 -0
  33. package/dist/owner-dacl.d.ts +2 -0
  34. package/dist/owner-dacl.js +3 -0
  35. package/dist/path.js +17 -1
  36. package/dist/permission-exec.js +3 -6
  37. package/dist/permissions-public.d.ts +1 -0
  38. package/dist/permissions-public.js +1 -0
  39. package/dist/pinned-mutation-admission.d.ts +0 -1
  40. package/dist/pinned-open.d.ts +0 -1
  41. package/dist/pinned-open.js +1 -2
  42. package/dist/publish-copy-stage.js +4 -0
  43. package/dist/read-opened-file.d.ts +2 -5
  44. package/dist/regular-file.js +3 -3
  45. package/dist/replace-file-buffer.d.ts +4 -0
  46. package/dist/replace-file-buffer.js +36 -0
  47. package/dist/replace-file-copy-fallback.d.ts +3 -2
  48. package/dist/replace-file-copy-fallback.js +66 -38
  49. package/dist/replace-file-descriptor.d.ts +4 -0
  50. package/dist/replace-file-descriptor.js +9 -1
  51. package/dist/replace-file-destination.d.ts +17 -0
  52. package/dist/replace-file-destination.js +61 -0
  53. package/dist/replace-file-mutation.d.ts +26 -0
  54. package/dist/replace-file-mutation.js +47 -0
  55. package/dist/replace-file-temp-owner.d.ts +2 -2
  56. package/dist/replace-file-temp-owner.js +16 -4
  57. package/dist/replace-file-types.d.ts +55 -0
  58. package/dist/replace-file-types.js +1 -0
  59. package/dist/replace-file.d.ts +3 -55
  60. package/dist/replace-file.js +29 -10
  61. package/dist/retained-file-types.d.ts +61 -0
  62. package/dist/retained-file-types.js +1 -0
  63. package/dist/retained-file.d.ts +3 -0
  64. package/dist/retained-file.js +121 -0
  65. package/dist/root-directory-entry.d.ts +9 -0
  66. package/dist/root-directory-entry.js +28 -0
  67. package/dist/root-directory-list.d.ts +7 -1
  68. package/dist/root-directory-list.js +48 -23
  69. package/dist/root-handle-context.d.ts +4 -0
  70. package/dist/root-handle-context.js +12 -0
  71. package/dist/root-impl.d.ts +3 -3
  72. package/dist/root-impl.js +8 -5
  73. package/dist/root-observed-path.d.ts +0 -1
  74. package/dist/root-observed-path.js +0 -3
  75. package/dist/root-path-observation.d.ts +4 -11
  76. package/dist/root-path.js +7 -10
  77. package/dist/root-walk.d.ts +19 -12
  78. package/dist/root-walk.js +49 -18
  79. package/dist/root-write-admission.js +0 -2
  80. package/dist/safe-path-segment.d.ts +1 -0
  81. package/dist/safe-path-segment.js +8 -2
  82. package/dist/secure-file.js +3 -2
  83. package/dist/sidecar-lock.js +5 -3
  84. package/dist/staged-symlink-types.d.ts +49 -0
  85. package/dist/staged-symlink-types.js +1 -0
  86. package/dist/symlink-parents.js +58 -7
  87. package/dist/temp-target.js +5 -2
  88. package/dist/temp-workspace-admission.js +22 -21
  89. package/dist/temp-workspace-child-admission.d.ts +1 -1
  90. package/dist/temp-workspace-child-admission.js +14 -9
  91. package/dist/temp-workspace-owner.js +4 -9
  92. package/dist/temp-workspace-ownership.d.ts +8 -0
  93. package/dist/temp-workspace-ownership.js +52 -0
  94. package/dist/test-hooks.d.ts +3 -0
  95. package/dist/text-atomic.d.ts +2 -1
  96. package/dist/text-atomic.js +2 -0
  97. package/dist/trash.js +27 -1
  98. package/dist/walk.d.ts +2 -5
  99. package/dist/watch-alias.d.ts +6 -0
  100. package/dist/watch-alias.js +80 -0
  101. package/dist/watch-hints.d.ts +8 -0
  102. package/dist/watch-hints.js +77 -0
  103. package/dist/watch-native.d.ts +32 -0
  104. package/dist/watch-native.js +56 -0
  105. package/dist/watch-scan.d.ts +24 -0
  106. package/dist/watch-scan.js +269 -0
  107. package/dist/watch-types.d.ts +58 -0
  108. package/dist/watch-types.js +1 -0
  109. package/dist/watch.d.ts +5 -0
  110. package/dist/watch.js +502 -0
  111. package/dist/windows-owner.d.ts +0 -1
  112. package/dist/windows-owner.js +0 -1
  113. package/dist/windows-security-bridge.cs +6 -4
  114. package/dist/windows-security-bridge.ps1 +78 -3
  115. package/dist/windows-security-command.d.ts +8 -0
  116. package/dist/windows-security-command.js +66 -12
  117. package/dist/windows-security-facts.d.ts +3 -0
  118. package/dist/windows-security-facts.js +4 -0
  119. package/docs/advanced.md +4 -2
  120. package/docs/archive.md +8 -0
  121. package/docs/atomic.md +72 -3
  122. package/docs/contributing.md +35 -0
  123. package/docs/durability.md +7 -0
  124. package/docs/index.md +1 -0
  125. package/docs/install.md +28 -0
  126. package/docs/native-helper.md +14 -4
  127. package/docs/native.md +45 -1
  128. package/docs/permissions.md +66 -0
  129. package/docs/public-api.md +7 -1
  130. package/docs/retained-file.md +113 -0
  131. package/docs/root.md +6 -1
  132. package/docs/security-model.md +4 -1
  133. package/docs/sidecar-lock.md +2 -0
  134. package/docs/staged-symlink.md +123 -0
  135. package/docs/store.md +3 -1
  136. package/docs/temp.md +24 -4
  137. package/docs/testing.md +88 -0
  138. package/docs/types.md +6 -0
  139. package/docs/walk.md +22 -1
  140. package/docs/watch.md +184 -0
  141. package/docs/writing.md +10 -0
  142. package/package.json +13 -9
@@ -2,6 +2,7 @@ import type { BigIntStats, Stats } from "node:fs";
2
2
  import { type AsyncDirectoryGuard } from "./directory-guard.js";
3
3
  import { type RootContext } from "./root-context.js";
4
4
  import type { RootPathDirectoryObservationGuard, RootPathObservationReceipt, RootPathParentObservationReceipt } from "./root-path.js";
5
+ import { type ExactStatIdentity } from "./stat-observation.js";
5
6
  import type { DirEntry, PathStat } from "./types.js";
6
7
  export declare function pathStatFromStats(stat: Stats | BigIntStats): PathStat;
7
8
  export type RootDirectoryObservationGuard = AsyncDirectoryGuard<BigIntStats>;
@@ -21,6 +22,7 @@ export type RootDirectoryListing = {
21
22
  next(): Promise<{
22
23
  kind: "entry";
23
24
  entry: DirEntry;
25
+ identity?: ExactStatIdentity;
24
26
  } | {
25
27
  kind: "limit";
26
28
  name: string;
@@ -33,6 +35,10 @@ export type RootDirectoryListingOptions = {
33
35
  snapshot: boolean;
34
36
  maxNames?: number;
35
37
  metadataBatchSize?: number;
38
+ /** Internal exact metadata lane, supported by streaming filesystem order. */
39
+ exactIdentity?: boolean;
40
+ /** Internal owner receives cleanup failures, including acquisition rollback. */
41
+ onCleanupFailure?: (error: unknown) => void;
36
42
  admitEntry(): boolean;
37
43
  };
38
- export declare function openRootDirectoryListing(root: RootContext, directory: string, options: RootDirectoryListingOptions): Promise<RootDirectoryListing>;
44
+ export declare function openRootDirectoryListing(root: RootContext, directory: string, options: RootDirectoryListingOptions, receipt?: RootPathObservationReceipt): Promise<RootDirectoryListing>;
@@ -11,6 +11,7 @@ import { errorCauseOptions, rootPathChangedError } from "./root-errors.js";
11
11
  import { admitPathInsideRoot } from "./root-boundary.js";
12
12
  import { createSuppressedError } from "./suppressed-error.js";
13
13
  import { realpathSync } from "./realpath.js";
14
+ import { inspectStatObservationSync } from "./stat-observation.js";
14
15
  import { getFsSafeTestHooks } from "./test-hooks.js";
15
16
  const METADATA_BATCH_SIZE = 32;
16
17
  export function pathStatFromStats(stat) {
@@ -173,14 +174,7 @@ function normalizeInitialDirectoryError(error) {
173
174
  export async function listDirectoryPath(root, directory, withFileTypes, receipt) {
174
175
  let guard;
175
176
  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;
177
+ guard = directoryGuardFromReceipt(directory, receipt);
184
178
  }
185
179
  else {
186
180
  try {
@@ -192,6 +186,16 @@ export async function listDirectoryPath(root, directory, withFileTypes, receipt)
192
186
  }
193
187
  return await listGuardedDirectoryPath(root, guard, withFileTypes, receipt);
194
188
  }
189
+ function directoryGuardFromReceipt(directory, receipt) {
190
+ if (receipt.kind !== "directory" || receipt.targetPath !== directory ||
191
+ receipt.directoryGuard.dir !== directory ||
192
+ receipt.target !== receipt.directoryGuard ||
193
+ (!isNativeDirectoryObservationGuard(receipt.directoryGuard) &&
194
+ !receipt.directoryGuard.stat.isDirectory())) {
195
+ throw new FsSafeError("path-mismatch", "directory observation receipt does not match target");
196
+ }
197
+ return receipt.directoryGuard;
198
+ }
195
199
  async function listGuardedDirectoryPath(root, guard, withFileTypes, receipt) {
196
200
  let entries;
197
201
  try {
@@ -221,19 +225,30 @@ async function listGuardedDirectoryPath(root, guard, withFileTypes, receipt) {
221
225
  await assertRootDirectoryObservationGuard(root, guard);
222
226
  return entries;
223
227
  }
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);
228
+ export async function openRootDirectoryListing(root, directory, options, receipt) {
229
+ if (options.exactIdentity && options.order !== "filesystem")
230
+ throw new TypeError("exact entry identities require filesystem order");
231
+ let guard;
232
+ if (receipt) {
233
+ guard = directoryGuardFromReceipt(directory, receipt);
234
+ }
235
+ else {
236
+ const admitted = await createRootDirectoryObservationGuard(root, directory).catch((error) => {
237
+ throw normalizeDirectoryError(error);
238
+ });
239
+ // Retain exact admission identities; metadata rechecks can use numeric Stats
240
+ // only when every identity component is losslessly representable.
241
+ guard = extendDirectoryObservationGuard({
242
+ stat: admitted.stat,
243
+ identity: { dev: admitted.stat.dev, ino: admitted.stat.ino },
244
+ }, admitted.dir, admitted.realPath);
245
+ }
234
246
  const assertCurrent = async () => {
235
247
  options.signal?.throwIfAborted();
236
- await assertRootDirectoryObservationGuard(root, guard);
248
+ if (receipt)
249
+ assertRootPathObservationReceiptCurrent(root, receipt);
250
+ else
251
+ await assertRootDirectoryObservationGuard(root, guard);
237
252
  options.signal?.throwIfAborted();
238
253
  };
239
254
  let handle;
@@ -248,7 +263,13 @@ export async function openRootDirectoryListing(root, directory, options) {
248
263
  const close = async () => {
249
264
  const owned = handle;
250
265
  handle = undefined;
251
- await owned?.close();
266
+ try {
267
+ await owned?.close();
268
+ }
269
+ catch (error) {
270
+ options.onCleanupFailure?.(error);
271
+ throw error;
272
+ }
252
273
  };
253
274
  try {
254
275
  await assertCurrent();
@@ -257,7 +278,7 @@ export async function openRootDirectoryListing(root, directory, options) {
257
278
  handle = await fs.opendir(guard.realPath, { bufferSize: 1 });
258
279
  }
259
280
  else if (options.snapshot) {
260
- snapshot = await listGuardedDirectoryPath(root, guard, true);
281
+ snapshot = await listGuardedDirectoryPath(root, guard, true, receipt);
261
282
  }
262
283
  else if (options.maxNames !== undefined) {
263
284
  names = [];
@@ -369,9 +390,13 @@ export async function openRootDirectoryListing(root, directory, options) {
369
390
  if (!options.admitEntry())
370
391
  return { kind: "limit", name };
371
392
  // 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));
393
+ const pathname = path.join(guard.realPath, name);
394
+ const observed = options.exactIdentity ? inspectStatObservationSync(bigint => bigint
395
+ ? fsSync.lstatSync(pathname, { bigint: true }) : fsSync.lstatSync(pathname)) : undefined;
396
+ const stat = observed?.stat ?? fsSync.lstatSync(pathname);
373
397
  await assertCurrent();
374
- return { kind: "entry", entry: { name, ...pathStatFromStats(stat) } };
398
+ const entry = { name, ...pathStatFromStats(stat) };
399
+ return observed ? { kind: "entry", entry, identity: observed.identity } : { kind: "entry", entry };
375
400
  }
376
401
  catch (error) {
377
402
  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
  },
@@ -798,7 +804,6 @@ async function mkdirPathInRoot(root, params) {
798
804
  const prepared = policy && resolved.relativePosix !== ""
799
805
  ? await preparePinnedWriteMutationAdmission({
800
806
  rootReal: resolved.rootReal,
801
- rootWithSep: ensureTrailingSep(resolved.rootReal),
802
807
  rootIdentity: root.rootIdentity,
803
808
  resolvedTargetPath: resolved.resolved,
804
809
  originalPath: params.relativePath,
@@ -1045,10 +1050,8 @@ async function resolvePinnedRootPathInRoot(root, params) {
1045
1050
  throw err;
1046
1051
  throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
1047
1052
  }
1048
- const rootWithSep = ensureTrailingSep(resolved.rootCanonicalPath);
1049
1053
  return {
1050
1054
  rootReal: resolved.rootCanonicalPath,
1051
- rootWithSep,
1052
1055
  canonicalPath: resolved.canonicalPath,
1053
1056
  };
1054
1057
  }
@@ -1,7 +1,6 @@
1
1
  import { type RootPathObservationKind, type RootPathObservationReceipt } from "./root-path.js";
2
2
  import { type RootContext } from "./root-context.js";
3
3
  export type PinnedObservedPath = {
4
- rootReal: string;
5
4
  resolved: string;
6
5
  receipt?: RootPathObservationReceipt;
7
6
  };
@@ -63,7 +63,6 @@ export async function resolvePinnedObservedPathInRoot(root, relativePath, kind)
63
63
  // A receipt is emitted only for the straight traversal whose lexical and
64
64
  // canonical cursors stayed identical and inside the checked boundary.
65
65
  return {
66
- rootReal: resolved.rootCanonicalPath,
67
66
  resolved: resolved.canonicalPath,
68
67
  receipt: observed.receipt,
69
68
  };
@@ -71,7 +70,6 @@ export async function resolvePinnedObservedPathInRoot(root, relativePath, kind)
71
70
  const relativeResolved = path.relative(resolved.rootCanonicalPath, resolved.canonicalPath);
72
71
  if (relativeResolved === "" || relativeResolved === ".") {
73
72
  return {
74
- rootReal: resolved.rootCanonicalPath,
75
73
  resolved: resolved.canonicalPath,
76
74
  };
77
75
  }
@@ -87,7 +85,6 @@ export async function resolvePinnedObservedPathInRoot(root, relativePath, kind)
87
85
  if (!admittedCanonicalPath)
88
86
  throw outsideWorkspaceError();
89
87
  return {
90
- rootReal: resolved.rootCanonicalPath,
91
88
  resolved: admittedCanonicalPath.path,
92
89
  };
93
90
  }
@@ -7,14 +7,11 @@ export type RootPathObservationKind = "stat" | "directory";
7
7
  export type RootPathDirectoryObservationGuard = DirectoryObservationGuard | NativeDirectoryObservationGuard;
8
8
  export type RootPathTargetObservation = StatObservationReceipt | NativeDirectoryObservationGuard;
9
9
  /** An exact receipt owned by one stat/list operation. Never cache it. */
10
- export type RootPathObservationReceipt = {
11
- kind: RootPathObservationKind;
12
- rootGuard: DirectoryObservationGuard;
10
+ export type RootPathObservationReceipt = Pick<RootPathObservationRequest & {
13
11
  directoryGuard: RootPathDirectoryObservationGuard;
14
- directoryObserver?: NativeDirectoryObservationBackend;
15
12
  targetPath: string;
16
13
  target: RootPathTargetObservation;
17
- };
14
+ }, "kind" | "rootGuard" | "directoryGuard" | "directoryObserver" | "targetPath" | "target">;
18
15
  /** A failed initial target lookup must not discard its already admitted parent. */
19
16
  export type RootPathParentObservationReceipt = Omit<RootPathObservationReceipt, "kind" | "target"> & {
20
17
  kind: "stat-parent";
@@ -24,16 +21,12 @@ export type RootPathObservationRequest = {
24
21
  rootGuard: DirectoryObservationGuard;
25
22
  directoryObserver?: NativeDirectoryObservationBackend;
26
23
  };
27
- export type RootPathTraversalObservation = {
24
+ export type RootPathTraversalObservation = Omit<Partial<RootPathObservationReceipt> & {
28
25
  enabled: boolean;
29
26
  request: RootPathObservationRequest;
30
27
  targetIndex: number;
31
28
  directoryIndex: number;
32
- directoryGuard?: RootPathDirectoryObservationGuard;
33
- directoryObserver?: NativeDirectoryObservationBackend;
34
- targetPath?: string;
35
- target?: RootPathTargetObservation;
36
- };
29
+ }, "kind" | "rootGuard">;
37
30
  export type RootPathObservedTraversalEntry = StatObservationReceipt | {
38
31
  stat: fs.Stats | BigIntStats;
39
32
  identity?: undefined;
package/dist/root-path.js CHANGED
@@ -214,8 +214,8 @@ function finalizeLexicalResolution(context, kind) {
214
214
  rootPath: context.rootPath,
215
215
  rootCanonicalPath: context.rootCanonicalPath,
216
216
  relativePath: relativeInsideRoot(context.rootCanonicalPath, context.state.canonicalCursor),
217
- exists: kind.exists,
218
- kind: kind.kind,
217
+ exists: kind !== "missing",
218
+ kind,
219
219
  };
220
220
  }
221
221
  function applyResolvedSymlinkHop(context, linkCanonical) {
@@ -374,12 +374,9 @@ function traverseRootPath(params, { mode = "native", observation: observationOut
374
374
  const completeObservation = observation?.enabled === true && observation.directoryGuard !== undefined &&
375
375
  observation.targetPath === state.canonicalCursor && observation.target !== undefined;
376
376
  const kind = completeObservation
377
- ? {
378
- exists: true,
379
- kind: isNativeDirectoryObservationGuard(observation.target)
380
- ? "directory"
381
- : toResolvedKind(observation.target.stat),
382
- }
377
+ ? isNativeDirectoryObservationGuard(observation.target)
378
+ ? "directory"
379
+ : toResolvedKind(observation.target.stat)
383
380
  : getPathKindSync(state.canonicalCursor, state.preserveFinalSymlink);
384
381
  if (completeObservation && observationOutput) {
385
382
  observationOutput.receipt = {
@@ -399,11 +396,11 @@ function getPathKindSync(absolutePath, preserveFinalSymlink) {
399
396
  const stat = preserveFinalSymlink
400
397
  ? fs.lstatSync(operationPath)
401
398
  : fs.statSync(operationPath);
402
- return { exists: true, kind: toResolvedKind(stat) };
399
+ return toResolvedKind(stat);
403
400
  }
404
401
  catch (error) {
405
402
  if (isNotFoundPathError(error)) {
406
- return { exists: false, kind: "missing" };
403
+ return "missing";
407
404
  }
408
405
  throw error;
409
406
  }
@@ -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
  }
@@ -201,7 +201,6 @@ export async function resolveGuardedWriteTargetInRoot(root, params) {
201
201
  const relativeParent = path.relative(resolvedPath.rootReal, path.dirname(resolvedPath.resolved));
202
202
  const prepared = await preparePinnedWriteMutationAdmission({
203
203
  rootReal: resolvedPath.rootReal,
204
- rootWithSep: resolvedPath.rootWithSep,
205
204
  rootIdentity: root.rootIdentity,
206
205
  resolvedTargetPath: resolvedPath.resolved,
207
206
  originalPath: params.relativePath,
@@ -288,7 +287,6 @@ export async function resolvePinnedWriteTargetInRoot(root, relativePath, request
288
287
  if (policy) {
289
288
  ({ relativeParentPath, mutationAdmission } = await preparePinnedWriteMutationAdmission({
290
289
  rootReal,
291
- rootWithSep,
292
290
  rootIdentity: root.rootIdentity,
293
291
  resolvedTargetPath: resolved,
294
292
  originalPath: relativePath,
@@ -7,5 +7,6 @@ export declare function assertNoDriveRelativePathSegments(value: string, label:
7
7
  export declare function trimHyphenEdges(value: string): string;
8
8
  export declare function isSafePathSegment(segment: string, options?: SafePathSegmentOptions): boolean;
9
9
  export declare function assertSafePathSegment(segment: string, options?: SafePathSegmentOptions): string;
10
+ export declare function normalizeSafePathSegment(value: string): string;
10
11
  export declare function sanitizeSafePathSegment(value: string): string | undefined;
11
12
  export declare function assertSafePathPrefix(prefix: string, options?: SafePathSegmentOptions): string;
@@ -1,3 +1,4 @@
1
+ import { isWindowsReservedDeviceName } from "./device-path.js";
1
2
  import { FsSafeError } from "./errors.js";
2
3
  const SAFE_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
3
4
  const SAFE_DOT_PREFIX_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
@@ -34,6 +35,8 @@ export function isSafePathSegment(segment, options = {}) {
34
35
  !segment.includes("/") &&
35
36
  !segment.includes("\\") &&
36
37
  !segment.includes("\0") &&
38
+ // Segments are portable identifiers: CON.json and CON.<pid>.tmp name devices on Windows.
39
+ !isWindowsReservedDeviceName(segment) &&
37
40
  (options.allowDotPrefix === true || !segment.startsWith(".")) &&
38
41
  (options.allowDotPrefix === true
39
42
  ? SAFE_DOT_PREFIX_PATH_SEGMENT_PATTERN.test(segment)
@@ -47,13 +50,16 @@ export function assertSafePathSegment(segment, options = {}) {
47
50
  }
48
51
  return segment;
49
52
  }
50
- export function sanitizeSafePathSegment(value) {
53
+ export function normalizeSafePathSegment(value) {
51
54
  const sanitized = value
52
55
  .trim()
53
56
  .replace(/[\\/]+/g, "-")
54
57
  .replace(/\0/g, "")
55
58
  .replace(/[^A-Za-z0-9._-]+/g, "-");
56
- const trimmed = trimHyphenEdges(sanitized);
59
+ return trimHyphenEdges(sanitized);
60
+ }
61
+ export function sanitizeSafePathSegment(value) {
62
+ const trimmed = normalizeSafePathSegment(value);
57
63
  return isSafePathSegment(trimmed, { allowDotPrefix: true }) ? trimmed : undefined;
58
64
  }
59
65
  export function assertSafePathPrefix(prefix, options = {}) {
@@ -202,7 +202,8 @@ function inspectOpenedPermissions(stat, platform) {
202
202
  groupReadable: isGroupReadable(bits),
203
203
  };
204
204
  }
205
- async function assertSecurePermissions(options, stat, realPath, identity, fd) {
205
+ async function assertSecurePermissions(options, opened, fd) {
206
+ const { pathStat: stat, realPath, identity } = opened;
206
207
  if (options.permissions?.allowInsecure) {
207
208
  return undefined;
208
209
  }
@@ -282,7 +283,7 @@ export async function readSecureFile(options) {
282
283
  const opened = await openSecureHandle(options, maxBytes);
283
284
  try {
284
285
  assertTrustedDirs(options, opened.realPath);
285
- const permissions = await assertSecurePermissions(options, opened.pathStat, opened.realPath, opened.identity, opened.handle.fd);
286
+ const permissions = await assertSecurePermissions(options, opened, opened.handle.fd);
286
287
  const buffer = await readHandleWithTimeout(opened.handle, options.io?.timeoutMs, maxBytes);
287
288
  const finalIdentity = inspectFileIdentitySync(() => fsSync.fstatSync(opened.handle.fd, { bigint: true }), opened.identity);
288
289
  if (!finalIdentity.isFile()) {