@openclaw/fs-safe 0.18.0 → 0.18.2

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 (183) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/advanced.d.ts +4 -2
  3. package/dist/advanced.js +1 -1
  4. package/dist/archive-durability.d.ts +1 -1
  5. package/dist/archive-merge.js +0 -1
  6. package/dist/archive-parser.wasm +0 -0
  7. package/dist/archive-plan.d.ts +64 -3
  8. package/dist/archive-plan.js +55 -1
  9. package/dist/archive-staging.d.ts +1 -2
  10. package/dist/archive-staging.js +0 -2
  11. package/dist/archive-tar-wasm.d.ts +1 -1
  12. package/dist/archive-zip-directory.d.ts +1 -1
  13. package/dist/archive-zip-entry.d.ts +1 -1
  14. package/dist/archive-zip-entry.js +1 -1
  15. package/dist/archive-zip-preflight.d.ts +0 -2
  16. package/dist/archive-zip-preflight.js +0 -1
  17. package/dist/archive.d.ts +14 -8
  18. package/dist/archive.js +127 -12
  19. package/dist/atomic.d.ts +3 -1
  20. package/dist/copy-publication.d.ts +1 -1
  21. package/dist/create.d.ts +16 -2
  22. package/dist/create.js +388 -2
  23. package/dist/creation-boundary.d.ts +60 -0
  24. package/dist/creation-boundary.js +371 -0
  25. package/dist/directory-guard.d.ts +0 -1
  26. package/dist/directory-guard.js +0 -3
  27. package/dist/directory-mode-node.d.ts +20 -2
  28. package/dist/directory-mode-node.js +89 -3
  29. package/dist/durability.d.ts +2 -1
  30. package/dist/file-lock-sync-acquisition.js +3 -3
  31. package/dist/file-lock-sync-root-acquire.js +2 -6
  32. package/dist/file-lock-sync-root-options.d.ts +2 -10
  33. package/dist/file-lock-sync-root.d.ts +2 -1
  34. package/dist/file-lock-sync-stale-admission.js +3 -10
  35. package/dist/file-lock-sync.d.ts +4 -21
  36. package/dist/file-store-boundary.d.ts +7 -0
  37. package/dist/file-store-boundary.js +58 -1
  38. package/dist/file-store-sync-write.js +2 -3
  39. package/dist/file-store.js +1 -6
  40. package/dist/guarded-mkdir.d.ts +1 -1
  41. package/dist/index.d.ts +9 -6
  42. package/dist/json-durable-queue-ownership.d.ts +1 -5
  43. package/dist/json-durable-queue-read.js +7 -3
  44. package/dist/json-durable-queue.d.ts +0 -1
  45. package/dist/json-durable-queue.js +1 -3
  46. package/dist/json-store.js +1 -2
  47. package/dist/json.js +1 -2
  48. package/dist/move-path.js +1 -2
  49. package/dist/native-operations.js +0 -1
  50. package/dist/native-pinned-write-windows.d.ts +1 -1
  51. package/dist/native-pinned-write-windows.js +1 -1
  52. package/dist/native-pinned-write.d.ts +1 -1
  53. package/dist/native-pinned-write.js +3 -6
  54. package/dist/native-policy-parent-windows.d.ts +1 -1
  55. package/dist/native-staged-file.d.ts +2 -2
  56. package/dist/native-staged-file.js +1 -2
  57. package/dist/permission-exec.d.ts +6 -0
  58. package/dist/permissions-public.d.ts +2 -1
  59. package/dist/permissions-windows.d.ts +3 -6
  60. package/dist/permissions.d.ts +3 -10
  61. package/dist/permissions.js +0 -1
  62. package/dist/pinned-mutation-admission.d.ts +1 -1
  63. package/dist/pinned-open.d.ts +1 -0
  64. package/dist/pinned-open.js +7 -2
  65. package/dist/pinned-operation.d.ts +1 -1
  66. package/dist/pinned-operation.js +3 -16
  67. package/dist/pinned-write-mode.js +1 -1
  68. package/dist/pinned-write-staged.js +1 -2
  69. package/dist/pinned-write.d.ts +0 -1
  70. package/dist/pinned-write.js +3 -5
  71. package/dist/private-producer-handoff-sync.d.ts +2 -12
  72. package/dist/private-producer-handoff-sync.js +1 -1
  73. package/dist/private-producer-handoff.d.ts +3 -3
  74. package/dist/private-temp-workspace.js +1 -2
  75. package/dist/publish-copy-stage.d.ts +1 -1
  76. package/dist/publish-file.d.ts +0 -1
  77. package/dist/publish-file.js +1 -2
  78. package/dist/replace-directory.js +6 -19
  79. package/dist/replace-file-copy-fallback.d.ts +1 -8
  80. package/dist/replace-file-descriptor.d.ts +3 -11
  81. package/dist/replace-file-descriptor.js +1 -1
  82. package/dist/replace-file-rename-policy.d.ts +1 -2
  83. package/dist/replace-file.d.ts +3 -3
  84. package/dist/replace-file.js +1 -1
  85. package/dist/retained-directory-replacement.d.ts +1 -0
  86. package/dist/retained-directory-replacement.js +1 -1
  87. package/dist/root-context.js +3 -7
  88. package/dist/root-create-input.d.ts +1 -1
  89. package/dist/root-directory-creation.d.ts +1 -1
  90. package/dist/root-directory-creation.js +1 -1
  91. package/dist/root-directory-list.js +3 -7
  92. package/dist/root-entries.js +2 -2
  93. package/dist/root-errors.d.ts +4 -0
  94. package/dist/root-errors.js +5 -3
  95. package/dist/root-file-final-admission.d.ts +1 -1
  96. package/dist/root-file-final-admission.js +1 -6
  97. package/dist/root-file.js +2 -5
  98. package/dist/root-impl.d.ts +0 -5
  99. package/dist/root-impl.js +22 -21
  100. package/dist/root-move-noreplace.js +4 -10
  101. package/dist/root-observed-path.js +2 -4
  102. package/dist/root-options.d.ts +1 -1
  103. package/dist/root-path-existing.js +2 -3
  104. package/dist/root-path-stat.js +2 -4
  105. package/dist/root-public-types.d.ts +11 -0
  106. package/dist/root-remove-identity.js +2 -4
  107. package/dist/root-remove.js +2 -4
  108. package/dist/root-write-admission.d.ts +1 -1
  109. package/dist/root-write-admission.js +3 -7
  110. package/dist/root-write-complete-parent.js +2 -3
  111. package/dist/root-write-verification.d.ts +1 -1
  112. package/dist/root.d.ts +2 -6
  113. package/dist/secret-file.js +11 -17
  114. package/dist/secret-read-policy.js +1 -1
  115. package/dist/secure-file-windows.js +4 -9
  116. package/dist/secure-temp-dir.js +2 -7
  117. package/dist/secure-temp-repair.js +5 -1
  118. package/dist/sidecar-lock-acquire.js +8 -11
  119. package/dist/sidecar-lock-policy.d.ts +1 -0
  120. package/dist/sidecar-lock-policy.js +7 -0
  121. package/dist/sidecar-lock-reclaim.js +5 -6
  122. package/dist/sidecar-lock-stale-admission.js +2 -6
  123. package/dist/sidecar-lock-types.d.ts +22 -16
  124. package/dist/stat-observation.js +3 -10
  125. package/dist/strict-file-identity.d.ts +2 -0
  126. package/dist/strict-file-identity.js +6 -6
  127. package/dist/temp-target.js +3 -8
  128. package/dist/temp-workspace-admission.js +7 -15
  129. package/dist/temp-workspace-child-admission.d.ts +16 -2
  130. package/dist/temp-workspace-child-admission.js +65 -3
  131. package/dist/temp-workspace-descriptor.d.ts +1 -1
  132. package/dist/temp-workspace-descriptor.js +2 -4
  133. package/dist/text-atomic.js +1 -1
  134. package/dist/windows-owner.d.ts +3 -6
  135. package/dist/windows-owner.js +1 -1
  136. package/dist/windows-path-alias.d.ts +1 -0
  137. package/dist/windows-path-alias.js +5 -0
  138. package/dist/windows-security-bridge.cs +0 -2
  139. package/docs/advanced.md +3 -1
  140. package/docs/secret-file.md +13 -0
  141. package/docs/sidecar-lock.md +6 -0
  142. package/docs/store.md +6 -0
  143. package/docs/temp.md +8 -1
  144. package/docs/writing.md +5 -0
  145. package/package.json +8 -8
  146. package/dist/archive-native.d.ts +0 -10
  147. package/dist/archive-native.js +0 -73
  148. package/dist/archive-options.d.ts +0 -30
  149. package/dist/archive-policy.d.ts +0 -21
  150. package/dist/archive-policy.js +0 -38
  151. package/dist/archive-tar-inspect.d.ts +0 -6
  152. package/dist/archive-tar-inspect.js +0 -62
  153. package/dist/archive-tar.d.ts +0 -18
  154. package/dist/archive-tar.js +0 -21
  155. package/dist/create-directory.d.ts +0 -18
  156. package/dist/create-directory.js +0 -111
  157. package/dist/create-file-async.d.ts +0 -6
  158. package/dist/create-file-async.js +0 -121
  159. package/dist/create-file.d.ts +0 -7
  160. package/dist/create-file.js +0 -179
  161. package/dist/creation-darwin.d.ts +0 -5
  162. package/dist/creation-darwin.js +0 -70
  163. package/dist/creation-file-state.d.ts +0 -18
  164. package/dist/creation-file-state.js +0 -118
  165. package/dist/creation-path.d.ts +0 -22
  166. package/dist/creation-path.js +0 -83
  167. package/dist/creation-permissions.d.ts +0 -18
  168. package/dist/creation-permissions.js +0 -115
  169. package/dist/directory-mode-owner.d.ts +0 -20
  170. package/dist/directory-mode-owner.js +0 -78
  171. package/dist/file-store-copy-source.d.ts +0 -4
  172. package/dist/file-store-copy-source.js +0 -31
  173. package/dist/file-store-limit.d.ts +0 -1
  174. package/dist/file-store-limit.js +0 -7
  175. package/dist/file-store-path.d.ts +0 -2
  176. package/dist/file-store-path.js +0 -30
  177. package/dist/standalone-publication-path.d.ts +0 -1
  178. package/dist/standalone-publication-path.js +0 -6
  179. package/dist/temp-workspace-identity.d.ts +0 -14
  180. package/dist/temp-workspace-identity.js +0 -41
  181. package/dist/temp-workspace-permissions.d.ts +0 -3
  182. package/dist/temp-workspace-permissions.js +0 -32
  183. /package/dist/{archive-options.js → root-public-types.js} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { FsSafeError } from "./errors.js";
2
2
  import { recordFileObservationFailure } from "./file-observation.js";
3
- function identityMismatch() {
3
+ export function fileIdentityMismatchError() {
4
4
  const error = new FsSafeError("path-mismatch", "file identity changed or could not be verified");
5
5
  recordFileObservationFailure(error, "identity");
6
6
  return error;
@@ -13,20 +13,20 @@ function identityCheck(expected, platform) {
13
13
  const value = stat[field];
14
14
  // Numeric receipts cannot recover identity bits already lost to rounding.
15
15
  if (typeof value !== "bigint")
16
- throw identityMismatch();
16
+ throw fileIdentityMismatchError();
17
17
  if (platform === "win32" && value === 0n) {
18
18
  complete = false;
19
19
  }
20
20
  else {
21
21
  if (known[field] !== undefined && known[field] !== value)
22
- throw identityMismatch();
22
+ throw fileIdentityMismatchError();
23
23
  known[field] = value;
24
24
  }
25
25
  }
26
26
  return complete;
27
27
  };
28
28
  if (expected && !check(expected))
29
- throw identityMismatch();
29
+ throw fileIdentityMismatchError();
30
30
  return check;
31
31
  }
32
32
  // Retry only unknown Windows identities, retaining every known component so a
@@ -38,9 +38,9 @@ export async function inspectFileIdentity(inspect, expected, platform = process.
38
38
  if (check(stat))
39
39
  return stat;
40
40
  }
41
- throw identityMismatch();
41
+ throw fileIdentityMismatchError();
42
42
  }
43
- export function inspectFileIdentitySync(inspect, expected, platform = process.platform, mismatch = identityMismatch) {
43
+ export function inspectFileIdentitySync(inspect, expected, platform = process.platform, mismatch = fileIdentityMismatchError) {
44
44
  let knownDev;
45
45
  let knownIno;
46
46
  if (expected) {
@@ -6,6 +6,7 @@ import { suffixWindowsReservedDeviceName } from "./filename.js";
6
6
  import { sameFileIdentityForCleanup } from "./file-identity.js";
7
7
  import { assertSafePathSegment, sanitizeSafePathSegment, trimHyphenEdges } from "./safe-path-segment.js";
8
8
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
9
+ import { hasNodeErrorCode } from "./path.js";
9
10
  import { registerTempPathForExit } from "./temp-cleanup.js";
10
11
  import { assertNoWindowsPathAlias, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
11
12
  const HYPHEN_CHAR_CODE = 0x2d;
@@ -76,12 +77,6 @@ export function buildRandomTempFilePath(params) {
76
77
  assertNoWindowsPathAlias(filePath, "filesystem", "temp file path uses a Windows filesystem namespace alias");
77
78
  return filePath;
78
79
  }
79
- function isNodeErrorWithCode(err, code) {
80
- return (typeof err === "object" &&
81
- err !== null &&
82
- "code" in err &&
83
- err.code === code);
84
- }
85
80
  async function cleanupTempDir(dir, identity, onCleanupError) {
86
81
  try {
87
82
  const current = fsSync.lstatSync(dir, { bigint: true });
@@ -91,7 +86,7 @@ async function cleanupTempDir(dir, identity, onCleanupError) {
91
86
  await fs.rm(dir, { recursive: true, force: true });
92
87
  }
93
88
  catch (err) {
94
- if (!isNodeErrorWithCode(err, "ENOENT")) {
89
+ if (!hasNodeErrorCode(err, "ENOENT")) {
95
90
  onCleanupError?.(err);
96
91
  }
97
92
  }
@@ -174,7 +169,7 @@ export async function createOwnedTempFile(params) {
174
169
  await owner.cleanup();
175
170
  }
176
171
  catch (err) {
177
- if (!isNodeErrorWithCode(err, "ENOENT")) {
172
+ if (!hasNodeErrorCode(err, "ENOENT")) {
178
173
  params.onCleanupError?.(err);
179
174
  }
180
175
  }
@@ -2,14 +2,11 @@ import fsSync, {} from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { inspectDirectoryIdentitySync, observeDirectoryIdentitySync, } from "./directory-guard.js";
5
- import { admitTempWorkspaceChild, admitTempWorkspaceChildSync, } from "./temp-workspace-child-admission.js";
5
+ import { admitTempWorkspaceChild, admitTempWorkspaceChildSync, assertTrustedTempWorkspaceDirectory, inspectTempWorkspaceDescriptorIdentitySync, projectTempWorkspaceNumericIdentity, TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY, } from "./temp-workspace-child-admission.js";
6
6
  import { FsSafeError } from "./errors.js";
7
- import { recordFileObservationFailure } from "./file-observation.js";
8
7
  import { realpathSync } from "./realpath.js";
9
8
  import { assertNoWindowsPathAlias, pathForWindowsFilesystem, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
10
- import { inspectFileIdentitySync } from "./strict-file-identity.js";
11
- import { inspectTempWorkspaceDescriptorIdentitySync, projectTempWorkspaceNumericIdentity, TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY, } from "./temp-workspace-identity.js";
12
- import { assertTrustedTempWorkspaceDirectory } from "./temp-workspace-permissions.js";
9
+ import { fileIdentityMismatchError, inspectFileIdentitySync } from "./strict-file-identity.js";
13
10
  const WINDOWS = process.platform === "win32";
14
11
  function effectiveOwner() {
15
12
  // Windows mode/uid fields do not describe ACL authority. The supplied root's
@@ -74,11 +71,6 @@ function assertCanonicalRoot(entry) {
74
71
  throw new FsSafeError("path-mismatch", "temp workspace root ancestry changed");
75
72
  }
76
73
  }
77
- function identityMismatch() {
78
- const error = new FsSafeError("path-mismatch", "file identity changed or could not be verified");
79
- recordFileObservationFailure(error, "identity");
80
- throw error;
81
- }
82
74
  function inspectSnapshotIdentity(entry) {
83
75
  const expected = entry.numericIdentity;
84
76
  if (!expected) {
@@ -89,21 +81,21 @@ function inspectSnapshotIdentity(entry) {
89
81
  const devKnown = Number.isSafeInteger(stat.dev) && stat.dev >= 0 && (!WINDOWS || stat.dev !== 0);
90
82
  if (devKnown) {
91
83
  if (stat.dev !== expected.dev)
92
- identityMismatch();
84
+ throw fileIdentityMismatchError();
93
85
  }
94
86
  else if (WINDOWS)
95
87
  requiresExactRetry = true;
96
88
  else
97
- identityMismatch();
89
+ throw fileIdentityMismatchError();
98
90
  const inoKnown = Number.isSafeInteger(stat.ino) && stat.ino >= 0 && (!WINDOWS || stat.ino !== 0);
99
91
  if (inoKnown) {
100
92
  if (stat.ino !== expected.ino)
101
- identityMismatch();
93
+ throw fileIdentityMismatchError();
102
94
  }
103
95
  else if (WINDOWS)
104
96
  requiresExactRetry = true;
105
97
  else
106
- identityMismatch();
98
+ throw fileIdentityMismatchError();
107
99
  if (!requiresExactRetry)
108
100
  return stat;
109
101
  // Read exact identity only once. The strict helper may re-check this constant
@@ -159,7 +151,7 @@ function canonicalAncestry(root) {
159
151
  }
160
152
  function exactIdentityMatches(current, expected) {
161
153
  if (current.dev !== expected.dev || current.ino !== expected.ino)
162
- identityMismatch();
154
+ throw fileIdentityMismatchError();
163
155
  }
164
156
  function associateTempWorkspaceRoot(entry, ownerUid, descriptorFd) {
165
157
  const stat = inspectTempWorkspaceDescriptorIdentitySync(descriptorFd, entry.identity, entry.numericIdentity);
@@ -1,6 +1,20 @@
1
- import type { BigIntStats, Stats } from "node:fs";
1
+ import { type BigIntStats, type Stats } from "node:fs";
2
2
  import type { TempWorkspaceRootAdmission } from "./temp-workspace-admission.js";
3
- import { type TempWorkspaceIdentityStat } from "./temp-workspace-identity.js";
3
+ export type TempWorkspaceIdentity = Readonly<{
4
+ dev: bigint;
5
+ ino: bigint;
6
+ }>;
7
+ export type TempWorkspaceNumericIdentity = Readonly<{
8
+ dev: number;
9
+ ino: number;
10
+ }>;
11
+ export type TempWorkspaceIdentityStat = BigIntStats | Stats;
12
+ export declare const TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY: boolean;
13
+ export declare function projectTempWorkspaceNumericIdentity(identity: TempWorkspaceIdentity): TempWorkspaceNumericIdentity | undefined;
14
+ export declare function inspectTempWorkspaceDescriptorIdentitySync(fd: number, expected: TempWorkspaceIdentity, numeric: TempWorkspaceNumericIdentity | undefined): TempWorkspaceIdentityStat;
15
+ export declare function inspectTempWorkspaceDirectoryIdentitySync(dir: string, expected: TempWorkspaceIdentity, numeric: TempWorkspaceNumericIdentity | undefined): TempWorkspaceIdentityStat;
16
+ export declare function validateTempWorkspaceDirMode(mode: number): void;
17
+ export declare function assertTrustedTempWorkspaceDirectory(stat: Pick<BigIntStats, "uid" | "mode"> | Pick<Stats, "uid" | "mode">, uid: number | undefined, child?: boolean): void;
4
18
  export declare function assertTempWorkspaceChildState(stat: BigIntStats | Stats, ownerUid: number | undefined): void;
5
19
  export declare function validateInitialTempWorkspaceChild(stat: BigIntStats, ownerUid: number | undefined, mode: number): boolean;
6
20
  export declare function childHasRequestedMode(stat: BigIntStats | Stats, mode: number): boolean;
@@ -1,8 +1,70 @@
1
- import { inspectDirectoryIdentitySync } from "./directory-guard.js";
1
+ import fsSync, {} from "node:fs";
2
+ import { inspectDirectoryIdentitySync, observeDirectoryIdentitySync } from "./directory-guard.js";
2
3
  import { pinNodeDirectoryForMode, pinNodeDirectoryForModeSync } from "./directory-mode-node.js";
3
4
  import { FsSafeError } from "./errors.js";
4
- import { TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY, } from "./temp-workspace-identity.js";
5
- import { assertTrustedTempWorkspaceDirectory } from "./temp-workspace-permissions.js";
5
+ import { fileIdentityMismatchError, inspectFileIdentitySync } from "./strict-file-identity.js";
6
+ export const TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY = process.platform === "linux" || process.platform === "darwin";
7
+ export function projectTempWorkspaceNumericIdentity(identity) {
8
+ const dev = Number(identity.dev);
9
+ const ino = Number(identity.ino);
10
+ if (!Number.isSafeInteger(dev) || dev < 0 || BigInt(dev) !== identity.dev ||
11
+ !Number.isSafeInteger(ino) || ino < 0 || BigInt(ino) !== identity.ino) {
12
+ return undefined;
13
+ }
14
+ return Object.freeze({ dev, ino });
15
+ }
16
+ function inspectNumericIdentity(current, expected) {
17
+ if (!Number.isSafeInteger(current.dev) || current.dev < 0 || current.dev !== expected.dev ||
18
+ !Number.isSafeInteger(current.ino) || current.ino < 0 || current.ino !== expected.ino) {
19
+ throw fileIdentityMismatchError();
20
+ }
21
+ return current;
22
+ }
23
+ export function inspectTempWorkspaceDescriptorIdentitySync(fd, expected, numeric) {
24
+ // A malformed or mismatched numeric observation is definite and never
25
+ // retried. Unsafe receipts and other platforms retain exact replay.
26
+ if (TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY && numeric) {
27
+ return inspectNumericIdentity(fsSync.fstatSync(fd), numeric);
28
+ }
29
+ return inspectFileIdentitySync(() => fsSync.fstatSync(fd, { bigint: true }), expected);
30
+ }
31
+ export function inspectTempWorkspaceDirectoryIdentitySync(dir, expected, numeric) {
32
+ if (TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY && numeric) {
33
+ return inspectNumericIdentity(observeDirectoryIdentitySync(dir), numeric);
34
+ }
35
+ return inspectFileIdentitySync(() => observeDirectoryIdentitySync(dir, { bigint: true }), expected);
36
+ }
37
+ export function validateTempWorkspaceDirMode(mode) {
38
+ if (!Number.isInteger(mode) || mode < 0 || mode > 0o7777) {
39
+ throw new FsSafeError("insecure-permissions", "temp workspace dirMode must be permission bits");
40
+ }
41
+ if (process.platform !== "win32" && (mode & 0o022) !== 0) {
42
+ throw new FsSafeError("insecure-permissions", "temp workspace must not be group/world writable");
43
+ }
44
+ }
45
+ export function assertTrustedTempWorkspaceDirectory(stat, uid, child = false) {
46
+ if (uid === undefined)
47
+ return;
48
+ const ownedByUser = typeof stat.uid === "bigint" ? stat.uid === BigInt(uid) : stat.uid === uid;
49
+ const ownedByRoot = typeof stat.uid === "bigint" ? stat.uid === 0n : stat.uid === 0;
50
+ if (!ownedByUser && (child || !ownedByRoot)) {
51
+ throw new FsSafeError("not-owned", "temp workspace directory has an untrusted owner");
52
+ }
53
+ // A root/current-user-owned sticky directory protects children owned by us,
54
+ // including the usual shared system temp directory. Never chmod that parent.
55
+ const writable = typeof stat.mode === "bigint"
56
+ ? (stat.mode & 18n) !== 0n
57
+ : Number.isSafeInteger(stat.mode) && stat.mode >= 0 && (stat.mode & 0o022) !== 0;
58
+ const sticky = typeof stat.mode === "bigint"
59
+ ? (stat.mode & 512n) !== 0n
60
+ : Number.isSafeInteger(stat.mode) && stat.mode >= 0 && (stat.mode & 0o1000) !== 0;
61
+ if (typeof stat.mode !== "bigint" && (!Number.isSafeInteger(stat.mode) || stat.mode < 0)) {
62
+ throw new FsSafeError("insecure-permissions", "temp workspace directory permissions are invalid");
63
+ }
64
+ if (writable && (child || !sticky)) {
65
+ throw new FsSafeError("insecure-permissions", "temp workspace directory is group/world writable without sticky protection");
66
+ }
67
+ }
6
68
  const WINDOWS = process.platform === "win32";
7
69
  export function assertTempWorkspaceChildState(stat, ownerUid) {
8
70
  const exactIdentity = typeof stat.dev === "bigint" && typeof stat.ino === "bigint" &&
@@ -1,7 +1,7 @@
1
1
  import fsSync from "node:fs";
2
2
  import type { FileIdentityStat } from "./file-identity.js";
3
3
  import type { TempWorkspaceRootAdmission } from "./temp-workspace-admission.js";
4
- import { type TempWorkspaceIdentityStat } from "./temp-workspace-identity.js";
4
+ import { type TempWorkspaceIdentityStat } from "./temp-workspace-child-admission.js";
5
5
  type DirectoryDescriptorAccess = "read" | "search";
6
6
  export type RetainedDirectory = {
7
7
  fd: number;
@@ -1,12 +1,10 @@
1
1
  import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
- import { nodeDirectorySearchOnlyFlags } from "./directory-mode-node.js";
4
- import { assertOwnedDirectory } from "./directory-mode-owner.js";
3
+ import { nodeDirectorySearchOnlyFlags, assertOwnedDirectory } from "./directory-mode-node.js";
5
4
  import { FsSafeError } from "./errors.js";
6
5
  import { assertNoWindowsPathAlias, pathForWindowsFilesystem, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
7
6
  import { inspectFileIdentitySync } from "./strict-file-identity.js";
8
- import { assertTempWorkspaceChildState, childHasRequestedMode, validateAdmittedTempWorkspaceChild, } from "./temp-workspace-child-admission.js";
9
- import { inspectTempWorkspaceDescriptorIdentitySync, inspectTempWorkspaceDirectoryIdentitySync, projectTempWorkspaceNumericIdentity, TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY, } from "./temp-workspace-identity.js";
7
+ import { assertTempWorkspaceChildState, childHasRequestedMode, validateAdmittedTempWorkspaceChild, inspectTempWorkspaceDescriptorIdentitySync, inspectTempWorkspaceDirectoryIdentitySync, projectTempWorkspaceNumericIdentity, TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY, } from "./temp-workspace-child-admission.js";
10
8
  function assertRetainedChildDirectory(stat) {
11
9
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
12
10
  throw new FsSafeError("not-file", "temp workspace child must be a real directory");
@@ -1,5 +1,5 @@
1
1
  import { replaceFileAtomic } from "./replace-file.js";
2
- import { admitStandalonePublicationPath } from "./standalone-publication-path.js";
2
+ import { admitStandalonePublicationPath } from "./windows-path-alias.js";
3
3
  export async function writeTextAtomic(filePath, content, options) {
4
4
  const admittedPath = admitStandalonePublicationPath(filePath);
5
5
  const payload = options?.trailingNewline && !content.endsWith("\n") ? `${content}\n` : content;
@@ -1,9 +1,9 @@
1
- import { type PermissionCommandFailure } from "./permission-exec.js";
1
+ import { type PermissionFailureFields } from "./permission-exec.js";
2
2
  export type WindowsOwnerExec = (command: string, args: string[]) => Promise<{
3
3
  stdout: string;
4
4
  stderr: string;
5
5
  }>;
6
- export type WindowsOwnerSummary = {
6
+ export type WindowsOwnerSummary = Omit<PermissionFailureFields & {
7
7
  sid?: string;
8
8
  currentUserSid?: string;
9
9
  daclPresent?: boolean;
@@ -11,10 +11,7 @@ export type WindowsOwnerSummary = {
11
11
  aclError?: string;
12
12
  remote?: boolean;
13
13
  trusted?: boolean;
14
- error?: string;
15
- errorDetail?: PermissionCommandFailure;
16
- errorCause?: unknown;
17
- };
14
+ }, never>;
18
15
  export type WindowsOwnerAce = {
19
16
  sid: string;
20
17
  mask: number;
@@ -1,4 +1,4 @@
1
- import { formatCaughtPermissionFailure, formatPermissionErrorDetail, getPermissionCommandFailure, } from "./permission-exec.js";
1
+ import { formatCaughtPermissionFailure, getPermissionCommandFailure, } from "./permission-exec.js";
2
2
  import { resolveWindowsSystemCommand } from "./windows-command.js";
3
3
  import { hasWindowsPathAlias } from "./windows-path-alias.js";
4
4
  const SID_RE = /^\*?s-\d+-\d+(-\d+)+$/i;
@@ -36,3 +36,4 @@ export declare function hasWindowsPathAlias(value: string, kind: WindowsPathAlia
36
36
  export declare function assertNoWindowsPathAliasForPlatform(value: string, kind: WindowsPathAliasKind, message: string, platform: NodeJS.Platform | string | undefined): void;
37
37
  export declare function assertNoWindowsPathAlias(value: string, kind?: WindowsPathAliasKind, message?: string, platform?: NodeJS.Platform | string): void;
38
38
  export declare function isWindowsPathAliasError(error: unknown): error is FsSafeError;
39
+ export declare function admitStandalonePublicationPath(value: string, message?: string): string;
@@ -115,3 +115,8 @@ export function assertNoWindowsPathAlias(value, kind = "filesystem", message = "
115
115
  export function isWindowsPathAliasError(error) {
116
116
  return error instanceof FsSafeError && error.details?.reason === "windows-path-alias";
117
117
  }
118
+ export function admitStandalonePublicationPath(value, message) {
119
+ const admittedPath = anchorWindowsDriveRelativePath(value);
120
+ assertNoWindowsPathAlias(admittedPath, "filesystem", message);
121
+ return admittedPath;
122
+ }
@@ -2,8 +2,6 @@
2
2
  // descriptor never passes through CommonSecurityDescriptor's ACE normalization.
3
3
  using System;
4
4
  using System.Collections.Generic;
5
- using System.ComponentModel;
6
- using System.IO;
7
5
  using System.Runtime.InteropServices;
8
6
  using System.Security.AccessControl;
9
7
  using System.Security.Principal;
package/docs/advanced.md CHANGED
@@ -106,7 +106,9 @@ before component traversal. Immediately before transferring descriptor ownership
106
106
  they check that root, freshly canonicalize the consumed pathname, admit the fresh
107
107
  spelling under the captured root, compare a no-follow canonical-leaf observation
108
108
  with the retained descriptor, and check the root again. Boundary or identity drift
109
- is a validation failure and the descriptor is closed. A custom `ioFs` supplies
109
+ is a validation failure and triggers one descriptor-close attempt. If cleanup also
110
+ fails, the selected admission failure result is preserved. Successful opens transfer
111
+ descriptor ownership to the caller. A custom `ioFs` supplies
110
112
  these observations; the built-in adapter uses fs-safe's native realpath wrapper.
111
113
  On Windows, the built-in adapter binds native root spelling before traversal,
112
114
  including supplied `rootRealPath`, and returns that spelling in its root receipt.
@@ -113,6 +113,10 @@ If an already validated descriptor fails while reading, both readers throw an
113
113
  operational `FsSafeError` with `code: "read-failed"`; inspect `cause` for the
114
114
  underlying Node filesystem code such as `EIO`.
115
115
 
116
+ Caught `null` or `undefined` inspection and read failures are reported as
117
+ structured errors with an `Error` cause carrying `"null"` or `"undefined"`,
118
+ instead of an internal `TypeError` while inspecting the thrown value.
119
+
116
120
  A synchronous reader closes its descriptor once. A close failure preserves an
117
121
  earlier read or identity-validation error; after a successful read, the close
118
122
  failure is reported before trimming or rejecting empty content.
@@ -137,6 +141,11 @@ startWebhookVerifier(signingKey);
137
141
 
138
142
  Async. Creates the parent directory at `dirMode` (default `0o700`) if missing, writes content to a sibling temp file, finalizes `mode` (default `0o600`) through an owned descriptor after content writes, and atomically renames over the destination. Publication verification checks the final file identity and mode.
139
143
 
144
+ Both secret writers capture top-level parameter values when called, before
145
+ asynchronous filesystem preparation; a supplied byte buffer is captured by
146
+ reference. A parameter getter throwing `null` or `undefined` rejects with that
147
+ same value before filesystem inspection.
148
+
140
149
  On POSIX, both native and JavaScript writers verify actual `0o600` permission
141
150
  bits through the retained descriptor before writing content. A filesystem that
142
151
  reports successful chmod without enforcing those bits fails with
@@ -205,6 +214,10 @@ Both mode options must resolve to integers between `0o0000` and `0o7777`; invali
205
214
 
206
215
  After this operation wins directory creation, initialization uses a pinned descriptor bound to the admitted identity and effective user, with ancestor checks before chmod. It does not chmod the caller's pathname. Creation and descriptor admission are separate operations, not an atomic create-and-pin guarantee. A raced directory that has not reached its requested mode yet is rejected rather than repaired; callers may retry after its creator finishes initialization.
207
216
 
217
+ If initial directory-descriptor admission fails, its original identity, ownership,
218
+ or inspection error is preserved even when closing the rejected descriptor fails.
219
+ Close failures after successful admission remain reportable.
220
+
208
221
  Initialization fails closed if the platform cannot safely pin a created directory. In particular, a non-root macOS process cannot pin a new `000` directory produced by `umask(0o777)`; the write fails without repairing that directory or writing a secret. Restrictive masks retaining owner search permission remain usable. Linux x64/arm64 can use the guarded `O_PATH`/procfs descriptor route where available. There is no unguarded pathname-chmod fallback, and a failure may leave a created directory for caller-managed cleanup.
209
222
 
210
223
  ### `createSecretFileAtomic(params)`
@@ -199,6 +199,10 @@ explicit `retry.retries` still applies with `timeoutMs: Infinity`, and zero allo
199
199
  only the initial attempt. After process defaults are applied, an omitted retry
200
200
  count means unlimited retries, and an omitted or infinite timeout means no
201
201
  deadline. With neither budget bounded, contention can wait indefinitely.
202
+ Finite deadlines use monotonic elapsed time, so system clock corrections do not
203
+ extend or shorten the retry budget. Payload timestamps, `heldEntries().acquiredAt`,
204
+ and stale-policy `nowMs` remain wall-clock based. Deadlines are checked at retry
205
+ boundaries; they do not interrupt callbacks or filesystem operations.
202
206
  `parsePayload` replaces JSON parsing for legacy or custom sidecars. Its `unknown`
203
207
  result is passed to `shouldReclaim` and `shouldRemoveStaleLock`, allowing PID,
204
208
  process-start, argv, or role schemas to remain application-owned.
@@ -263,6 +267,8 @@ Pass `lockRoot` to place sidecar create, read, verification, and removal behind
263
267
  an existing `Root` capability. `lockPath` must resolve inside that root.
264
268
  Identity-conditioned removal remains the only release and reclaim deletion
265
269
  path.
270
+ Root mutation refusals during stale removal propagate unchanged, including
271
+ `null`, `undefined`, and errors carrying `ENOENT`; they do not become missing-sidecar retries.
266
272
 
267
273
  Async Root-backed acquisition normalizes the target's parent without creating
268
274
  it, checking the retained Root before and after normalization. A deleted or
package/docs/store.md CHANGED
@@ -93,6 +93,12 @@ existing single-load rejection and batch-skip behavior. A caller or migration
93
93
  error with code `ENOENT` is still a failure, not a missing queue entry; only a
94
94
  claim that is absent or disappears before reading returns `null` from a single load.
95
95
 
96
+ If closing the read descriptor also fails, the original read, validation,
97
+ callback, or migration failure keeps precedence, including non-Error rejection
98
+ values. A close failure without an earlier failure is reported through the same
99
+ loader policy: direct reads and single loads reject, while batch loads skip it
100
+ unless migration has started.
101
+
96
102
  On Windows, migration releases its read pin once at this publication boundary
97
103
  because an open target can block replacement. It rechecks the exact pathname
98
104
  identity after the asynchronous close while still holding the transfer lock.
package/docs/temp.md CHANGED
@@ -31,6 +31,8 @@ rejects with `permission-unverified`. Existing supplied directories keep their
31
31
  permissions. Missing root components are created at `0o700` and initialized
32
32
  from their first exact security snapshot; if a restrictive umask changes that
33
33
  mode, correction uses a verified directory descriptor.
34
+ An initial mode-descriptor admission error is preserved if closing that rejected
35
+ descriptor also fails; close failures after successful admission remain reportable.
34
36
 
35
37
  For an already existing canonical root, discovery retains only its immutable
36
38
  exact identity. Cleanup-parent retention is provisional: after any native
@@ -645,7 +647,12 @@ recursive-`mkdir` winner is inspected as an untrusted existing directory.
645
647
  Broad-mode repair also uses a pinned descriptor; there is no pathname chmod.
646
648
 
647
649
  Repair and finalization require a known nonnegative safe-integer UID and exact
648
- bigint device, inode, owner, mode, and directory-type facts. They open with
650
+ bigint device, inode, owner, mode, and directory-type facts. Device and inode
651
+ identities accept Node's signed 64-bit stat representation, including negative
652
+ values down to `-(1n << 63n)`; inode zero remains invalid. Previously supported
653
+ nonnegative device and positive inode adapter values remain supported without
654
+ an upper cap. Values are compared exactly as received: signed and unsigned
655
+ encodings of the same bits are not treated as equal. They open with
649
656
  `O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK`, verify the descriptor and
650
657
  current directory entry against the initial receipt before `fchmod`, then
651
658
  verify identity, permissions, and write/search access again before closing.
package/docs/writing.md CHANGED
@@ -198,6 +198,11 @@ existing `durable` file/directory synchronization policy; it does not turn
198
198
  best-effort synchronization into a strict crash-durability guarantee or strengthen
199
199
  JavaScript pathname containment.
200
200
 
201
+ Wider option objects do not enable create-only `atomic` or `private` behavior on
202
+ `write` or `writeJson`. Buffered Root writes and creates also ignore an extra
203
+ `maxBytes` property; that byte limit belongs to streamed creation and `copyIn`.
204
+ File synchronization follows each method's documented `durable` option.
205
+
201
206
  Atomic and streamed creates settle owned cleanup and close operations before
202
207
  returning. Failed or unverifiable cleanup is reported rather than silently
203
208
  discarded. Errors after publication and incomplete-settlement errors carry the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -169,13 +169,13 @@
169
169
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
170
170
  },
171
171
  "optionalDependencies": {
172
- "@openclaw/fs-safe-darwin-arm64": "0.18.0",
173
- "@openclaw/fs-safe-darwin-x64": "0.18.0",
174
- "@openclaw/fs-safe-linux-arm64-gnu": "0.18.0",
175
- "@openclaw/fs-safe-linux-arm64-musl": "0.18.0",
176
- "@openclaw/fs-safe-linux-x64-gnu": "0.18.0",
177
- "@openclaw/fs-safe-linux-x64-musl": "0.18.0",
178
- "@openclaw/fs-safe-win32-x64-msvc": "0.18.0",
172
+ "@openclaw/fs-safe-darwin-arm64": "0.18.2",
173
+ "@openclaw/fs-safe-darwin-x64": "0.18.2",
174
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.18.2",
175
+ "@openclaw/fs-safe-linux-arm64-musl": "0.18.2",
176
+ "@openclaw/fs-safe-linux-x64-gnu": "0.18.2",
177
+ "@openclaw/fs-safe-linux-x64-musl": "0.18.2",
178
+ "@openclaw/fs-safe-win32-x64-msvc": "0.18.2",
179
179
  "jszip": "^3.10.2"
180
180
  },
181
181
  "devDependencies": {
@@ -1,10 +0,0 @@
1
- import type { ArchiveKind } from "./archive-kind.js";
2
- import { type TarMeterLimits } from "./archive-limits.js";
3
- import type { StagedArchiveExtractOptions } from "./archive-options.js";
4
- import type { NativeBinding } from "./native.js";
5
- export declare function throwMappedNativeArchiveError(error: unknown): never;
6
- export declare function extractNativeArchive(params: StagedArchiveExtractOptions & {
7
- binding: NativeBinding;
8
- kind: ArchiveKind;
9
- tarLimits: TarMeterLimits;
10
- }): Promise<void>;
@@ -1,73 +0,0 @@
1
- import { classifyArchiveParserError } from "./archive-parser-errors.js";
2
- import { constants as fsConstants } from "node:fs";
3
- import fs from "node:fs/promises";
4
- import { ArchiveFormatError, isArchiveFormatErrorMessage, } from "./archive-errors.js";
5
- import { validateArchiveEntryPath } from "./archive-entry.js";
6
- import { createArchiveEntryPlanner } from "./archive-plan.js";
7
- import { assertArchiveEntryCountWithinLimit, } from "./archive-limits.js";
8
- import { prepareArchiveDestinationGuard } from "./archive-staging.js";
9
- import { withStagedArchivePublication } from "./archive-merge.js";
10
- import { admitZipFile } from "./archive-zip-admission.js";
11
- import { validateNativeZipManifest } from "./archive-zip-manifest.js";
12
- export function throwMappedNativeArchiveError(error) {
13
- if (error instanceof Error) {
14
- const mapped = classifyArchiveParserError(error.message, { cause: error });
15
- if (mapped)
16
- throw mapped;
17
- if (isArchiveFormatErrorMessage(error.message)) {
18
- throw new ArchiveFormatError(error.message, { cause: error });
19
- }
20
- if (error.code === "InvalidArg") {
21
- throw new ArchiveFormatError(`invalid archive: ${error.message}`, { cause: error });
22
- }
23
- }
24
- throw error;
25
- }
26
- export async function extractNativeArchive(params) {
27
- const { archivePath, limits, tarLimits, deadline } = params;
28
- const zipEntries = [];
29
- if (params.kind === "zip") {
30
- await admitZipFile(archivePath, limits, deadline, (entry) => { zipEntries.push(entry); });
31
- }
32
- const destinationGuard = await prepareArchiveDestinationGuard(params.destDir);
33
- await withStagedArchivePublication({ ...params, destinationGuard }, async (stagingDir) => {
34
- deadline.check();
35
- // N-API retains completed task state on its signal; each pass needs its own.
36
- const manifest = await params.binding
37
- .inspectArchiveNative(archivePath, params.kind, tarLimits, AbortSignal.any([deadline.signal]))
38
- .catch(throwMappedNativeArchiveError);
39
- deadline.check();
40
- assertArchiveEntryCountWithinLimit(manifest.length, limits);
41
- if (params.kind === "zip") {
42
- validateNativeZipManifest(manifest, zipEntries);
43
- }
44
- // Recheck the native manifest before any caller callback observes an entry.
45
- if (params.kind !== "zip") {
46
- for (const entry of manifest)
47
- validateArchiveEntryPath(entry.path);
48
- }
49
- const planEntry = createArchiveEntryPlanner({ ...params, rootDir: stagingDir }, params.kind);
50
- const plan = [];
51
- for (const entry of manifest) {
52
- deadline.check();
53
- const mode = params.kind === "zip"
54
- ? zipEntries[entry.index].creatorSystem === 3
55
- ? zipEntries[entry.index].externalAttributes >>> 16
56
- : undefined
57
- : entry.mode;
58
- const accepted = planEntry({ ...entry, mode });
59
- if (accepted)
60
- plan.push({ ...accepted, index: entry.index });
61
- }
62
- const directory = await fs.open(stagingDir, fsConstants.O_RDONLY |
63
- (typeof fsConstants.O_DIRECTORY === "number" ? fsConstants.O_DIRECTORY : 0));
64
- try {
65
- deadline.check();
66
- await params.binding.extractArchiveNative(archivePath, params.kind, directory.fd, plan.map((entry) => ({ ...entry, mode: entry.kind === "directory" ? 0o700 : 0o600 })), tarLimits, AbortSignal.any([deadline.signal])).catch(throwMappedNativeArchiveError);
67
- }
68
- finally {
69
- await directory.close().catch(() => undefined);
70
- }
71
- return plan;
72
- });
73
- }
@@ -1,30 +0,0 @@
1
- import type { ArchiveKind } from "./archive-kind.js";
2
- import type { ExtractionDeadline } from "./archive-deadline.js";
3
- import type { ArchiveExtractLimits, ResolvedArchiveExtractLimits } from "./archive-limits.js";
4
- import type { ArchiveEntryFilter, ArchiveEntryModePolicy, ArchiveFilteredEntryPolicy } from "./archive-policy.js";
5
- export type ArchiveLogger = {
6
- info?: (message: string) => void;
7
- warn?: (message: string) => void;
8
- };
9
- export type ExtractArchiveOptions = {
10
- archivePath: string;
11
- destDir: string;
12
- timeoutMs: number;
13
- /** Sync published files and directories before returning. Defaults to false. */
14
- durable?: boolean;
15
- kind?: ArchiveKind;
16
- stripComponents?: number;
17
- tarGzip?: boolean;
18
- limits?: ArchiveExtractLimits;
19
- logger?: ArchiveLogger;
20
- entryModes?: ArchiveEntryModePolicy;
21
- /** Remove these rwx bits from final entry modes. Defaults to zero. */
22
- entryUmask?: number;
23
- entryFilter?: ArchiveEntryFilter;
24
- onFiltered?: ArchiveFilteredEntryPolicy;
25
- };
26
- /** Private executors receive owned options and an already-staged archive path. */
27
- export type StagedArchiveExtractOptions = Pick<ExtractArchiveOptions, "archivePath" | "destDir" | "durable" | "stripComponents" | "entryModes" | "entryUmask" | "entryFilter" | "onFiltered"> & {
28
- limits: ResolvedArchiveExtractLimits;
29
- deadline: ExtractionDeadline;
30
- };
@@ -1,21 +0,0 @@
1
- export type ArchiveEntryKind = "file" | "directory" | "symlink" | "other";
2
- export type ArchiveEntryModePolicy = "clamp" | "preserve";
3
- export type ArchiveFilteredEntryPolicy = "reject-archive" | "skip-entry";
4
- export type ArchiveEntryFilter = (entry: {
5
- /** Validated canonical archive path before stripping: / separators, no empty or . components. */
6
- path: string;
7
- kind: ArchiveEntryKind;
8
- size: number;
9
- }) => "extract" | "skip";
10
- export declare function archiveEntryKindFromTarType(type: string): ArchiveEntryKind;
11
- export declare function resolveArchiveEntryMode(params: {
12
- kind: "file" | "directory";
13
- archivedMode?: number | null;
14
- policy?: ArchiveEntryModePolicy;
15
- }): number;
16
- export declare function resolveArchiveFilteredEntryPolicy(value: unknown): ArchiveFilteredEntryPolicy;
17
- export declare function shouldExtractArchiveEntry(params: {
18
- filter?: ArchiveEntryFilter;
19
- onFiltered?: ArchiveFilteredEntryPolicy;
20
- entry: Parameters<ArchiveEntryFilter>[0];
21
- }): boolean;