@openclaw/fs-safe 0.18.2 → 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 (124) hide show
  1. package/CHANGELOG.md +47 -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-durability.js +1 -1
  6. package/dist/archive-merge.js +1 -1
  7. package/dist/archive-plan.d.ts +2 -7
  8. package/dist/archive-read.js +9 -18
  9. package/dist/archive-staging.js +3 -1
  10. package/dist/archive-zip-entry.d.ts +11 -11
  11. package/dist/archive-zip-entry.js +3 -35
  12. package/dist/archive-zip-integrity.d.ts +2 -2
  13. package/dist/archive-zip-integrity.js +2 -12
  14. package/dist/archive-zip-loader.d.ts +7 -3
  15. package/dist/archive-zip-loader.js +10 -9
  16. package/dist/archive-zip-preflight.d.ts +2 -1
  17. package/dist/archive-zip-preflight.js +16 -7
  18. package/dist/archive.js +17 -16
  19. package/dist/directory-receipt.js +5 -7
  20. package/dist/effective-uid.js +1 -4
  21. package/dist/errors.d.ts +3 -1
  22. package/dist/errors.js +3 -2
  23. package/dist/file-lock-sync-root-held.d.ts +4 -11
  24. package/dist/file-lock-sync-root-held.js +1 -4
  25. package/dist/file-lock-sync-root-io.d.ts +1 -4
  26. package/dist/file-lock-sync-root.d.ts +2 -4
  27. package/dist/file-store-boundary.d.ts +3 -7
  28. package/dist/file-store-boundary.js +7 -10
  29. package/dist/file-store-prune.js +3 -2
  30. package/dist/file-store.d.ts +4 -7
  31. package/dist/file-store.js +19 -21
  32. package/dist/guest-native-python.js +23 -31
  33. package/dist/guest.js +21 -12
  34. package/dist/json-document-store.d.ts +4 -9
  35. package/dist/json-durable-queue.js +2 -6
  36. package/dist/local-file-access.js +2 -5
  37. package/dist/local-file-descriptor.d.ts +2 -5
  38. package/dist/local-roots.d.ts +2 -7
  39. package/dist/move-path-cleanup.js +4 -4
  40. package/dist/native-binding.d.ts +18 -14
  41. package/dist/native-staged-symlink.d.ts +13 -0
  42. package/dist/native-staged-symlink.js +303 -0
  43. package/dist/owner-dacl-batch-worker.d.ts +1 -0
  44. package/dist/owner-dacl-batch-worker.js +54 -0
  45. package/dist/owner-dacl-batch.d.ts +5 -0
  46. package/dist/owner-dacl-batch.js +64 -0
  47. package/dist/owner-dacl.d.ts +2 -0
  48. package/dist/owner-dacl.js +3 -0
  49. package/dist/path.js +17 -1
  50. package/dist/permission-exec.js +3 -6
  51. package/dist/permissions-public.d.ts +1 -0
  52. package/dist/permissions-public.js +1 -0
  53. package/dist/pinned-mutation-admission.d.ts +0 -1
  54. package/dist/pinned-mutation-shared-route.d.ts +2 -8
  55. package/dist/pinned-open.d.ts +0 -1
  56. package/dist/pinned-open.js +1 -2
  57. package/dist/publish-copy-stage.js +4 -0
  58. package/dist/read-opened-file.d.ts +2 -5
  59. package/dist/regular-file.js +3 -3
  60. package/dist/replace-file-copy-fallback.d.ts +1 -2
  61. package/dist/root-context.js +3 -2
  62. package/dist/root-impl.js +0 -3
  63. package/dist/root-move-noreplace.d.ts +2 -7
  64. package/dist/root-observed-path.d.ts +0 -1
  65. package/dist/root-observed-path.js +0 -3
  66. package/dist/root-path-observation.d.ts +4 -11
  67. package/dist/root-path.js +7 -10
  68. package/dist/root-paths.d.ts +2 -6
  69. package/dist/root-remove-identity.d.ts +1 -3
  70. package/dist/root-walk.js +8 -3
  71. package/dist/root-write-admission.js +1 -6
  72. package/dist/root-write-complete-parent.d.ts +2 -0
  73. package/dist/root-write-complete-parent.js +1 -1
  74. package/dist/safe-path-segment.d.ts +1 -0
  75. package/dist/safe-path-segment.js +8 -2
  76. package/dist/secret-file.d.ts +6 -2
  77. package/dist/secret-file.js +1 -0
  78. package/dist/secure-file-windows.js +1 -5
  79. package/dist/secure-file.js +3 -2
  80. package/dist/sidecar-lock-admission-parser.d.ts +1 -2
  81. package/dist/sidecar-lock-handle.d.ts +2 -8
  82. package/dist/sidecar-lock-policy.d.ts +2 -7
  83. package/dist/sidecar-lock-stale-admission.d.ts +1 -5
  84. package/dist/sidecar-lock.js +5 -3
  85. package/dist/staged-symlink-types.d.ts +49 -0
  86. package/dist/staged-symlink-types.js +1 -0
  87. package/dist/symlink-parents.js +58 -7
  88. package/dist/temp-target.js +4 -2
  89. package/dist/temp-workspace-owner.js +4 -9
  90. package/dist/test-hooks.d.ts +1 -1
  91. package/dist/text-atomic.d.ts +2 -1
  92. package/dist/text-atomic.js +2 -0
  93. package/dist/trash.js +27 -1
  94. package/dist/walk.d.ts +2 -5
  95. package/dist/windows-owner.d.ts +0 -1
  96. package/dist/windows-owner.js +0 -1
  97. package/dist/windows-security-bridge.cs +6 -4
  98. package/dist/windows-security-bridge.ps1 +78 -3
  99. package/dist/windows-security-command.d.ts +8 -0
  100. package/dist/windows-security-command.js +66 -15
  101. package/dist/windows-security-facts.d.ts +4 -0
  102. package/dist/windows-security-facts.js +6 -2
  103. package/docs/advanced.md +3 -2
  104. package/docs/archive.md +10 -0
  105. package/docs/atomic.md +11 -3
  106. package/docs/contributing.md +30 -0
  107. package/docs/copy.md +2 -0
  108. package/docs/file-store.md +5 -0
  109. package/docs/guest.md +7 -1
  110. package/docs/install.md +28 -0
  111. package/docs/native-helper.md +11 -4
  112. package/docs/native.md +45 -1
  113. package/docs/permissions.md +66 -0
  114. package/docs/public-api.md +7 -1
  115. package/docs/root.md +10 -1
  116. package/docs/secret-file.md +10 -0
  117. package/docs/security-model.md +4 -1
  118. package/docs/sidecar-lock.md +2 -0
  119. package/docs/staged-symlink.md +123 -0
  120. package/docs/store.md +3 -1
  121. package/docs/testing.md +28 -0
  122. package/docs/walk.md +12 -0
  123. package/docs/writing.md +21 -0
  124. package/package.json +9 -9
@@ -22,6 +22,10 @@ export function publishCopyStage(params) {
22
22
  // Caller authority checks can synchronously replace a parent or staged entry.
23
23
  if (params.assertBeforeMutation)
24
24
  assertCurrent();
25
+ // An observed collision needs no dispatch; errno after a link attempt remains ambiguous.
26
+ if (fs.lstatSync(targetPath, { throwIfNoEntry: false })) {
27
+ throw new FsSafeError("already-exists", "destination already exists");
28
+ }
25
29
  params.onPublicationAttempt?.();
26
30
  fs.linkSync(temporaryPath, targetPath);
27
31
  let observerRejected = false;
@@ -7,12 +7,9 @@ export type ReadResult = {
7
7
  realPath: string;
8
8
  stat: Stats;
9
9
  };
10
- type OpenedFile = {
10
+ type OpenedFile = Omit<ReadResult & {
11
11
  handle: FileHandle;
12
- containment: ContainmentGuarantee;
13
- realPath: string;
14
- stat: Stats;
15
- };
12
+ }, "buffer">;
16
13
  export declare function readOpenedFileSafely(params: {
17
14
  opened: OpenedFile;
18
15
  maxBytes?: number;
@@ -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: {
@@ -107,7 +107,7 @@ export function rootRelativeReadPath(root, filePath) {
107
107
  resolveCandidateRoot: base === root.rootDir && root.rootDir !== root.rootReal,
108
108
  });
109
109
  if (admitted)
110
- return admitted.relativePath;
110
+ return `.${path.sep}${admitted.relativePath}`;
111
111
  continue;
112
112
  }
113
113
  const prefix = ensureTrailingSep(base);
@@ -116,7 +116,8 @@ export function rootRelativeReadPath(root, filePath) {
116
116
  let start = prefix.length;
117
117
  while (raw[start] === path.sep)
118
118
  start += 1;
119
- return raw.slice(start);
119
+ // An admitted absolute tail stays literal, including a leading home marker.
120
+ return `.${path.sep}${raw.slice(start)}`;
120
121
  }
121
122
  }
122
123
  return raw;
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,13 +1,8 @@
1
1
  import { type BigIntStats, type Stats } from "node:fs";
2
- import { type DenyMutationPolicy } from "./deny-mutations.js";
2
+ import type { RootMoveOptions } from "./root-options.js";
3
3
  import { type RootContext } from "./root-context.js";
4
- import { type MutationSymlinkPolicy } from "./root-symlink-policy.js";
5
4
  export declare function admitMoveSourceStat<T extends Stats | BigIntStats>(stat: T, overwrite?: boolean): T;
6
- export declare function movePathNoReplaceNative(root: RootContext, params: {
7
- assertBeforeMutation?: () => void;
8
- denyMutations?: DenyMutationPolicy;
9
- mutationSymlinks?: MutationSymlinkPolicy;
10
- }, paths: {
5
+ export declare function movePathNoReplaceNative(root: RootContext, params: Pick<RootMoveOptions, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks">, paths: {
11
6
  sourcePath: string;
12
7
  sourceParentPath: string;
13
8
  targetPath: string;
@@ -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,3 +1,4 @@
1
+ import { type ResolvePathWithinRootParams } from "./path-scope-lexical.js";
1
2
  import { type DirectoryResult } from "./root-directory.js";
2
3
  export { resolvePathWithinRoot } from "./root-paths-lexical.js";
3
4
  export { ensureDirectoryWithinRoot } from "./root-directory.js";
@@ -44,12 +45,7 @@ export type PathScope = {
44
45
  mode?: number;
45
46
  }): Promise<DirectoryResult>;
46
47
  };
47
- export declare function resolveWritablePathWithinRoot(params: {
48
- rootDir: string;
49
- requestedPath: string;
50
- scopeLabel: string;
51
- defaultFileName?: string;
52
- }): Promise<{
48
+ export declare function resolveWritablePathWithinRoot(params: ResolvePathWithinRootParams): Promise<{
53
49
  ok: true;
54
50
  path: string;
55
51
  } | {
@@ -1,9 +1,7 @@
1
1
  import type { BigIntStats } from "node:fs";
2
2
  type ExactDirectoryIdentity = Readonly<Pick<BigIntStats, "dev" | "ino">>;
3
- export type RemovalDirectoryAssertion = Readonly<{
3
+ export type RemovalDirectoryAssertion = ExactDirectoryIdentity & Readonly<{
4
4
  path: string;
5
- dev: bigint;
6
- ino: bigint;
7
5
  numericDev?: number;
8
6
  numericIno?: number;
9
7
  platform: NodeJS.Platform;
package/dist/root-walk.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { FsSafeError } from "./errors.js";
3
+ import { expandRelativePathWithHome } from "./root-context.js";
3
4
  import { resolveRootPath, ROOT_PATH_ALIAS_POLICIES } from "./root-path.js";
4
5
  import { createSuppressedError } from "./suppressed-error.js";
5
6
  function validateBudget(name, value) {
@@ -65,9 +66,10 @@ export async function* walkRoot(root, relativePath, options) {
65
66
  options.signal?.throwIfAborted();
66
67
  let listing;
67
68
  try {
69
+ const expandedDirectory = depth === 0 ? await expandRelativePathWithHome(directory) : directory;
68
70
  const skipChildSymlinks = depth > 0 && options.symlinkPolicy === "skip";
69
71
  const resolvedDirectory = await resolveRootPath({
70
- absolutePath: path.resolve(root.rootReal, directory),
72
+ absolutePath: path.resolve(root.rootReal, expandedDirectory),
71
73
  rootPath: root.rootReal,
72
74
  rootCanonicalPath: root.rootReal,
73
75
  boundaryLabel: "root walk",
@@ -87,7 +89,10 @@ export async function* walkRoot(root, relativePath, options) {
87
89
  .relative(root.rootReal, resolvedDirectory.canonicalPath)
88
90
  .split(path.sep)
89
91
  .join(path.posix.sep);
90
- listing = await root.list(listingDirectory, {
92
+ if (depth === 0 && expandedDirectory !== directory)
93
+ directory = listingDirectory;
94
+ // Resolved filesystem names are literal, not caller home-directory shorthand.
95
+ listing = await root.list(`./${listingDirectory}`, {
91
96
  order: options.order ?? "sorted",
92
97
  signal: options.signal,
93
98
  snapshot: maxEntries === Number.POSITIVE_INFINITY,
@@ -139,7 +144,7 @@ export async function* walkRoot(root, relativePath, options) {
139
144
  if (!resolved.exists) {
140
145
  continue;
141
146
  }
142
- const target = await root.stat(path.relative(root.rootReal, resolved.canonicalPath));
147
+ const target = await root.stat(`./${path.relative(root.rootReal, resolved.canonicalPath)}`);
143
148
  kind = target.isDirectory ? "directory" : target.isFile ? "file" : "other";
144
149
  size = target.size;
145
150
  }
@@ -14,7 +14,7 @@ import { mutationSymlinkResolution, } from "./root-symlink-policy.js";
14
14
  import { inspectFileIdentitySync } from "./strict-file-identity.js";
15
15
  import { realpathSync } from "./realpath.js";
16
16
  import { getFsSafeTestHooks } from "./test-hooks.js";
17
- import { assertPreparedRootWriteParentCurrent, } from "./root-write-complete-parent.js";
17
+ import { assertPreparedRootWriteParentCurrent, writeSelectionChanged, } from "./root-write-complete-parent.js";
18
18
  export function createRootWriteSelectionForFd(selection, fd) {
19
19
  const stat = inspectFileIdentitySync(() => fsSync.fstatSync(fd, { bigint: true }));
20
20
  return Object.freeze({
@@ -22,9 +22,6 @@ export function createRootWriteSelectionForFd(selection, fd) {
22
22
  identity: Object.freeze({ dev: stat.dev, ino: stat.ino }),
23
23
  });
24
24
  }
25
- function writeSelectionChanged(cause) {
26
- return new FsSafeError("path-mismatch", "write target changed during operation", errorCauseOptions(cause));
27
- }
28
25
  function inspectRegularSelectionPath(pathname, expected, follow) {
29
26
  let observed;
30
27
  try {
@@ -204,7 +201,6 @@ export async function resolveGuardedWriteTargetInRoot(root, params) {
204
201
  const relativeParent = path.relative(resolvedPath.rootReal, path.dirname(resolvedPath.resolved));
205
202
  const prepared = await preparePinnedWriteMutationAdmission({
206
203
  rootReal: resolvedPath.rootReal,
207
- rootWithSep: resolvedPath.rootWithSep,
208
204
  rootIdentity: root.rootIdentity,
209
205
  resolvedTargetPath: resolvedPath.resolved,
210
206
  originalPath: params.relativePath,
@@ -291,7 +287,6 @@ export async function resolvePinnedWriteTargetInRoot(root, relativePath, request
291
287
  if (policy) {
292
288
  ({ relativeParentPath, mutationAdmission } = await preparePinnedWriteMutationAdmission({
293
289
  rootReal,
294
- rootWithSep,
295
290
  rootIdentity: root.rootIdentity,
296
291
  resolvedTargetPath: resolved,
297
292
  originalPath: relativePath,
@@ -1,5 +1,6 @@
1
1
  import type { BigIntStats } from "node:fs";
2
2
  import { type AsyncDirectoryGuard } from "./directory-guard.js";
3
+ import { FsSafeError } from "./errors.js";
3
4
  import type { RootContext } from "./root-context.js";
4
5
  import type { GuardedRootWriteTarget } from "./root-write-admission.js";
5
6
  type PreparedWriteTargetObservation = Readonly<{
@@ -36,6 +37,7 @@ export type SharedRootWriteTarget = Readonly<{
36
37
  mutationAdmission: GuardedRootWriteTarget["mutationAdmission"];
37
38
  preparedParent?: PreparedRootWriteParent;
38
39
  }>;
40
+ export declare function writeSelectionChanged(cause?: unknown): FsSafeError;
39
41
  export declare function assertPreparedRootWriteParentCurrent(prepared: PreparedRootWriteParent, verifyTarget?: boolean): void;
40
42
  export declare function prepareSharedRootWriteTarget(root: RootContext, params: SharedRootWriteTargetParams): Promise<SharedRootWriteTarget>;
41
43
  export {};
@@ -12,7 +12,7 @@ import { errorCauseOptions } from "./root-errors.js";
12
12
  import { canReuseParentWithMutationAssertion } from "./root-write-lock-binding.js";
13
13
  import { inspectFileIdentitySync } from "./strict-file-identity.js";
14
14
  import { getFsSafeTestHooks } from "./test-hooks.js";
15
- function writeSelectionChanged(cause) {
15
+ export function writeSelectionChanged(cause) {
16
16
  return new FsSafeError("path-mismatch", "write target changed during operation", errorCauseOptions(cause));
17
17
  }
18
18
  function ordinarySharedWriteRoute(root, relativePath, operationTargetPath) {
@@ -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 = {}) {
@@ -13,7 +13,11 @@ type SecretFileWriteParams = {
13
13
  dirMode?: number;
14
14
  durable?: boolean;
15
15
  };
16
- export declare function prepareSecretFileWrite(params: Omit<SecretFileWriteParams, "content">): Promise<{
16
+ type SecretFileCreateParams = Omit<SecretFileWriteParams, "durable"> & {
17
+ /** "file" requires file synchronization; directory synchronization remains best effort. */
18
+ durable?: boolean | "file";
19
+ };
20
+ export declare function prepareSecretFileWrite(params: Pick<SecretFileWriteParams, "rootDir" | "filePath" | "mode" | "dirMode">): Promise<{
17
21
  mode: number;
18
22
  rootGuard: AsyncDirectoryGuard<BigIntStats>;
19
23
  parentGuard: AsyncDirectoryGuard<BigIntStats>;
@@ -21,5 +25,5 @@ export declare function prepareSecretFileWrite(params: Omit<SecretFileWriteParam
21
25
  finalFilePath: string;
22
26
  }>;
23
27
  export declare function writeSecretFileAtomic(params: SecretFileWriteParams): Promise<void>;
24
- export declare function createSecretFileAtomic(params: SecretFileWriteParams): Promise<void>;
28
+ export declare function createSecretFileAtomic(params: SecretFileCreateParams): Promise<void>;
25
29
  export {};
@@ -285,6 +285,7 @@ async function materializeSecretFileAtomic(params, createOnly) {
285
285
  mode,
286
286
  verifyPosixMode: true,
287
287
  sync: params.durable !== false,
288
+ strictFileSync: createOnly && params.durable === "file",
288
289
  overwrite: !createOnly,
289
290
  input: { kind: "buffer", data: typeof params.content === "string" ? params.content : Buffer.from(params.content) },
290
291
  rootIdentity: { dev: parentGuard.stat.dev, ino: parentGuard.stat.ino },
@@ -1,15 +1,11 @@
1
- import { FsSafeError } from "./errors.js";
2
1
  import { fileIdentityMismatchError } from "./strict-file-identity.js";
3
2
  import { getNativeBinding } from "./native.js";
4
3
  import { getFsSafeNativeConfig } from "./native-config.js";
5
4
  import { warnNativeFallback } from "./native-fallback-warning.js";
6
5
  import { inspectWindowsDescriptorCommand } from "./windows-security-command.js";
7
- import { validateSecureWindowsSecurityFacts } from "./windows-security-facts.js";
6
+ import { unverified as permissionUnverified, validateSecureWindowsSecurityFacts } from "./windows-security-facts.js";
8
7
  const IDENTITY_RE = /^([0-9a-f]{8}):([0-9a-f]{16})$/;
9
8
  const TRUSTED_OWNER_CLASSES = new Set(["current-user", "system", "administrators"]);
10
- function permissionUnverified(message, cause) {
11
- throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
12
- }
13
9
  function isRecord(value) {
14
10
  return typeof value === "object" && value !== null;
15
11
  }
@@ -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,7 +1,7 @@
1
1
  import type { Root } from "./root-impl.js";
2
2
  import type { HeldSidecarLock } from "./sidecar-lock-admission.js";
3
3
  import { readSidecarLockRawSnapshot, type SidecarLockSnapshot } from "./sidecar-lock-reclaim.js";
4
- type SidecarAdmissionRunner = {
4
+ export type SidecarAdmissionRunner = {
5
5
  hasToken(): boolean;
6
6
  run<T>(callback: () => T): T;
7
7
  };
@@ -39,4 +39,3 @@ export declare function observeHeldSidecarParser(params: {
39
39
  parserAccessor(): ((raw: string) => unknown) | undefined;
40
40
  isTransientDenial(error: unknown): boolean;
41
41
  }): Promise<HeldSidecarParserObservation>;
42
- export {};
@@ -1,5 +1,4 @@
1
- import type { Root } from "./root-impl.js";
2
- import { type SidecarLockSnapshot } from "./sidecar-lock-reclaim.js";
1
+ import type { HeldSidecarLock } from "./sidecar-lock-admission.js";
3
2
  import type { SidecarLockHandle } from "./sidecar-lock-types.js";
4
3
  export declare function stopSidecarLockMonitoring(held: {
5
4
  compromiseTimer?: NodeJS.Timeout;
@@ -14,12 +13,7 @@ export declare function createSidecarLockHandle(params: {
14
13
  }): SidecarLockHandle;
15
14
  export declare function createHeldSidecarLockHandle(params: {
16
15
  normalizedTargetPath: string;
17
- held: {
18
- lockPath: string;
19
- snapshot: SidecarLockSnapshot;
20
- lockRoot?: Root;
21
- parsePayload?: (raw: string) => unknown;
22
- };
16
+ held: Pick<HeldSidecarLock, "lockPath" | "snapshot" | "lockRoot" | "parsePayload">;
23
17
  release: (options?: {
24
18
  retry?: boolean;
25
19
  }) => Promise<unknown>;
@@ -1,4 +1,4 @@
1
- import type { SidecarLockRetryOptions } from "./sidecar-lock-types.js";
1
+ import type { SidecarLockReclaimParams, SidecarLockRetryOptions } from "./sidecar-lock-types.js";
2
2
  export declare function validateSidecarLockRetryOptions(retry: SidecarLockRetryOptions): void;
3
3
  export declare function validateSidecarLockTimeoutMs(timeoutMs: number | undefined): void;
4
4
  export declare function validateSidecarLockStaleMs(staleMs: number | undefined): void;
@@ -11,9 +11,4 @@ export declare function sidecarLockRetryDelay(retry: SidecarLockRetryOptions, ti
11
11
  export declare const maxTransientLockDenials = 8;
12
12
  export declare function isTransientLockFileDenial(error: unknown, lockPath: string): boolean;
13
13
  export declare function sidecarLockPayloadCreatedAtMs(payload: unknown): number | null;
14
- export declare function defaultSidecarLockShouldReclaim(params: {
15
- lockPath: string;
16
- payload: unknown;
17
- staleMs: number;
18
- nowMs: number;
19
- }): Promise<boolean>;
14
+ export declare function defaultSidecarLockShouldReclaim(params: Pick<SidecarLockReclaimParams, "lockPath" | "payload" | "staleMs" | "nowMs">): Promise<boolean>;
@@ -1,11 +1,7 @@
1
1
  import { type SidecarReclaimGuard } from "./sidecar-lock-reclaim.js";
2
- import { type SidecarLockParserState } from "./sidecar-lock-admission-parser.js";
2
+ import { type SidecarLockParserState, type SidecarAdmissionRunner } from "./sidecar-lock-admission-parser.js";
3
3
  import type { Root } from "./root-impl.js";
4
4
  import type { SidecarLockAcquireOptions } from "./sidecar-lock-types.js";
5
- type SidecarAdmissionRunner = {
6
- hasToken(): boolean;
7
- run<T>(callback: () => T): T;
8
- };
9
5
  type StaleOptions = Pick<SidecarLockAcquireOptions<Record<string, unknown>>, "shouldReclaim" | "shouldRemoveStaleLock" | "staleRecovery">;
10
6
  export type SidecarLockStaleOptionsState = {
11
7
  shouldReclaimObserved?: boolean;
@@ -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 {};