@openclaw/fs-safe 0.19.0 → 0.20.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 (87) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +15 -5
  3. package/dist/advanced.d.ts +2 -0
  4. package/dist/advanced.js +1 -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/directory-receipt.js +5 -7
  17. package/dist/effective-uid.js +1 -4
  18. package/dist/errors.d.ts +3 -1
  19. package/dist/errors.js +3 -2
  20. package/dist/file-lock-sync-root-held.js +1 -4
  21. package/dist/file-store.d.ts +4 -7
  22. package/dist/json-document-store.d.ts +4 -9
  23. package/dist/local-file-access.js +2 -5
  24. package/dist/move-path-cleanup.js +4 -4
  25. package/dist/native-binding.d.ts +6 -1
  26. package/dist/native-staged-symlink.d.ts +13 -0
  27. package/dist/native-staged-symlink.js +303 -0
  28. package/dist/owner-dacl-batch-worker.d.ts +1 -0
  29. package/dist/owner-dacl-batch-worker.js +54 -0
  30. package/dist/owner-dacl-batch.d.ts +5 -0
  31. package/dist/owner-dacl-batch.js +64 -0
  32. package/dist/owner-dacl.d.ts +2 -0
  33. package/dist/owner-dacl.js +3 -0
  34. package/dist/path.js +17 -1
  35. package/dist/permission-exec.js +3 -6
  36. package/dist/permissions-public.d.ts +1 -0
  37. package/dist/permissions-public.js +1 -0
  38. package/dist/pinned-mutation-admission.d.ts +0 -1
  39. package/dist/pinned-open.d.ts +0 -1
  40. package/dist/pinned-open.js +1 -2
  41. package/dist/publish-copy-stage.js +4 -0
  42. package/dist/read-opened-file.d.ts +2 -5
  43. package/dist/regular-file.js +3 -3
  44. package/dist/replace-file-copy-fallback.d.ts +1 -2
  45. package/dist/root-impl.js +0 -3
  46. package/dist/root-observed-path.d.ts +0 -1
  47. package/dist/root-observed-path.js +0 -3
  48. package/dist/root-path-observation.d.ts +4 -11
  49. package/dist/root-path.js +7 -10
  50. package/dist/root-write-admission.js +0 -2
  51. package/dist/safe-path-segment.d.ts +1 -0
  52. package/dist/safe-path-segment.js +8 -2
  53. package/dist/secure-file.js +3 -2
  54. package/dist/sidecar-lock.js +5 -3
  55. package/dist/staged-symlink-types.d.ts +49 -0
  56. package/dist/staged-symlink-types.js +1 -0
  57. package/dist/symlink-parents.js +58 -7
  58. package/dist/temp-target.js +4 -2
  59. package/dist/temp-workspace-owner.js +4 -9
  60. package/dist/text-atomic.d.ts +2 -1
  61. package/dist/text-atomic.js +2 -0
  62. package/dist/trash.js +27 -1
  63. package/dist/walk.d.ts +2 -5
  64. package/dist/windows-owner.d.ts +0 -1
  65. package/dist/windows-owner.js +0 -1
  66. package/dist/windows-security-bridge.cs +6 -4
  67. package/dist/windows-security-bridge.ps1 +78 -3
  68. package/dist/windows-security-command.d.ts +8 -0
  69. package/dist/windows-security-command.js +66 -12
  70. package/dist/windows-security-facts.d.ts +3 -0
  71. package/dist/windows-security-facts.js +4 -0
  72. package/docs/advanced.md +3 -2
  73. package/docs/archive.md +8 -0
  74. package/docs/atomic.md +11 -3
  75. package/docs/contributing.md +30 -0
  76. package/docs/install.md +28 -0
  77. package/docs/native-helper.md +5 -4
  78. package/docs/native.md +45 -1
  79. package/docs/permissions.md +66 -0
  80. package/docs/public-api.md +7 -1
  81. package/docs/security-model.md +4 -1
  82. package/docs/sidecar-lock.md +2 -0
  83. package/docs/staged-symlink.md +123 -0
  84. package/docs/store.md +3 -1
  85. package/docs/testing.md +28 -0
  86. package/docs/writing.md +10 -0
  87. package/package.json +9 -9
@@ -208,10 +208,10 @@ function inspectAppendPath(filePath) {
208
208
  }
209
209
  function prepareRegularAppend(filePath, rejectSymlinkParents) {
210
210
  if (rejectSymlinkParents === true) {
211
- const resolvedDir = resolvePathPreservingWindowsRoot(path.dirname(filePath));
211
+ const unresolvedDir = path.dirname(filePath);
212
212
  assertNoSymlinkParentsSync({
213
- rootDir: path.parse(resolvedDir).root,
214
- targetPath: resolvedDir,
213
+ rootDir: path.parse(resolvePathPreservingWindowsRoot(unresolvedDir)).root,
214
+ targetPath: unresolvedDir,
215
215
  allowMissing: false,
216
216
  allowRootChildSymlink: true,
217
217
  requireDirectories: true,
@@ -9,9 +9,8 @@ type AsyncFallbackFs = {
9
9
  lstat: typeof import("node:fs/promises").lstat;
10
10
  open: typeof import("node:fs/promises").open;
11
11
  rm: typeof import("node:fs/promises").rm;
12
- unlink: typeof import("node:fs/promises").unlink;
13
12
  };
14
- type SyncFallbackFs = Pick<typeof syncFs, "closeSync" | "fstatSync" | "fsyncSync" | "ftruncateSync" | "lstatSync" | "openSync" | "readSync" | "rmSync" | "unlinkSync" | "writeSync">;
13
+ type SyncFallbackFs = Pick<typeof syncFs, "closeSync" | "fstatSync" | "fsyncSync" | "ftruncateSync" | "lstatSync" | "openSync" | "readSync" | "rmSync" | "writeSync">;
15
14
  export declare function assertDestinationHardlinkPolicy(fsModule: AsyncFallbackFs, dest: string, policy?: ReplaceFileDestinationHardlinkPolicy): Promise<void>;
16
15
  export declare function assertDestinationHardlinkPolicySync(fsModule: SyncFallbackFs, dest: string, policy?: ReplaceFileDestinationHardlinkPolicy): void;
17
16
  export declare function copyFallbackReplace(params: {
package/dist/root-impl.js CHANGED
@@ -798,7 +798,6 @@ async function mkdirPathInRoot(root, params) {
798
798
  const prepared = policy && resolved.relativePosix !== ""
799
799
  ? await preparePinnedWriteMutationAdmission({
800
800
  rootReal: resolved.rootReal,
801
- rootWithSep: ensureTrailingSep(resolved.rootReal),
802
801
  rootIdentity: root.rootIdentity,
803
802
  resolvedTargetPath: resolved.resolved,
804
803
  originalPath: params.relativePath,
@@ -1045,10 +1044,8 @@ async function resolvePinnedRootPathInRoot(root, params) {
1045
1044
  throw err;
1046
1045
  throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
1047
1046
  }
1048
- const rootWithSep = ensureTrailingSep(resolved.rootCanonicalPath);
1049
1047
  return {
1050
1048
  rootReal: resolved.rootCanonicalPath,
1051
- rootWithSep,
1052
1049
  canonicalPath: resolved.canonicalPath,
1053
1050
  };
1054
1051
  }
@@ -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
  }
@@ -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()) {
@@ -1,6 +1,6 @@
1
1
  import fsSync from "node:fs";
2
2
  import { FsSafeError } from "./errors.js";
3
- import { sameFileIdentity } from "./file-identity.js";
3
+ import { sameFileIdentityForCleanup } from "./file-identity.js";
4
4
  import { removeSidecarLockIfUnchanged, sidecarLockSnapshotMatches, } from "./sidecar-lock-reclaim.js";
5
5
  import { acquireSidecarLock } from "./sidecar-lock-acquire.js";
6
6
  import { createHeldSidecarLockHandle, stopSidecarLockMonitoring } from "./sidecar-lock-handle.js";
@@ -61,7 +61,9 @@ function snapshotMatchesSync(lockPath, observed) {
61
61
  (typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0);
62
62
  fd = fsSync.openSync(lockPath, openFlags);
63
63
  const openedStat = fsSync.fstatSync(fd);
64
- if (!openedStat.isFile()) {
64
+ // Token-owned files can have different descriptor/path identities on VirtioFS.
65
+ // Require a known descriptor identity without rejecting that supported drift.
66
+ if (!openedStat.isFile() || !sameFileIdentityForCleanup(openedStat, openedStat)) {
65
67
  return false;
66
68
  }
67
69
  if (observed.raw !== undefined && openedStat.size !== Buffer.byteLength(observed.raw)) {
@@ -69,7 +71,7 @@ function snapshotMatchesSync(lockPath, observed) {
69
71
  }
70
72
  const raw = fsSync.readFileSync(fd, "utf8");
71
73
  const afterStat = fsSync.lstatSync(lockPath);
72
- if (!afterStat.isFile() || !sameFileIdentity(beforeStat, afterStat)) {
74
+ if (!afterStat.isFile() || !sameFileIdentityForCleanup(beforeStat, afterStat)) {
73
75
  return false;
74
76
  }
75
77
  return sidecarLockSnapshotMatches({ raw, payload: null, stat: afterStat }, observed);
@@ -0,0 +1,49 @@
1
+ import type { StagedFileReceipt } from "./staged-file-types.js";
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;
9
+ target: string;
10
+ }>;
11
+ export type StagedSymlinkReceipt = StagedFileReceipt & Readonly<{
12
+ target: string;
13
+ }>;
14
+ export type PublishedSymlinkReceipt = Readonly<{
15
+ status: "published";
16
+ staged: StagedSymlinkReceipt;
17
+ basename: string;
18
+ overwrite: false;
19
+ }>;
20
+ export type StagedSymlinkPublication = Readonly<{
21
+ status: "not-published";
22
+ }> | PublishedSymlinkReceipt | Readonly<{
23
+ status: "indeterminate";
24
+ basename: string;
25
+ overwrite: false;
26
+ }>;
27
+ export type StagedSymlinkRemoval = "removed" | "name-absent" | "preserved";
28
+ export type StagedSymlinkCleanupReceipt = Readonly<{
29
+ temporaryBasename: string;
30
+ publication: StagedSymlinkPublication;
31
+ status: StagedSymlinkRemoval | "failed" | "not-needed";
32
+ resources: "closed" | "close-failed";
33
+ }>;
34
+ export type StagedSymlinkFailureDetails = Readonly<{
35
+ phase: "prepare" | "publish" | "remove-published" | "cleanup";
36
+ publication: StagedSymlinkPublication;
37
+ cleanup?: StagedSymlinkCleanupReceipt;
38
+ }>;
39
+ export interface StagedSymlink extends AsyncDisposable {
40
+ readonly receipt: StagedSymlinkReceipt;
41
+ assertCurrent(): Promise<void>;
42
+ publish(basename: string): Promise<PublishedSymlinkReceipt>;
43
+ /** Check the published name against the still-retained original symlink. */
44
+ assertPublished(): Promise<void>;
45
+ /** Explicit recovery only. Never removes an observed foreign replacement. */
46
+ removePublished(): Promise<StagedSymlinkRemoval>;
47
+ /** Closes the owner; only an unattempted, still-owned stage is removed. */
48
+ cleanup(): Promise<StagedSymlinkCleanupReceipt>;
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -3,24 +3,46 @@ import path from "node:path";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { hasNodeErrorCode, isPathRelativeEscape } from "./path.js";
5
5
  import { assertNoWindowsPathAlias, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
6
+ function outsideRootError(params, root) {
7
+ return new Error(`${params.messagePrefix ?? "Path"} must stay under ${root}.`);
8
+ }
9
+ function pathSegments(value) {
10
+ return value.split(process.platform === "win32" ? /[/\\]+/ : /\/+/)
11
+ .filter((segment) => segment.length > 0 && segment !== ".");
12
+ }
13
+ function rawTargetSegments(root, targetPath) {
14
+ // Resolve only the drive/root or cwd, never the caller's dotdot segments.
15
+ const targetRoot = path.parse(targetPath).root;
16
+ const base = resolvePathPreservingWindowsRoot(targetRoot || ".");
17
+ const absoluteTarget = `${base}${path.sep}${targetPath.slice(targetRoot.length)}`;
18
+ const rootSegments = pathSegments(root);
19
+ const targetSegments = pathSegments(absoluteTarget);
20
+ const fold = (segment) => process.platform === "win32" ? segment.toLowerCase() : segment;
21
+ if (rootSegments.some((segment, index) => fold(segment) !== fold(targetSegments[index] ?? ""))) {
22
+ return undefined;
23
+ }
24
+ return targetSegments.slice(rootSegments.length);
25
+ }
6
26
  function resolvePathWalk(params) {
7
27
  const rawRootDir = params.rootDir;
8
28
  assertNoWindowsPathAlias(rawRootDir, "filesystem", "root dir uses a Windows filesystem namespace alias");
9
29
  const rawTargetPath = params.targetPath;
10
30
  assertNoWindowsPathAlias(rawTargetPath, "filesystem", "target path uses a Windows filesystem namespace alias");
11
31
  const root = resolvePathPreservingWindowsRoot(rawRootDir);
12
- const target = resolvePathPreservingWindowsRoot(rawTargetPath);
13
- const relative = path.relative(root, target);
32
+ const lexicalTarget = resolvePathPreservingWindowsRoot(rawTargetPath);
33
+ const relative = path.relative(root, lexicalTarget);
14
34
  if (isPathRelativeEscape(relative)) {
15
35
  if (params.allowOutsideRoot) {
16
36
  return null;
17
37
  }
18
- throw new Error(`${params.messagePrefix ?? "Path"} must stay under ${root}.`);
38
+ throw outsideRootError(params, root);
19
39
  }
20
- return {
21
- root,
22
- segments: relative && relative !== "." ? relative.split(path.sep).filter(Boolean) : [],
23
- };
40
+ const segments = rawTargetSegments(root, rawTargetPath);
41
+ // A spelling outside the root must not fall back to a normalized walk that
42
+ // could erase a symlink before the filesystem receives the original path.
43
+ if (!segments)
44
+ throw outsideRootError(params, root);
45
+ return { root, segments };
24
46
  }
25
47
  function formatUnsafePath(params, current) {
26
48
  return `${params.messagePrefix ?? "Path"} must not traverse symlinked directory: ${current}`;
@@ -28,18 +50,39 @@ function formatUnsafePath(params, current) {
28
50
  export async function assertNoSymlinkParents(params) {
29
51
  assertNoSymlinkParentsSync(params);
30
52
  }
53
+ function isFilesystemRoot(root) {
54
+ return root === path.parse(root).root;
55
+ }
31
56
  export function assertNoSymlinkParentsSync(params) {
32
57
  const walk = resolvePathWalk(params);
33
58
  if (!walk) {
34
59
  return;
35
60
  }
36
61
  let current = walk.root;
62
+ // `..` may only undo a real directory this walk already lstat'd.
63
+ const walked = [];
37
64
  for (const [index, segment] of walk.segments.entries()) {
65
+ if (segment === "..") {
66
+ const top = walked[walked.length - 1];
67
+ if (top?.kind === "dir") {
68
+ walked.pop();
69
+ current = walked[walked.length - 1]?.path ?? walk.root;
70
+ continue;
71
+ }
72
+ if (top?.kind === "symlink") {
73
+ throw new Error(formatUnsafePath(params, top.path));
74
+ }
75
+ if (!isFilesystemRoot(walk.root)) {
76
+ throw outsideRootError(params, walk.root);
77
+ }
78
+ continue;
79
+ }
38
80
  current = path.join(current, segment);
39
81
  try {
40
82
  const stat = fsSync.lstatSync(current);
41
83
  if (stat.isSymbolicLink()) {
42
84
  if (params.allowRootChildSymlink && path.dirname(current) === walk.root) {
85
+ walked.push({ path: current, kind: "symlink" });
43
86
  continue;
44
87
  }
45
88
  throw new Error(formatUnsafePath(params, current));
@@ -47,9 +90,17 @@ export function assertNoSymlinkParentsSync(params) {
47
90
  if ((params.requireDirectories || index < walk.segments.length - 1) && !stat.isDirectory()) {
48
91
  throw new FsSafeError("not-file", `${params.messagePrefix ?? "Path"} must traverse directories: ${current}`);
49
92
  }
93
+ if (stat.isDirectory()) {
94
+ walked.push({ path: current, kind: "dir" });
95
+ }
50
96
  }
51
97
  catch (err) {
52
98
  if (hasNodeErrorCode(err, "ENOENT") && params.allowMissing !== false) {
99
+ // Win32 can cancel a nonexistent component before filesystem lookup.
100
+ // Returning early would leave later, reachable symlinks unchecked.
101
+ if (process.platform === "win32" && walk.segments.slice(index + 1).includes("..")) {
102
+ throw new FsSafeError("invalid-path", `${params.messagePrefix ?? "Path"} must not cancel a missing directory: ${current}`);
103
+ }
53
104
  return;
54
105
  }
55
106
  throw err;
@@ -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, sanitizeSafePathSegment, trimHyphenEdges } from "./safe-path-segment.js";
7
+ import { assertSafePathSegment, normalizeSafePathSegment, sanitizeSafePathSegment, 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";
@@ -60,7 +60,9 @@ function sanitizeExtension(extension) {
60
60
  return token ? `.${token}` : "";
61
61
  }
62
62
  export function sanitizeTempFileName(fileName) {
63
- return suffixWindowsReservedDeviceName(sanitizeSafePathSegment(path.basename(fileName)) ?? "download.bin");
63
+ // Suffix reserved stems before admission so CON.txt stays CON_.txt.
64
+ const suffixed = suffixWindowsReservedDeviceName(normalizeSafePathSegment(path.basename(fileName)));
65
+ return sanitizeSafePathSegment(suffixed) ?? "download.bin";
64
66
  }
65
67
  export function buildRandomTempFilePath(params) {
66
68
  const rootDir = resolveTempRoot(params.rootDir);
@@ -14,13 +14,6 @@ function isNativeCleanupBinding(binding) {
14
14
  typeof binding.removeOwnedTreeSync === "function" &&
15
15
  typeof binding.ownedTreeRemovalAvailable === "function";
16
16
  }
17
- function nativeRemovalError(result) {
18
- if (!result.errorCode)
19
- return undefined;
20
- return Object.assign(new Error(result.errorMessage ?? "native owned-tree cleanup failed"), {
21
- code: result.errorCode,
22
- });
23
- }
24
17
  export class TempWorkspaceCleanupCapability {
25
18
  binding;
26
19
  parent;
@@ -309,8 +302,10 @@ export class TempWorkspaceCleanupOwner {
309
302
  this.#capability.assertCurrent();
310
303
  }
311
304
  #mapRemoval(result) {
312
- const error = nativeRemovalError(result);
313
- if (error) {
305
+ if (result.errorCode) {
306
+ const error = Object.assign(new Error(result.errorMessage ?? "native owned-tree cleanup failed"), {
307
+ code: result.errorCode,
308
+ });
314
309
  if (error.code === "path-mismatch")
315
310
  return "indeterminate";
316
311
  throw error;
@@ -1,4 +1,5 @@
1
- export type WriteTextAtomicOptions = {
1
+ import { type ReplaceFileAtomicOptions } from "./replace-file.js";
2
+ export type WriteTextAtomicOptions = Pick<ReplaceFileAtomicOptions, "beforeRename" | "tempPrefix"> & {
2
3
  mode?: number;
3
4
  dirMode?: number;
4
5
  trailingNewline?: boolean;
@@ -12,5 +12,7 @@ export async function writeTextAtomic(filePath, content, options) {
12
12
  copyFallbackOnPermissionError: true,
13
13
  syncTempFile: durable,
14
14
  syncParentDir: durable,
15
+ beforeRename: options?.beforeRename,
16
+ tempPrefix: options?.tempPrefix,
15
17
  });
16
18
  }
package/dist/trash.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { assertSyncDirectoryGuard, createSyncDirectoryGuard } from "./directory-guard.js";
4
5
  import { sameFileIdentity } from "./file-identity.js";
5
6
  import { guardedRenameSync, guardedRmSync } from "./guarded-mutation.js";
6
7
  import { realpathSync } from "./realpath.js";
@@ -72,6 +73,22 @@ function resolveTrashTargetPath(targetPath) {
72
73
  assertNoTrashPathAlias(resolvedPath, "target path");
73
74
  return { path: resolvedPath, resolved: true };
74
75
  }
76
+ function resolveTrashEntryParent(lexicalTarget, targetPath) {
77
+ const lexicalParent = path.dirname(lexicalTarget);
78
+ assertNoTrashPathAlias(lexicalParent, "target path");
79
+ let realParent;
80
+ try {
81
+ // The renamed name lives in this parent. rename follows intermediate
82
+ // symlinks, so a lexical parent inside an allowed root is not enough.
83
+ realParent = realpathSync.native(lexicalParent);
84
+ }
85
+ catch {
86
+ throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
87
+ }
88
+ const resolvedParent = path.resolve(realParent);
89
+ assertNoTrashPathAlias(resolvedParent, "target path");
90
+ return resolvedParent;
91
+ }
75
92
  function assertAllowedTrashTarget(targetPath, allowedRoots) {
76
93
  assertNoTrashPathAlias(targetPath, "target path");
77
94
  const lexicalTarget = path.resolve(targetPath);
@@ -79,11 +96,19 @@ function assertAllowedTrashTarget(targetPath, allowedRoots) {
79
96
  const stat = fs.lstatSync(lexicalTarget);
80
97
  const resolvedTarget = resolveTrashTargetPath(targetPath);
81
98
  const resolvedTargetPath = resolvedTarget.path;
82
- const isAllowed = resolveAllowedTrashRoots(allowedRoots).some((root) => resolvedTargetPath !== root && isSameOrChildPath(resolvedTargetPath, root));
99
+ const parent = createSyncDirectoryGuard(path.dirname(lexicalTarget));
100
+ // Admit the directory entry only when its parent really stays inside an
101
+ // allowed root. Do not admit it because the symlink target is inside.
102
+ const resolvedParent = resolveTrashEntryParent(lexicalTarget, targetPath);
103
+ const isAllowed = resolveAllowedTrashRoots(allowedRoots).some((root) => isSameOrChildPath(resolvedParent, root));
83
104
  if (!isAllowed) {
84
105
  throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
85
106
  }
107
+ // Sync and native realpath can use different Windows short-name spellings.
108
+ // Recheck the retained guard around native containment instead of comparing them.
109
+ assertSyncDirectoryGuard(parent);
86
110
  return {
111
+ parent,
87
112
  path: lexicalTarget,
88
113
  realPath: resolvedTargetPath,
89
114
  realPathResolved: resolvedTarget.resolved,
@@ -91,6 +116,7 @@ function assertAllowedTrashTarget(targetPath, allowedRoots) {
91
116
  };
92
117
  }
93
118
  function assertTrashTargetGuard(guard) {
119
+ assertSyncDirectoryGuard(guard.parent);
94
120
  const stat = fs.lstatSync(guard.path);
95
121
  if (!sameFileIdentity(stat, guard.stat)) {
96
122
  throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
package/dist/walk.d.ts CHANGED
@@ -20,12 +20,9 @@ export type AsyncWalkDirectoryOptions = Omit<WalkDirectoryOptions, "include" | "
20
20
  include?: (entry: WalkDirectoryEntry) => boolean | Promise<boolean>;
21
21
  descend?: (entry: WalkDirectoryEntry) => boolean | Promise<boolean>;
22
22
  };
23
- export type WalkDirectoryFailure = {
24
- path: string;
25
- relativePath: string;
26
- depth: number;
23
+ export type WalkDirectoryFailure = Pick<WalkDirectoryEntry & {
27
24
  error: unknown;
28
- };
25
+ }, "path" | "relativePath" | "depth" | "error">;
29
26
  export type WalkDirectoryResult = {
30
27
  entries: WalkDirectoryEntry[];
31
28
  scannedEntryCount: number;
@@ -9,7 +9,6 @@ export type WindowsOwnerSummary = Omit<PermissionFailureFields & {
9
9
  daclPresent?: boolean;
10
10
  aces?: WindowsOwnerAce[];
11
11
  aclError?: string;
12
- remote?: boolean;
13
12
  trusted?: boolean;
14
13
  }, never>;
15
14
  export type WindowsOwnerAce = {
@@ -87,7 +87,6 @@ export async function inspectWindowsOwner(params) {
87
87
  sid: ownerSid,
88
88
  currentUserSid,
89
89
  ...parseWindowsAclFacts(parsed),
90
- remote,
91
90
  trusted: !remote && (ownerSid === currentUserSid || TRUSTED_OWNER_SIDS.has(ownerSid)),
92
91
  };
93
92
  }
@@ -312,6 +312,10 @@ public static partial class FsSafeWindowsBridge {
312
312
  }
313
313
  }
314
314
  }
315
+ static object InspectPath(string path) {
316
+ // Raw reporting retains ACL facts when locality is unknown; admission remains strict.
317
+ using(var handle=Open(path,0x00020080,false)) return Security(handle,false);
318
+ }
315
319
  public static object Execute(string operation,string path) {
316
320
  try {
317
321
  object result;
@@ -323,10 +327,8 @@ public static partial class FsSafeWindowsBridge {
323
327
  Require(!handle.IsInvalid,"EBADF","inherited file handle is unavailable");
324
328
  result=Row("identity",Identity(handle),"security",Security(handle));
325
329
  }
326
- } else if(operation=="path") {
327
- // Raw reporting retains ACL facts when locality is unknown; admission remains strict.
328
- using(var handle=Open(path,0x00020080,false)) result=Security(handle,false);
329
- } else throw new Failure("EINVAL","unknown Windows security operation");
330
+ } else if(operation=="path") result=InspectPath(path);
331
+ else throw new Failure("EINVAL","unknown Windows security operation");
330
332
  return Row("ok",true,"result",result);
331
333
  } catch(Failure error) { return Row("ok",false,"code",error.Code,"message",error.Message); }
332
334
  catch(Exception) { return Row("ok",false,"code","EIO","message","Windows security descriptor processing failed"); }