@openclaw/fs-safe 0.5.6 → 0.6.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 (145) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +16 -10
  3. package/dist/advanced.d.ts +2 -1
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -0
  6. package/dist/archive-crc32.d.ts +2 -0
  7. package/dist/archive-crc32.d.ts.map +1 -0
  8. package/dist/archive-crc32.js +14 -0
  9. package/dist/archive-kind.js +2 -2
  10. package/dist/archive-native.d.ts.map +1 -1
  11. package/dist/archive-native.js +7 -0
  12. package/dist/archive-read.d.ts.map +1 -1
  13. package/dist/archive-read.js +24 -5
  14. package/dist/archive-tar-meta.d.ts.map +1 -1
  15. package/dist/archive-tar-meta.js +53 -6
  16. package/dist/archive-tar-pax.d.ts +8 -0
  17. package/dist/archive-tar-pax.d.ts.map +1 -0
  18. package/dist/archive-tar-pax.js +100 -0
  19. package/dist/archive-tar-runtime.d.ts +1 -0
  20. package/dist/archive-tar-runtime.d.ts.map +1 -1
  21. package/dist/archive-zip-admission.d.ts +7 -0
  22. package/dist/archive-zip-admission.d.ts.map +1 -0
  23. package/dist/archive-zip-admission.js +60 -0
  24. package/dist/archive-zip-count.d.ts +2 -0
  25. package/dist/archive-zip-count.d.ts.map +1 -0
  26. package/dist/archive-zip-count.js +140 -0
  27. package/dist/archive-zip-directory.d.ts +8 -0
  28. package/dist/archive-zip-directory.d.ts.map +1 -0
  29. package/dist/archive-zip-directory.js +224 -0
  30. package/dist/archive-zip-integrity.d.ts.map +1 -1
  31. package/dist/archive-zip-integrity.js +1 -14
  32. package/dist/archive-zip-names.d.ts +12 -0
  33. package/dist/archive-zip-names.d.ts.map +1 -0
  34. package/dist/archive-zip-names.js +99 -0
  35. package/dist/archive-zip-preflight.d.ts +1 -1
  36. package/dist/archive-zip-preflight.d.ts.map +1 -1
  37. package/dist/archive-zip-preflight.js +8 -145
  38. package/dist/archive.d.ts.map +1 -1
  39. package/dist/archive.js +32 -23
  40. package/dist/file-hash.d.ts.map +1 -1
  41. package/dist/file-hash.js +25 -19
  42. package/dist/file-lock-sync.d.ts.map +1 -1
  43. package/dist/file-lock-sync.js +19 -7
  44. package/dist/native-binding.d.ts +3 -0
  45. package/dist/native-binding.d.ts.map +1 -1
  46. package/dist/native-operations.d.ts +4 -1
  47. package/dist/native-operations.d.ts.map +1 -1
  48. package/dist/native-operations.js +22 -6
  49. package/dist/native-pinned-write-windows.d.ts +8 -0
  50. package/dist/native-pinned-write-windows.d.ts.map +1 -0
  51. package/dist/native-pinned-write-windows.js +92 -0
  52. package/dist/native-pinned-write.d.ts.map +1 -1
  53. package/dist/native-pinned-write.js +136 -127
  54. package/dist/native-staged-file.d.ts +24 -0
  55. package/dist/native-staged-file.d.ts.map +1 -0
  56. package/dist/native-staged-file.js +337 -0
  57. package/dist/native.d.ts.map +1 -1
  58. package/dist/native.js +4 -4
  59. package/dist/opened-realpath.d.ts +2 -0
  60. package/dist/opened-realpath.d.ts.map +1 -1
  61. package/dist/opened-realpath.js +12 -7
  62. package/dist/owner-dacl.d.ts.map +1 -1
  63. package/dist/owner-dacl.js +2 -1
  64. package/dist/permission-exec.d.ts +19 -0
  65. package/dist/permission-exec.d.ts.map +1 -1
  66. package/dist/permission-exec.js +57 -11
  67. package/dist/permissions-public.d.ts +1 -1
  68. package/dist/permissions-public.d.ts.map +1 -1
  69. package/dist/permissions-windows.d.ts +3 -0
  70. package/dist/permissions-windows.d.ts.map +1 -1
  71. package/dist/permissions-windows.js +16 -5
  72. package/dist/permissions.d.ts +5 -0
  73. package/dist/permissions.d.ts.map +1 -1
  74. package/dist/pinned-write.d.ts +6 -0
  75. package/dist/pinned-write.d.ts.map +1 -1
  76. package/dist/pinned-write.js +19 -29
  77. package/dist/private-directory.d.ts.map +1 -1
  78. package/dist/private-directory.js +3 -2
  79. package/dist/root-impl.d.ts.map +1 -1
  80. package/dist/root-impl.js +78 -72
  81. package/dist/root-paths.d.ts +11 -14
  82. package/dist/root-paths.d.ts.map +1 -1
  83. package/dist/root-paths.js +36 -27
  84. package/dist/root-write-verification.d.ts +11 -0
  85. package/dist/root-write-verification.d.ts.map +1 -0
  86. package/dist/root-write-verification.js +91 -0
  87. package/dist/secret-file.d.ts +1 -6
  88. package/dist/secret-file.d.ts.map +1 -1
  89. package/dist/secret-file.js +43 -117
  90. package/dist/secret-read-async.d.ts +1 -1
  91. package/dist/secret-read-async.d.ts.map +1 -1
  92. package/dist/secret-read-async.js +45 -67
  93. package/dist/secret-read-policy.d.ts +13 -0
  94. package/dist/secret-read-policy.d.ts.map +1 -0
  95. package/dist/secret-read-policy.js +28 -0
  96. package/dist/secret.d.ts +2 -1
  97. package/dist/secret.d.ts.map +1 -1
  98. package/dist/secret.js +2 -1
  99. package/dist/secure-file.d.ts.map +1 -1
  100. package/dist/secure-file.js +25 -16
  101. package/dist/staged-directory.d.ts +16 -0
  102. package/dist/staged-directory.d.ts.map +1 -0
  103. package/dist/staged-directory.js +60 -0
  104. package/dist/staged-file-types.d.ts +56 -0
  105. package/dist/staged-file-types.d.ts.map +1 -0
  106. package/dist/staged-file-types.js +1 -0
  107. package/dist/staged-file.d.ts +10 -0
  108. package/dist/staged-file.d.ts.map +1 -0
  109. package/dist/staged-file.js +15 -0
  110. package/dist/strict-file-identity.d.ts +6 -0
  111. package/dist/strict-file-identity.d.ts.map +1 -0
  112. package/dist/strict-file-identity.js +48 -0
  113. package/dist/windows-owner.d.ts +3 -0
  114. package/dist/windows-owner.d.ts.map +1 -1
  115. package/dist/windows-owner.js +10 -2
  116. package/docs/advanced.md +11 -1
  117. package/docs/archive.md +62 -15
  118. package/docs/config.md +7 -0
  119. package/docs/contributing.md +45 -7
  120. package/docs/durability.md +17 -4
  121. package/docs/errors.md +16 -1
  122. package/docs/index.md +3 -1
  123. package/docs/install.md +15 -7
  124. package/docs/migrating-to-0.5.md +7 -7
  125. package/docs/migrating-to-0.6.md +43 -0
  126. package/docs/native-helper.md +15 -5
  127. package/docs/native.md +40 -11
  128. package/docs/path-scope.md +28 -2
  129. package/docs/permissions.md +13 -2
  130. package/docs/root.md +1 -1
  131. package/docs/secret-file.md +8 -0
  132. package/docs/secure-file.md +17 -1
  133. package/docs/security-model.md +15 -1
  134. package/docs/sidecar-lock.md +11 -1
  135. package/docs/staged-file.md +178 -0
  136. package/docs/testing.md +1 -1
  137. package/docs/writing.md +19 -0
  138. package/package.json +15 -8
  139. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  140. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  141. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  142. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  143. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  144. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  145. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
@@ -0,0 +1,28 @@
1
+ import { FsSafeError } from "./errors.js";
2
+ export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
3
+ export function secretPathErrorCode(error) {
4
+ const code = error.code;
5
+ return code === "ENOENT" || code === "ENOTDIR" ? "not-found" : "invalid-path";
6
+ }
7
+ export function secretReadError(code, action, label, resolvedPath, error) {
8
+ const cause = error instanceof Error ? error : new Error(String(error));
9
+ return new FsSafeError(code, `Failed to ${action} ${label} file at ${resolvedPath}: ${String(cause)}`, { cause });
10
+ }
11
+ export function assertSecretFilePreview(stat, label, resolvedPath, maxBytes, rejectHardlinks) {
12
+ if (!stat.isFile()) {
13
+ throw new FsSafeError("not-file", `${label} file at ${resolvedPath} must be a regular file.`, { cause: undefined });
14
+ }
15
+ if (rejectHardlinks && stat.nlink > 1n) {
16
+ throw new FsSafeError("hardlink", `${label} file at ${resolvedPath} must not be hardlinked.`, { cause: undefined });
17
+ }
18
+ if (stat.size > maxBytes) {
19
+ throw new FsSafeError("too-large", `${label} file at ${resolvedPath} exceeds ${maxBytes} bytes.`, { cause: undefined });
20
+ }
21
+ }
22
+ export function trimSecretFileContent(raw, label, resolvedPath) {
23
+ const secret = raw.trim();
24
+ if (!secret) {
25
+ throw new FsSafeError("invalid-path", `${label} file at ${resolvedPath} is empty.`, { cause: undefined });
26
+ }
27
+ return secret;
28
+ }
package/dist/secret.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- export { createSecretFileAtomic, DEFAULT_SECRET_FILE_MAX_BYTES, PRIVATE_SECRET_DIR_MODE, PRIVATE_SECRET_FILE_MODE, readSecretFileSync, tryReadSecretFileSync, writeSecretFileAtomic, type SecretFileReadOptions, } from "./secret-file.js";
1
+ export { createSecretFileAtomic, PRIVATE_SECRET_DIR_MODE, PRIVATE_SECRET_FILE_MODE, readSecretFileSync, tryReadSecretFileSync, writeSecretFileAtomic, } from "./secret-file.js";
2
+ export { DEFAULT_SECRET_FILE_MAX_BYTES, type SecretFileReadOptions } from "./secret-read-policy.js";
2
3
  export { readSecretFile, tryReadSecretFile } from "./secret-read-async.js";
3
4
  //# sourceMappingURL=secret.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"secret.d.ts","sourceRoot":"","sources":["../src/secret.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,6BAA6B,EAC7B,uBAAuB,EACvB,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,qBAAqB,GAC3B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"secret.d.ts","sourceRoot":"","sources":["../src/secret.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,uBAAuB,EACvB,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,EACrB,qBAAqB,GACtB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,6BAA6B,EAAE,KAAK,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AACpG,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC"}
package/dist/secret.js CHANGED
@@ -1,2 +1,3 @@
1
- export { createSecretFileAtomic, DEFAULT_SECRET_FILE_MAX_BYTES, PRIVATE_SECRET_DIR_MODE, PRIVATE_SECRET_FILE_MODE, readSecretFileSync, tryReadSecretFileSync, writeSecretFileAtomic, } from "./secret-file.js";
1
+ export { createSecretFileAtomic, PRIVATE_SECRET_DIR_MODE, PRIVATE_SECRET_FILE_MODE, readSecretFileSync, tryReadSecretFileSync, writeSecretFileAtomic, } from "./secret-file.js";
2
+ export { DEFAULT_SECRET_FILE_MAX_BYTES } from "./secret-read-policy.js";
2
3
  export { readSecretFile, tryReadSecretFile } from "./secret-read-async.js";
@@ -1 +1 @@
1
- {"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAYrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAgLF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAc/B"}
1
+ {"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAYrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAM1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAyLF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAc/B"}
@@ -4,10 +4,11 @@ import path from "node:path";
4
4
  import { readFileHandleBounded } from "./bounded-read.js";
5
5
  import { assertNoUnsafeDeviceReadPath } from "./device-path.js";
6
6
  import { FsSafeError } from "./errors.js";
7
- import { sameFileIdentity } from "./file-identity.js";
8
7
  import { isWindowsDriveLetterPath, isWindowsNetworkPath } from "./local-file-access.js";
9
8
  import { isPathInside, isSymlinkOpenError } from "./path.js";
9
+ import { formatPermissionErrorDetail } from "./permission-exec.js";
10
10
  import { inspectPathPermissions, isGroupReadable, isGroupWritable, isWorldReadable, isWorldWritable, modeBits, } from "./permissions.js";
11
+ import { inspectFileIdentity } from "./strict-file-identity.js";
11
12
  const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
12
13
  const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
13
14
  function isAbsolutePathname(value) {
@@ -52,20 +53,18 @@ async function openSecureHandle(options) {
52
53
  if (!openedStat.isFile()) {
53
54
  throw new FsSafeError("not-file", `${label(options)} must be a file: ${options.filePath}`);
54
55
  }
55
- const pathStat = options.trust?.allowSymlink
56
- ? await fs.stat(options.filePath)
57
- : await fs.lstat(options.filePath);
58
- if (!options.trust?.allowSymlink && pathStat.isSymbolicLink()) {
59
- throw new FsSafeError("symlink", `${label(options)} must not be a symlink: ${options.filePath}`);
60
- }
61
- if (!sameFileIdentity(pathStat, openedStat)) {
62
- throw new FsSafeError("path-mismatch", `${label(options)} changed during open.`);
63
- }
56
+ const openedIdentity = await inspectFileIdentity(() => handle.stat({ bigint: true }));
57
+ await inspectFileIdentity(async () => {
58
+ const pathStat = options.trust?.allowSymlink
59
+ ? await fs.stat(options.filePath, { bigint: true })
60
+ : await fs.lstat(options.filePath, { bigint: true });
61
+ if (!options.trust?.allowSymlink && pathStat.isSymbolicLink()) {
62
+ throw new FsSafeError("symlink", `${label(options)} must not be a symlink: ${options.filePath}`);
63
+ }
64
+ return pathStat;
65
+ }, openedIdentity);
64
66
  const realPath = await fs.realpath(options.filePath);
65
- const realStat = await fs.stat(realPath);
66
- if (!sameFileIdentity(realStat, openedStat)) {
67
- throw new FsSafeError("path-mismatch", `${label(options)} real path changed during open.`);
68
- }
67
+ await inspectFileIdentity(() => fs.stat(realPath, { bigint: true }), openedIdentity);
69
68
  if (options.io?.maxBytes !== undefined && openedStat.size > options.io.maxBytes) {
70
69
  throw new FsSafeError("too-large", `${label(options)} exceeded maxBytes (${options.io.maxBytes}).`);
71
70
  }
@@ -111,11 +110,21 @@ async function assertSecurePermissions(options, stat, realPath) {
111
110
  const permissions = platform === "win32"
112
111
  ? await inspectPathPermissions(realPath, options.inject)
113
112
  : inspectOpenedPermissions(stat, platform);
113
+ const reason = permissions.error ? `: ${formatPermissionErrorDetail(permissions.error)}` : "";
114
+ const diagnostics = {
115
+ ...(permissions.errorCause !== undefined ? { cause: permissions.errorCause } : {}),
116
+ ...(permissions.ownerError || permissions.errorDetail ? {
117
+ details: {
118
+ ...(permissions.ownerError ? { ownerError: formatPermissionErrorDetail(permissions.ownerError) } : {}),
119
+ ...permissions.errorDetail,
120
+ },
121
+ } : {}),
122
+ };
114
123
  if (!permissions.ok) {
115
- throw new FsSafeError("permission-unverified", `${label(options)} permissions could not be verified: ${realPath}`);
124
+ throw new FsSafeError("permission-unverified", `${label(options)} permissions could not be verified: ${realPath}${reason}`, diagnostics);
116
125
  }
117
126
  if (platform === "win32" && permissions.source === "unknown") {
118
- throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}.`);
127
+ throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}${reason || "."}`, diagnostics);
119
128
  }
120
129
  if (platform === "win32" && permissions.ownerTrusted !== true) {
121
130
  throw new FsSafeError(permissions.ownerTrusted === false ? "not-owned" : "permission-unverified", `${label(options)} owner could not be trusted on Windows: ${realPath}`);
@@ -0,0 +1,16 @@
1
+ import type { DirectoryReceipt } from "./directory-durability.js";
2
+ import type { FileIdentityStat } from "./file-identity.js";
3
+ import type { StagedFileReceipt } from "./staged-file-types.js";
4
+ type DirectorySnapshot = StagedFileReceipt["directory"];
5
+ export declare function exactIdentityMatches(expected: FileIdentityStat, actual: Readonly<{
6
+ dev: bigint;
7
+ ino: bigint;
8
+ }>): boolean;
9
+ export declare function describeStagedDirectory(fd: number, pathname: string): DirectorySnapshot;
10
+ export declare function assertStagedDirectoryCurrent(receipt: DirectorySnapshot): void;
11
+ export declare function openStagedDirectory(directory: string | DirectoryReceipt): {
12
+ fd: number;
13
+ receipt: DirectorySnapshot;
14
+ };
15
+ export {};
16
+ //# sourceMappingURL=staged-directory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"staged-directory.d.ts","sourceRoot":"","sources":["../src/staged-directory.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAElE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAEhE,KAAK,iBAAiB,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;AAExD,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,QAAQ,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,GAC7C,OAAO,CAKT;AAED,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAYvF;AAED,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,iBAAiB,GAAG,IAAI,CAQ7E;AAED,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,GAAG;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,iBAAiB,CAAC;CAC5B,CA+BA"}
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { FsSafeError } from "./errors.js";
4
+ export function exactIdentityMatches(expected, actual) {
5
+ return ["dev", "ino"].every((key) => {
6
+ const value = expected[key];
7
+ return (typeof value === "bigint" || Number.isSafeInteger(value)) && BigInt(value) === actual[key];
8
+ });
9
+ }
10
+ export function describeStagedDirectory(fd, pathname) {
11
+ const identity = fs.fstatSync(fd, { bigint: true });
12
+ if (!identity.isDirectory()) {
13
+ throw new FsSafeError("not-file", "staging parent must be a directory");
14
+ }
15
+ const receipt = Object.freeze({
16
+ path: path.resolve(pathname),
17
+ realPath: fs.realpathSync(pathname),
18
+ identity: Object.freeze({ dev: identity.dev, ino: identity.ino }),
19
+ });
20
+ assertStagedDirectoryCurrent(receipt);
21
+ return receipt;
22
+ }
23
+ export function assertStagedDirectoryCurrent(receipt) {
24
+ const current = fs.lstatSync(receipt.path, { bigint: true });
25
+ if (!current.isDirectory() || !exactIdentityMatches(receipt.identity, current) ||
26
+ fs.realpathSync(receipt.path) !== receipt.realPath) {
27
+ throw new FsSafeError("path-mismatch", "staging directory pathname changed");
28
+ }
29
+ }
30
+ export function openStagedDirectory(directory) {
31
+ // Copy supplied facts before any asynchronous work; receipts are not authority.
32
+ const pathname = path.resolve(typeof directory === "string" ? directory : directory.path);
33
+ const expected = typeof directory === "string" ? undefined : {
34
+ realPath: directory.realPath, dev: directory.identity.dev, ino: directory.identity.ino,
35
+ };
36
+ const before = fs.lstatSync(pathname, { bigint: true });
37
+ if (!before.isDirectory()) {
38
+ throw new FsSafeError("not-file", "staging parent must be a real directory");
39
+ }
40
+ if (expected && (!exactIdentityMatches(expected, before) || fs.realpathSync(pathname) !== expected.realPath)) {
41
+ throw new FsSafeError("path-mismatch", "stale staging directory receipt");
42
+ }
43
+ const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
44
+ try {
45
+ const receipt = describeStagedDirectory(fd, pathname);
46
+ if (!exactIdentityMatches(before, receipt.identity)) {
47
+ throw new FsSafeError("path-mismatch", "staging directory changed while opening");
48
+ }
49
+ return { fd, receipt };
50
+ }
51
+ catch (error) {
52
+ try {
53
+ fs.closeSync(fd);
54
+ }
55
+ catch (closeError) {
56
+ throw new AggregateError([error, closeError], "staging directory admission and close failed");
57
+ }
58
+ throw error;
59
+ }
60
+ }
@@ -0,0 +1,56 @@
1
+ export type StagedFileReceipt = Readonly<{
2
+ directory: Readonly<{
3
+ path: string;
4
+ realPath: string;
5
+ identity: Readonly<{
6
+ dev: bigint;
7
+ ino: bigint;
8
+ }>;
9
+ }>;
10
+ temporaryBasename: string;
11
+ /** Preparation-time metadata of the private stage, not a final-file fingerprint. */
12
+ identity: Readonly<{
13
+ dev: bigint;
14
+ ino: bigint;
15
+ mode: number;
16
+ nlink: bigint;
17
+ size: bigint;
18
+ uid: number;
19
+ gid: number;
20
+ mtimeNs: bigint;
21
+ ctimeNs: bigint;
22
+ }>;
23
+ }>;
24
+ export type PublishedFileReceipt = Readonly<{
25
+ status: "published";
26
+ staged: StagedFileReceipt;
27
+ basename: string;
28
+ overwrite: boolean;
29
+ }>;
30
+ export type StagedFilePublication = Readonly<{
31
+ status: "not-published";
32
+ }> | PublishedFileReceipt | Readonly<{
33
+ status: "indeterminate";
34
+ basename: string;
35
+ overwrite: boolean;
36
+ }>;
37
+ export type StagedFileCleanupReceipt = Readonly<{
38
+ temporaryBasename: string;
39
+ publication: StagedFilePublication;
40
+ status: "removed" | "name-absent" | "preserved" | "failed" | "not-needed";
41
+ resources: "closed" | "close-failed";
42
+ }>;
43
+ export type StagedFileFailureDetails = Readonly<{
44
+ phase: "prepare" | "publish" | "cleanup";
45
+ publication: StagedFilePublication;
46
+ cleanup?: StagedFileCleanupReceipt;
47
+ }>;
48
+ export interface StagedFile extends AsyncDisposable {
49
+ readonly receipt: StagedFileReceipt;
50
+ assertCurrent(): Promise<void>;
51
+ publish(basename: string, options: {
52
+ overwrite: boolean;
53
+ }): Promise<PublishedFileReceipt>;
54
+ cleanup(): Promise<StagedFileCleanupReceipt>;
55
+ }
56
+ //# sourceMappingURL=staged-file-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"staged-file-types.d.ts","sourceRoot":"","sources":["../src/staged-file-types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IACvC,SAAS,EAAE,QAAQ,CAAC;QAClB,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,QAAQ,CAAC;YAAE,GAAG,EAAE,MAAM,CAAC;YAAC,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KAClD,CAAC,CAAC;IACH,iBAAiB,EAAE,MAAM,CAAC;IAC1B,oFAAoF;IACpF,QAAQ,EAAE,QAAQ,CAAC;QACjB,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC,CAAC;CACJ,CAAC,CAAC;AAEH,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C,MAAM,EAAE,WAAW,CAAC;IACpB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;CACpB,CAAC,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAC7B,QAAQ,CAAC;IAAE,MAAM,EAAE,eAAe,CAAA;CAAE,CAAC,GACrC,oBAAoB,GACpB,QAAQ,CAAC;IAAE,MAAM,EAAE,eAAe,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAAC,CAAC;AAEhF,MAAM,MAAM,wBAAwB,GAAG,QAAQ,CAAC;IAC9C,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,qBAAqB,CAAC;IACnC,MAAM,EAAE,SAAS,GAAG,aAAa,GAAG,WAAW,GAAG,QAAQ,GAAG,YAAY,CAAC;IAC1E,SAAS,EAAE,QAAQ,GAAG,cAAc,CAAC;CACtC,CAAC,CAAC;AAEH,MAAM,MAAM,wBAAwB,GAAG,QAAQ,CAAC;IAC9C,KAAK,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;IACzC,WAAW,EAAE,qBAAqB,CAAC;IACnC,OAAO,CAAC,EAAE,wBAAwB,CAAC;CACpC,CAAC,CAAC;AAEH,MAAM,WAAW,UAAW,SAAQ,eAAe;IACjD,QAAQ,CAAC,OAAO,EAAE,iBAAiB,CAAC;IACpC,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAC1F,OAAO,IAAI,OAAO,CAAC,wBAAwB,CAAC,CAAC;CAC9C"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { DirectoryReceipt } from "./directory-durability.js";
2
+ import type { StagedFile } from "./staged-file-types.js";
3
+ export type { PublishedFileReceipt, StagedFile, StagedFileCleanupReceipt, StagedFileFailureDetails, StagedFilePublication, StagedFileReceipt, } from "./staged-file-types.js";
4
+ export declare function stageFileInDirectory(options: {
5
+ directory: string | DirectoryReceipt;
6
+ content: string | Uint8Array;
7
+ /** Published mode; the unpublished stage stays at 0600. Defaults to 0600. */
8
+ mode?: number;
9
+ }): Promise<StagedFile>;
10
+ //# sourceMappingURL=staged-file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"staged-file.d.ts","sourceRoot":"","sources":["../src/staged-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAKlE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAEzD,YAAY,EACV,oBAAoB,EAAE,UAAU,EAAE,wBAAwB,EAAE,wBAAwB,EACpF,qBAAqB,EAAE,iBAAiB,GACzC,MAAM,wBAAwB,CAAC;AAEhC,wBAAsB,oBAAoB,CAAC,OAAO,EAAE;IAClD,SAAS,EAAE,MAAM,GAAG,gBAAgB,CAAC;IACrC,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,UAAU,CAAC,CAUtB"}
@@ -0,0 +1,15 @@
1
+ import { FsSafeError } from "./errors.js";
2
+ import { assertNativeStaging, createNativeStage } from "./native-staged-file.js";
3
+ import { requireNativeBinding } from "./native.js";
4
+ import { openStagedDirectory } from "./staged-directory.js";
5
+ export async function stageFileInDirectory(options) {
6
+ if (process.platform !== "linux" && process.platform !== "darwin") {
7
+ throw new FsSafeError("unsupported-platform", "retained-directory staging requires Linux or macOS");
8
+ }
9
+ const binding = requireNativeBinding();
10
+ assertNativeStaging(binding);
11
+ const input = { kind: "buffer", data: Buffer.from(options.content) };
12
+ const mode = options.mode ?? 0o600;
13
+ const parent = openStagedDirectory(options.directory);
14
+ return await createNativeStage(binding, parent.fd, parent.receipt, input, mode);
15
+ }
@@ -0,0 +1,6 @@
1
+ import type { BigIntStats } from "node:fs";
2
+ type ExactFileIdentity = Pick<BigIntStats, "dev" | "ino">;
3
+ export declare function inspectFileIdentity<T extends ExactFileIdentity>(inspect: () => Promise<T>, expected?: ExactFileIdentity, platform?: NodeJS.Platform): Promise<T>;
4
+ export declare function inspectFileIdentitySync<T extends ExactFileIdentity>(inspect: () => T, expected?: ExactFileIdentity, platform?: NodeJS.Platform): T;
5
+ export {};
6
+ //# sourceMappingURL=strict-file-identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strict-file-identity.d.ts","sourceRoot":"","sources":["../src/strict-file-identity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAG3C,KAAK,iBAAiB,GAAG,IAAI,CAAC,WAAW,EAAE,KAAK,GAAG,KAAK,CAAC,CAAC;AA6B1D,wBAAsB,mBAAmB,CAAC,CAAC,SAAS,iBAAiB,EACnE,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACzB,QAAQ,CAAC,EAAE,iBAAiB,EAC5B,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED,wBAAgB,uBAAuB,CAAC,CAAC,SAAS,iBAAiB,EACjE,OAAO,EAAE,MAAM,CAAC,EAChB,QAAQ,CAAC,EAAE,iBAAiB,EAC5B,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,CAAC,CAOH"}
@@ -0,0 +1,48 @@
1
+ import { FsSafeError } from "./errors.js";
2
+ function identityMismatch() {
3
+ return new FsSafeError("path-mismatch", "file identity changed or could not be verified");
4
+ }
5
+ function identityCheck(expected, platform) {
6
+ const known = {};
7
+ const check = (stat) => {
8
+ let complete = true;
9
+ for (const field of ["dev", "ino"]) {
10
+ const value = stat[field];
11
+ // Numeric receipts cannot recover identity bits already lost to rounding.
12
+ if (typeof value !== "bigint")
13
+ throw identityMismatch();
14
+ if (platform === "win32" && value === 0n) {
15
+ complete = false;
16
+ }
17
+ else {
18
+ if (known[field] !== undefined && known[field] !== value)
19
+ throw identityMismatch();
20
+ known[field] = value;
21
+ }
22
+ }
23
+ return complete;
24
+ };
25
+ if (expected && !check(expected))
26
+ throw identityMismatch();
27
+ return check;
28
+ }
29
+ // Retry only unknown Windows identities, retaining every known component so a
30
+ // later observation cannot erase a definite mismatch. Never reopen the file.
31
+ export async function inspectFileIdentity(inspect, expected, platform = process.platform) {
32
+ const check = identityCheck(expected, platform);
33
+ for (let attempt = 0; attempt < 2; attempt++) {
34
+ const stat = await inspect();
35
+ if (check(stat))
36
+ return stat;
37
+ }
38
+ throw identityMismatch();
39
+ }
40
+ export function inspectFileIdentitySync(inspect, expected, platform = process.platform) {
41
+ const check = identityCheck(expected, platform);
42
+ for (let attempt = 0; attempt < 2; attempt++) {
43
+ const stat = inspect();
44
+ if (check(stat))
45
+ return stat;
46
+ }
47
+ throw identityMismatch();
48
+ }
@@ -1,3 +1,4 @@
1
+ import { type PermissionCommandFailure } from "./permission-exec.js";
1
2
  export type WindowsOwnerExec = (command: string, args: string[]) => Promise<{
2
3
  stdout: string;
3
4
  stderr: string;
@@ -10,6 +11,8 @@ export type WindowsOwnerSummary = {
10
11
  remote?: boolean;
11
12
  trusted?: boolean;
12
13
  error?: string;
14
+ errorDetail?: PermissionCommandFailure;
15
+ errorCause?: unknown;
13
16
  };
14
17
  export declare function resolveWindowsPrincipalSids(params: {
15
18
  principals: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"windows-owner.d.ts","sourceRoot":"","sources":["../src/windows-owner.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,gBAAgB,GAAG,CAC7B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,KACX,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEjD,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA4DF,wBAAsB,2BAA2B,CAAC,MAAM,EAAE;IACxD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAyBlC;AAED,wBAAsB,4BAA4B,CAAC,MAAM,EAAE;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAWzB;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CA2C/B"}
1
+ {"version":3,"file":"windows-owner.d.ts","sourceRoot":"","sources":["../src/windows-owner.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,sBAAsB,CAAC;AAG9B,MAAM,MAAM,gBAAgB,GAAG,CAC7B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,KACX,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEjD,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,wBAAwB,CAAC;IACvC,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AA4DF,wBAAsB,2BAA2B,CAAC,MAAM,EAAE;IACxD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAyBlC;AAED,wBAAsB,4BAA4B,CAAC,MAAM,EAAE;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAWzB;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAkD/B"}
@@ -1,3 +1,4 @@
1
+ import { formatPermissionErrorDetail, getPermissionCommandFailure, } from "./permission-exec.js";
1
2
  import { resolveWindowsSystemCommand } from "./windows-command.js";
2
3
  const SID_RE = /^\*?s-\d+-\d+(-\d+)+$/i;
3
4
  const TRUSTED_OWNER_SIDS = new Set(["s-1-5-18", "s-1-5-32-544"]);
@@ -83,8 +84,11 @@ export async function resolveWindowsCurrentUserSid(params) {
83
84
  }
84
85
  }
85
86
  export async function inspectWindowsOwner(params) {
87
+ let command = "";
88
+ let startedAt = performance.now();
86
89
  try {
87
- const command = resolveWindowsSystemCommand(String.raw `WindowsPowerShell\v1.0\powershell.exe`, params.env);
90
+ command = resolveWindowsSystemCommand(String.raw `WindowsPowerShell\v1.0\powershell.exe`, params.env);
91
+ startedAt = performance.now();
88
92
  const { stdout } = await params.exec(command, [
89
93
  "-NoLogo",
90
94
  "-NoProfile",
@@ -113,6 +117,10 @@ export async function inspectWindowsOwner(params) {
113
117
  };
114
118
  }
115
119
  catch (err) {
116
- return { error: String(err) };
120
+ return {
121
+ error: formatPermissionErrorDetail(String(err)),
122
+ errorDetail: getPermissionCommandFailure(err, command, performance.now() - startedAt),
123
+ errorCause: err,
124
+ };
117
125
  }
118
126
  }
package/docs/advanced.md CHANGED
@@ -26,13 +26,22 @@ The exports group into a handful of themes. Each documented helper has its own p
26
26
  | Export | Page | Notes |
27
27
  |---|---|---|
28
28
  | `pathScope`, `PathScope`, `PathScopeOptions`, `PathScopeResolveOptions` | [path-scope.md](path-scope.md) | Absolute-path boundary helper with `Result`-shaped returns. |
29
- | `ensureDirectoryWithinRoot` | | Create a directory while enforcing the root boundary. |
29
+ | `ensureDirectoryWithinRoot` | [path-scope.md](path-scope.md#ensuredir-rel-options) | Create a directory while enforcing the root boundary; same result contract as `pathScope().ensureDir()`. |
30
30
  | `resolvePathWithinRoot`, `resolvePathsWithinRoot` | – | Resolve one or many relative paths against a trusted root. |
31
31
  | `resolveExistingPathsWithinRoot`, `resolveStrictExistingPathsWithinRoot` | – | Same, but require the targets to exist. |
32
32
  | `resolveWritablePathWithinRoot` | – | Resolve a write target inside a root. |
33
33
  | `resolveRootPath`, `resolveRootPathSync`, `ResolvedRootPath`, `ROOT_PATH_ALIAS_POLICIES`, `RootPathAliasPolicy` | – | Resolve a root directory honoring alias policy. |
34
34
  | `resolvePathViaExistingAncestorSync` | – | Walk to an existing ancestor for paths whose tail does not yet exist. |
35
35
 
36
+ `ensureDirectoryWithinRoot({ rootDir, requestedPath, scopeLabel, defaultDirName?, mode? })`
37
+ returns `{ ok: true, path }` or `{ ok: false, error: string, diagnostic?: FsSafeError }`.
38
+ It does not throw filesystem failures: operational failures carry a
39
+ `helper-failed` diagnostic with the original `cause` and a bounded, escaped
40
+ message naming the native code/syscall when available. Policy failures omit
41
+ `diagnostic`. Missing parents already created before a failure remain in place.
42
+ The diagnostic contract is specific to directory preparation, not the other
43
+ root-path result helpers.
44
+
36
45
  ### Absolute path helpers
37
46
 
38
47
  | Export | Page | Notes |
@@ -107,6 +116,7 @@ component is followed by another segment, both helpers throw
107
116
 
108
117
  | Export | Page | Notes |
109
118
  |---|---|---|
119
+ | `stageFileInDirectory`, `StagedFile`, `StagedFileReceipt`, `PublishedFileReceipt`, `StagedFilePublication`, `StagedFileCleanupReceipt`, `StagedFileFailureDetails` | [staged-file.md](staged-file.md) | Native-required Linux/macOS lifecycle retaining the original directory for abort cleanup. |
110
120
  | `tempFile`, `withTempFile`, `TempFile`, `buildRandomTempFilePath`, `sanitizeTempFileName` | [temp.md](temp.md) | One-file temp primitive; prefer `tempWorkspace` from `@openclaw/fs-safe/temp` for the stable surface. |
111
121
  | `writeSiblingTempFile`, `writeViaSiblingTempPath`, `WriteSiblingTempFileOptions`, `WriteSiblingTempFileResult` | – | Sibling-temp write building block used by `replaceFileAtomic`. |
112
122
 
package/docs/archive.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Archive extraction
2
2
 
3
- `@openclaw/fs-safe/archive` extracts ZIP and TAR archives behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets. When the bundled native binding is available for the current platform, Rust streams ZIP, TAR, gzip, zstd, and bzip2 while TypeScript remains the sole policy owner; every accepted output is created fd-relative in a private staging root. Extraction then merges through the same safe-open boundary used by direct writes — a symlinked entry can't trick the merge into following an out-of-tree path.
3
+ `@openclaw/fs-safe/archive` extracts ZIP and TAR archives behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets. When the native binding is available for the current platform, Rust streams ZIP, TAR, gzip, zstd, and bzip2 while TypeScript remains the sole policy owner; every accepted output is created fd-relative in a private staging root. Extraction then merges through the same safe-open boundary used by direct writes — a symlinked entry can't trick the merge into following an out-of-tree path.
4
4
 
5
5
  The guarded JavaScript fallback uses optional runtime dependencies: `jszip` for
6
6
  ZIP and `tar` for TAR/gzip. The native path does not use those packages. Installs
@@ -72,8 +72,19 @@ accepted-entry plan back to Rust. Rust only performs decompression and the
72
72
  fd-relative `mkdirBeneath`/exclusive-open writes. This keeps policy identical
73
73
  between native and JavaScript paths rather than reimplementing it in Rust.
74
74
 
75
- An `entryFilter` sees the validated archive path, entry kind, and declared
76
- size. Returning `"skip"` rejects the whole archive unless `onFiltered` is
75
+ ZIP extraction and bounded reads admit every physical central-directory record and its referenced local header before either decoder can normalize or collapse names. Raw names and valid Unicode Path names must pass traversal checks before stripping, filtering, or selecting a requested member; duplicate or colliding names reject with `entry-path`, even in unrelated or skipped members. Materially conflicting local/central or Unicode interpretations, malformed critical metadata, and ambiguous framing reject with `ArchiveFormatError`. Harmless separator and dot-component equivalence is allowed only after validation. Ordinary legacy filename decoding remains backend-selected.
76
+
77
+ `stripComponents` removes leading nonempty, non-`.` path components after
78
+ normalizing separators. For example, `./pkg/hello.txt` with
79
+ `stripComponents: 1` extracts to `hello.txt` on both backends. Entries with no
80
+ remaining components are skipped before the filter callback, but still count
81
+ toward `maxEntries` and undergo traversal validation. JavaScript TAR extraction
82
+ passes node-tar this accepted output path with its own stripping disabled, so
83
+ depth checks, collision checks, writes, and mode application agree.
84
+
85
+ An `entryFilter` sees the validated effective archive path **before stripping**
86
+ (including a local PAX `path` override), entry kind, and declared size.
87
+ Returning `"skip"` rejects the whole archive unless `onFiltered` is
77
88
  explicitly `"skip-entry"`. Path traversal and archive-wide entry-count checks
78
89
  still apply to skipped entries.
79
90
 
@@ -149,22 +160,58 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and
149
160
  ## What it defends against
150
161
 
151
162
  - **Path traversal:** entries with `..`, absolute paths, NUL bytes, or Windows drive-relative segments such as `C:secret` and `nested/C:secret` are rejected (`ArchiveSecurityError`). On Windows, path segments containing `:` are also rejected as alternate data stream names before either backend writes to the filesystem.
152
- - **Symlink/hardlink entries:** rejected by default. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
163
+ - **Symlink/hardlink entries:** rejected by default, including ZIP entries whose Unix mode says symlink while their name ends in a slash or their DOS directory bit is set. An explicit `entryFilter` with `onFiltered: "skip-entry"` can omit these entries. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
153
164
  - **Ambiguous output names:** duplicate names and distinct names that collide after `stripComponents`, case normalization, or Unicode normalization are rejected instead of relying on backend- or volume-specific overwrite order.
154
165
  - **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. Destination symlink swaps are checked with the selected platform mechanism; non-Linux routes retain the best-effort race window documented in the [security model](security-model.md#containment-guarantees-by-platform).
155
166
  - **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk.
156
167
  - **Corrupt ZIP payloads:** streamed output must match both the central-directory CRC and declared uncompressed size before it can leave private staging.
157
168
  - **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget. Extraction is aborted on overrun.
158
- - **Metadata bombs:** a fixed-header pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before either TAR implementation buffers them. It understands octal and base-256 size fields without interpreting metadata content.
159
-
160
- PAX headers can override the next entry's size from inside their content. The
161
- fixed-header meter deliberately never interprets that content, so PAX and GNU
162
- sparse entries are rejected with
163
- `ArchiveFormatError("archive-header-invalid")` rather than guessing. GNU sparse
164
- extension blocks are still metered in 512-byte units before rejection, ensuring
165
- malformed or excessive chains cannot bypass the metadata ceiling. GNU long-name
166
- and long-link entries remain supported because their fixed header size fully
167
- determines their layout.
169
+ - **Metadata bombs:** a streaming pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before either TAR implementation buffers them. It understands octal and base-256 fixed sizes and validates bounded local PAX bodies before using their size overrides for member framing. Original archive bytes remain unchanged.
170
+
171
+ ### Bounded local PAX support
172
+
173
+ Extraction and single-entry reads accept one nonempty local POSIX `x` header
174
+ (USTAR or GNU header format) immediately before one regular/contiguous file,
175
+ directory, symlink, or hardlink. `path`, `linkpath`, and `size` override that
176
+ member only. Effective paths still pass traversal validation before stripping,
177
+ then the output paths pass depth and collision checks. The filter receives the
178
+ effective pre-strip path, followed by link policy checks. PAX never permits link
179
+ creation. Effective sizes drive framing, filters, and the existing output-byte
180
+ budgets; `maxEntries` still counts members, not their metadata headers.
181
+
182
+ Records must have exact byte lengths, ASCII keys, a final newline, and no
183
+ duplicate keys, embedded newlines, or unconsumed bytes. Structural `path` and
184
+ `linkpath` values and ownership names must be nonempty printable ASCII. A PAX
185
+ member's raw name, USTAR prefix, and raw link target must also be printable
186
+ ASCII; raw link targets must be present only on links, even when overridden.
187
+ Unicode
188
+ PAX structural text is deliberately unsupported because the underlying parsers
189
+ do not agree when UTF-8 is split across input chunks. `size`, `uid`, and `gid`
190
+ must be canonical unsigned decimal safe integers (zero is valid; signs, leading
191
+ zeros, fractions, and exponents are not). Padded member sizes must also fit the
192
+ safe integer range. Raw and effective directory/link sizes must both be zero;
193
+ non-directory paths ending with a separator and `linkpath` on non-links are
194
+ rejected rather than allowing parser-specific type or framing changes.
195
+
196
+ The descriptive allowlist is `mtime`, `atime`, `ctime` (signed decimal seconds
197
+ with optional fractional digits, within JavaScript's Date range), `uid`, `gid`,
198
+ `uname`, and `gname`. These attributes are accepted but not restored to the
199
+ destination. `LIBARCHIVE.xattr.*` and `SCHILY.xattr.*` with nonempty ASCII
200
+ alphanumeric/dot/underscore/hyphen suffixes are also accepted as inert metadata,
201
+ never restored as extended attributes. Their values are byte-counted and may
202
+ contain NUL or non-UTF8 bytes, including macOS provenance metadata; embedded
203
+ newlines are rejected because they can disrupt downstream record parsing.
204
+
205
+ Global `g`, old `X`, old GNU `N`, empty/dangling/repeated local headers, mixed
206
+ PAX/GNU extension chains, unknown keys, charset declarations, ACL extensions,
207
+ and all sparse extensions (including `GNU.sparse.*`, `SCHILY.filetype`,
208
+ `SCHILY.realsize`, and `SCHILY.size`) fail closed with
209
+ `ArchiveFormatError("archive-header-invalid")`. Standalone GNU long-name `L`
210
+ and long-link `K` support is unchanged. GNU sparse extension blocks are still
211
+ metered in 512-byte units before rejection, preserving metadata-limit errors
212
+ for excessive chains. The per-body `maxMetaEntryBytes` limit bounds PAX storage
213
+ and duplicate-key state; one local header per member prevents local metadata
214
+ chains without introducing a new limit or changing defaults.
168
215
 
169
216
  ## `resolveArchiveKind`
170
217
 
@@ -257,7 +304,7 @@ import {
257
304
 
258
305
  - `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths, including alternate data stream names on Windows.
259
306
  - `normalizeArchiveEntryPath(raw)` — converts backslashes in the entry path to forward slashes.
260
- - `stripArchivePath(entryPath, n)` — strip the leading N path components, returning `null` if not enough remain.
307
+ - `stripArchivePath(entryPath, n)` — normalize separators, drop empty and `.` components, then strip the leading N components, returning `null` if none remain.
261
308
  - `resolveArchiveOutputPath({ destDir, entryPath })` — combines the entry path with the destination, after validation.
262
309
  - `isWindowsDrivePath(value)` — detects drive-relative segments such as `C:secret` or `nested/C:secret` that should be rejected.
263
310
 
package/docs/config.md CHANGED
@@ -78,6 +78,13 @@ configureFsSafeLocks({
78
78
  });
79
79
  ```
80
80
 
81
+ These defaults apply to both `acquireFileLock()` / `withFileLock()` and
82
+ `acquireFileLockSync()` / `withFileLockSync()`. Each acquisition resolves
83
+ `retry`, `staleMs`, `staleRecovery`, and `timeoutMs` from the per-call option
84
+ first, then the process configuration, then the package default. Explicit zero
85
+ values are preserved. A per-call `retry` object replaces the configured object
86
+ as a whole; omitted retry fields use package defaults, not configured fields.
87
+
81
88
  Individual lock calls can override any default. Switching the global stale
82
89
  recovery mode does not provide the application-owned liveness proof required
83
90
  by `shouldRemoveStaleLock`.