@openclaw/fs-safe 0.8.1 → 0.8.3

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 (195) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -2
  3. package/dist/advanced.d.ts +2 -2
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +2 -2
  6. package/dist/archive-entry.d.ts.map +1 -1
  7. package/dist/archive-entry.js +5 -0
  8. package/dist/archive-gzip-tail.d.ts +18 -0
  9. package/dist/archive-gzip-tail.d.ts.map +1 -0
  10. package/dist/archive-gzip-tail.js +74 -0
  11. package/dist/archive-kind.d.ts +1 -0
  12. package/dist/archive-kind.d.ts.map +1 -1
  13. package/dist/archive-kind.js +6 -0
  14. package/dist/archive-limits.d.ts +0 -1
  15. package/dist/archive-limits.d.ts.map +1 -1
  16. package/dist/archive-limits.js +0 -3
  17. package/dist/archive-native.d.ts.map +1 -1
  18. package/dist/archive-native.js +5 -8
  19. package/dist/archive-parser-errors.d.ts +2 -0
  20. package/dist/archive-parser-errors.d.ts.map +1 -0
  21. package/dist/archive-parser-errors.js +12 -0
  22. package/dist/archive-parser.wasm +0 -0
  23. package/dist/archive-read.d.ts.map +1 -1
  24. package/dist/archive-read.js +25 -73
  25. package/dist/archive-tar-stream.d.ts +18 -0
  26. package/dist/archive-tar-stream.d.ts.map +1 -0
  27. package/dist/archive-tar-stream.js +95 -0
  28. package/dist/archive-tar-wasm.d.ts +18 -0
  29. package/dist/archive-tar-wasm.d.ts.map +1 -0
  30. package/dist/archive-tar-wasm.js +99 -0
  31. package/dist/archive-tar.d.ts +0 -1
  32. package/dist/archive-tar.d.ts.map +1 -1
  33. package/dist/archive-tar.js +0 -22
  34. package/dist/archive.d.ts.map +1 -1
  35. package/dist/archive.js +44 -114
  36. package/dist/atomic.d.ts +1 -1
  37. package/dist/atomic.d.ts.map +1 -1
  38. package/dist/atomic.js +1 -1
  39. package/dist/bounded-read-stream.d.ts +1 -5
  40. package/dist/bounded-read-stream.d.ts.map +1 -1
  41. package/dist/bounded-read-stream.js +5 -13
  42. package/dist/byte-budget.d.ts +0 -1
  43. package/dist/byte-budget.d.ts.map +1 -1
  44. package/dist/byte-budget.js +1 -1
  45. package/dist/device-path.d.ts +1 -0
  46. package/dist/device-path.d.ts.map +1 -1
  47. package/dist/device-path.js +5 -2
  48. package/dist/directory-mode-owner.d.ts.map +1 -1
  49. package/dist/directory-mode-owner.js +5 -0
  50. package/dist/error-detail.d.ts +1 -0
  51. package/dist/error-detail.d.ts.map +1 -1
  52. package/dist/error-detail.js +6 -0
  53. package/dist/file-store-boundary.d.ts +1 -1
  54. package/dist/file-store-boundary.d.ts.map +1 -1
  55. package/dist/file-store-boundary.js +2 -13
  56. package/dist/file-store.js +1 -1
  57. package/dist/file-sync.d.ts +5 -0
  58. package/dist/file-sync.d.ts.map +1 -0
  59. package/dist/file-sync.js +19 -0
  60. package/dist/guarded-mutation.d.ts +3 -0
  61. package/dist/guarded-mutation.d.ts.map +1 -1
  62. package/dist/guarded-mutation.js +5 -1
  63. package/dist/home-dir.d.ts +0 -10
  64. package/dist/home-dir.d.ts.map +1 -1
  65. package/dist/home-dir.js +0 -28
  66. package/dist/json-durable-queue-ownership.d.ts +1 -0
  67. package/dist/json-durable-queue-ownership.d.ts.map +1 -1
  68. package/dist/json-durable-queue-ownership.js +139 -18
  69. package/dist/json-durable-queue.d.ts.map +1 -1
  70. package/dist/json-durable-queue.js +5 -10
  71. package/dist/json-store.d.ts.map +1 -1
  72. package/dist/json-store.js +1 -1
  73. package/dist/local-roots.d.ts.map +1 -1
  74. package/dist/local-roots.js +2 -3
  75. package/dist/move-path-cleanup.d.ts +1 -1
  76. package/dist/move-path-cleanup.d.ts.map +1 -1
  77. package/dist/move-path-cleanup.js +4 -2
  78. package/dist/move-path.d.ts +9 -0
  79. package/dist/move-path.d.ts.map +1 -1
  80. package/dist/move-path.js +53 -16
  81. package/dist/native-operations.d.ts +0 -1
  82. package/dist/native-operations.d.ts.map +1 -1
  83. package/dist/native-operations.js +0 -10
  84. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  85. package/dist/native-pinned-write-windows.js +5 -4
  86. package/dist/native-staged-file.d.ts +8 -1
  87. package/dist/native-staged-file.d.ts.map +1 -1
  88. package/dist/native-staged-file.js +30 -9
  89. package/dist/opened-realpath.d.ts +12 -1
  90. package/dist/opened-realpath.d.ts.map +1 -1
  91. package/dist/opened-realpath.js +8 -7
  92. package/dist/output.d.ts.map +1 -1
  93. package/dist/output.js +26 -2
  94. package/dist/path-policy.js +1 -1
  95. package/dist/permissions.d.ts +1 -0
  96. package/dist/permissions.d.ts.map +1 -1
  97. package/dist/permissions.js +3 -0
  98. package/dist/pinned-write.d.ts +1 -2
  99. package/dist/pinned-write.d.ts.map +1 -1
  100. package/dist/pinned-write.js +2 -11
  101. package/dist/private-temp-workspace.js +1 -1
  102. package/dist/publish-file.js +2 -2
  103. package/dist/replace-file-copy-source.d.ts.map +1 -1
  104. package/dist/replace-file-copy-source.js +2 -7
  105. package/dist/replace-file-descriptor.d.ts.map +1 -1
  106. package/dist/replace-file-descriptor.js +5 -17
  107. package/dist/replace-file-temp-owner.d.ts.map +1 -1
  108. package/dist/replace-file-temp-owner.js +2 -7
  109. package/dist/replace-file.d.ts.map +1 -1
  110. package/dist/replace-file.js +21 -8
  111. package/dist/root-errors.d.ts +1 -0
  112. package/dist/root-errors.d.ts.map +1 -1
  113. package/dist/root-errors.js +9 -0
  114. package/dist/root-file.d.ts.map +1 -1
  115. package/dist/root-file.js +17 -28
  116. package/dist/root-impl.d.ts.map +1 -1
  117. package/dist/root-impl.js +12 -33
  118. package/dist/root-path.d.ts.map +1 -1
  119. package/dist/root-path.js +49 -94
  120. package/dist/root-write-mode.d.ts +6 -0
  121. package/dist/root-write-mode.d.ts.map +1 -0
  122. package/dist/root-write-mode.js +60 -0
  123. package/dist/root-write-verification.js +2 -2
  124. package/dist/safe-path-segment.d.ts +1 -0
  125. package/dist/safe-path-segment.d.ts.map +1 -1
  126. package/dist/safe-path-segment.js +1 -1
  127. package/dist/secret-file.d.ts.map +1 -1
  128. package/dist/secret-file.js +4 -9
  129. package/dist/secret-read-async.d.ts.map +1 -1
  130. package/dist/secret-read-async.js +5 -10
  131. package/dist/secret-read-policy.d.ts +4 -0
  132. package/dist/secret-read-policy.d.ts.map +1 -1
  133. package/dist/secret-read-policy.js +12 -0
  134. package/dist/sibling-staged-file.d.ts.map +1 -1
  135. package/dist/sibling-staged-file.js +2 -7
  136. package/dist/sidecar-lock-policy.d.ts +0 -1
  137. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  138. package/dist/sidecar-lock-policy.js +0 -4
  139. package/dist/string-coerce.d.ts +0 -9
  140. package/dist/string-coerce.d.ts.map +1 -1
  141. package/dist/string-coerce.js +0 -56
  142. package/dist/temp-target.d.ts.map +1 -1
  143. package/dist/temp-target.js +1 -12
  144. package/docs/archive.md +66 -61
  145. package/docs/atomic.md +53 -19
  146. package/docs/contributing.md +35 -1
  147. package/docs/install.md +1 -1
  148. package/docs/native-helper.md +5 -0
  149. package/docs/native.md +16 -10
  150. package/docs/secret-file.md +5 -0
  151. package/docs/staged-file.md +6 -2
  152. package/docs/writing.md +5 -0
  153. package/package.json +19 -16
  154. package/dist/archive-tar-admission.d.ts +0 -7
  155. package/dist/archive-tar-admission.d.ts.map +0 -1
  156. package/dist/archive-tar-admission.js +0 -43
  157. package/dist/archive-tar-gnu.d.ts +0 -2
  158. package/dist/archive-tar-gnu.d.ts.map +0 -1
  159. package/dist/archive-tar-gnu.js +0 -20
  160. package/dist/archive-tar-header.d.ts +0 -8
  161. package/dist/archive-tar-header.d.ts.map +0 -1
  162. package/dist/archive-tar-header.js +0 -47
  163. package/dist/archive-tar-meta.d.ts +0 -34
  164. package/dist/archive-tar-meta.d.ts.map +0 -1
  165. package/dist/archive-tar-meta.js +0 -277
  166. package/dist/archive-tar-pax.d.ts +0 -8
  167. package/dist/archive-tar-pax.d.ts.map +0 -1
  168. package/dist/archive-tar-pax.js +0 -100
  169. package/dist/archive-tar-runtime.d.ts +0 -49
  170. package/dist/archive-tar-runtime.d.ts.map +0 -1
  171. package/dist/archive-tar-runtime.js +0 -22
  172. package/dist/fsync.d.ts +0 -2
  173. package/dist/fsync.d.ts.map +0 -1
  174. package/dist/fsync.js +0 -1
  175. package/dist/json-durable-queue-retirement.d.ts +0 -9
  176. package/dist/json-durable-queue-retirement.d.ts.map +0 -1
  177. package/dist/json-durable-queue-retirement.js +0 -126
  178. package/dist/json-durable-queue-transfer-lock.d.ts +0 -2
  179. package/dist/json-durable-queue-transfer-lock.d.ts.map +0 -1
  180. package/dist/json-durable-queue-transfer-lock.js +0 -19
  181. package/dist/mode.d.ts +0 -2
  182. package/dist/mode.d.ts.map +0 -1
  183. package/dist/mode.js +0 -3
  184. package/dist/output-sibling.d.ts +0 -8
  185. package/dist/output-sibling.d.ts.map +0 -1
  186. package/dist/output-sibling.js +0 -30
  187. package/dist/read-error.d.ts +0 -2
  188. package/dist/read-error.d.ts.map +0 -1
  189. package/dist/read-error.js +0 -11
  190. package/dist/short-path.d.ts +0 -2
  191. package/dist/short-path.d.ts.map +0 -1
  192. package/dist/short-path.js +0 -7
  193. package/dist/staged-file.d.ts +0 -10
  194. package/dist/staged-file.d.ts.map +0 -1
  195. package/dist/staged-file.js +0 -15
@@ -1,20 +1,13 @@
1
1
  import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import { readFileHandleBounded } from "./bounded-read.js";
4
- import { normalizeMaxBytes } from "./byte-budget.js";
4
+ import { assertNoUnsafeDeviceReadPath } from "./device-path.js";
5
5
  import { FsSafeError } from "./errors.js";
6
- import { resolveHomeRelativePath } from "./home-dir.js";
7
6
  import { resolveReadOpenFlags } from "./read-open-flags.js";
8
7
  import { inspectFileIdentity } from "./strict-file-identity.js";
9
- import { assertSecretFilePreview, DEFAULT_SECRET_FILE_MAX_BYTES, secretPathErrorCode, secretReadError, trimSecretFileContent, } from "./secret-read-policy.js";
8
+ import { assertSecretFilePreview, resolveSecretReadPolicy, secretPathErrorCode, secretReadError, trimSecretFileContent, } from "./secret-read-policy.js";
10
9
  export async function readSecretFile(filePath, label, options = {}) {
11
- const resolvedPath = resolveHomeRelativePath(filePath.trim());
12
- if (!resolvedPath) {
13
- throw new FsSafeError("invalid-path", `${label} file path is empty.`, { cause: undefined });
14
- }
15
- const maxBytes = normalizeMaxBytes(options.maxBytes, {
16
- defaultValue: DEFAULT_SECRET_FILE_MAX_BYTES,
17
- });
10
+ const { resolvedPath, maxBytes } = resolveSecretReadPolicy(filePath, label, options);
18
11
  async function inspectInput(symlinkMessage) {
19
12
  const stat = options.rejectSymlink
20
13
  ? await fs.lstat(resolvedPath, { bigint: true })
@@ -26,6 +19,7 @@ export async function readSecretFile(filePath, label, options = {}) {
26
19
  }
27
20
  let previewStat;
28
21
  try {
22
+ assertNoUnsafeDeviceReadPath(resolvedPath);
29
23
  previewStat = await inspectFileIdentity(() => inspectInput(`${label} file at ${resolvedPath} must not be a symlink.`));
30
24
  }
31
25
  catch (error) {
@@ -36,6 +30,7 @@ export async function readSecretFile(filePath, label, options = {}) {
36
30
  let raw;
37
31
  try {
38
32
  const realPath = await fs.realpath(resolvedPath);
33
+ assertNoUnsafeDeviceReadPath(realPath);
39
34
  handle = await fs.open(realPath, resolveReadOpenFlags());
40
35
  const openedHandle = handle;
41
36
  const openedStat = await inspectFileIdentity(async () => {
@@ -10,4 +10,8 @@ export declare function secretPathErrorCode(error: unknown): FsSafeErrorCode;
10
10
  export declare function secretReadError(code: FsSafeErrorCode, action: "inspect" | "read", label: string, resolvedPath: string, error: unknown): FsSafeError;
11
11
  export declare function assertSecretFilePreview(stat: BigIntStats, label: string, resolvedPath: string, maxBytes: number, rejectHardlinks: boolean): void;
12
12
  export declare function trimSecretFileContent(raw: string, label: string, resolvedPath: string): string;
13
+ export declare function resolveSecretReadPolicy(filePath: string, label: string, options: SecretFileReadOptions): {
14
+ resolvedPath: string;
15
+ maxBytes: number;
16
+ };
13
17
  //# sourceMappingURL=secret-read-policy.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"secret-read-policy.d.ts","sourceRoot":"","sources":["../src/secret-read-policy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAEhE,eAAO,MAAM,6BAA6B,QAAY,CAAC;AAEvD,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,eAAe,CAGnE;AAED,wBAAgB,eAAe,CAC7B,IAAI,EAAE,eAAe,EACrB,MAAM,EAAE,SAAS,GAAG,MAAM,EAC1B,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,OAAO,GACb,WAAW,CAGb;AAED,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,WAAW,EACjB,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,OAAO,GACvB,IAAI,CAUN;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAM9F"}
1
+ {"version":3,"file":"secret-read-policy.d.ts","sourceRoot":"","sources":["../src/secret-read-policy.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAEhE,eAAO,MAAM,6BAA6B,QAAY,CAAC;AAEvD,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,eAAe,CAGnE;AAED,wBAAgB,eAAe,CAC7B,IAAI,EAAE,eAAe,EACrB,MAAM,EAAE,SAAS,GAAG,MAAM,EAC1B,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,OAAO,GACb,WAAW,CAGb;AAED,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,WAAW,EACjB,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,OAAO,GACvB,IAAI,CAUN;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAM9F;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,qBAAqB,GAAG;IACxG,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB,CASA"}
@@ -1,3 +1,5 @@
1
+ import { normalizeMaxBytes } from "./byte-budget.js";
2
+ import { resolveHomeRelativePath } from "./home-dir.js";
1
3
  import { FsSafeError } from "./errors.js";
2
4
  export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
3
5
  export function secretPathErrorCode(error) {
@@ -26,3 +28,13 @@ export function trimSecretFileContent(raw, label, resolvedPath) {
26
28
  }
27
29
  return secret;
28
30
  }
31
+ export function resolveSecretReadPolicy(filePath, label, options) {
32
+ const resolvedPath = resolveHomeRelativePath(filePath.trim());
33
+ if (!resolvedPath) {
34
+ throw new FsSafeError("invalid-path", `${label} file path is empty.`, { cause: undefined });
35
+ }
36
+ const maxBytes = normalizeMaxBytes(options.maxBytes, {
37
+ defaultValue: DEFAULT_SECRET_FILE_MAX_BYTES,
38
+ });
39
+ return { resolvedPath, maxBytes };
40
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"sibling-staged-file.d.ts","sourceRoot":"","sources":["../src/sibling-staged-file.ts"],"names":[],"mappings":"AAkCA,wBAAsB,oBAAoB,CAAC,CAAC,EAAE,MAAM,EAAE;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,gBAAgB,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,OAAO,CAAC;IACtB,aAAa,EAAE,OAAO,CAAC;CACxB,GAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,CAAA;CAAE,CAAC,CA6G3C"}
1
+ {"version":3,"file":"sibling-staged-file.d.ts","sourceRoot":"","sources":["../src/sibling-staged-file.ts"],"names":[],"mappings":"AAmCA,wBAAsB,oBAAoB,CAAC,CAAC,EAAE,MAAM,EAAE;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,gBAAgB,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,OAAO,CAAC;IACtB,aAAa,EAAE,OAAO,CAAC;CACxB,GAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,CAAA;CAAE,CAAC,CAyG3C"}
@@ -1,3 +1,4 @@
1
+ import { syncFileBestEffort } from "./file-sync.js";
1
2
  import fsSync, {} from "node:fs";
2
3
  import fs, {} from "node:fs/promises";
3
4
  import path from "node:path";
@@ -92,13 +93,7 @@ export async function writeCallbackSibling(params) {
92
93
  }
93
94
  if (params.syncTempFile) {
94
95
  await assertCurrent(params.tempPath);
95
- try {
96
- await handle.sync();
97
- }
98
- catch (error) {
99
- if (error?.code !== "EPERM")
100
- throw error;
101
- }
96
+ await syncFileBestEffort(handle);
102
97
  }
103
98
  await assertCurrent(params.tempPath);
104
99
  await fs.rename(params.tempPath, filePath);
@@ -4,7 +4,6 @@ export declare function validateSidecarLockTimeoutMs(timeoutMs: number | undefin
4
4
  export declare function computeSidecarLockDelayMs(retry: SidecarLockRetryOptions, attempt: number): number;
5
5
  export declare const maxTransientLockDenials = 8;
6
6
  export declare function isTransientLockFileDenial(error: unknown, lockPath: string): boolean;
7
- export declare function sidecarLockPayloadIsStale(payload: unknown, staleMs: number, nowMs: number): boolean;
8
7
  export declare function sidecarLockPayloadCreatedAtMs(payload: unknown): number | null;
9
8
  export declare function defaultSidecarLockShouldReclaim(params: {
10
9
  lockPath: string;
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock-policy.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-policy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAQvE,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,uBAAuB,GAAG,IAAI,CAkBpF;AAED,wBAAgB,4BAA4B,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAGhF;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,uBAAuB,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAQjG;AASD,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGnF;AAED,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,OAAO,CAGT;AAED,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAU7E;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,OAAO,CAAC,CAQnB"}
1
+ {"version":3,"file":"sidecar-lock-policy.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-policy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAQvE,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,uBAAuB,GAAG,IAAI,CAkBpF;AAED,wBAAgB,4BAA4B,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAGhF;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,uBAAuB,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAQjG;AASD,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGnF;AAED,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAU7E;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,OAAO,CAAC,CAQnB"}
@@ -48,10 +48,6 @@ export function isTransientLockFileDenial(error, lockPath) {
48
48
  const denial = error;
49
49
  return process.platform === "win32" && denial?.code === "EPERM" && denial.path === lockPath;
50
50
  }
51
- export function sidecarLockPayloadIsStale(payload, staleMs, nowMs) {
52
- const createdAtMs = sidecarLockPayloadCreatedAtMs(payload);
53
- return createdAtMs !== null && nowMs - createdAtMs > staleMs;
54
- }
55
51
  export function sidecarLockPayloadCreatedAtMs(payload) {
56
52
  const createdAt = payload &&
57
53
  typeof payload === "object" &&
@@ -1,14 +1,5 @@
1
- export declare function readStringValue(value: unknown): string | undefined;
2
1
  export declare function normalizeNullableString(value: unknown): string | null;
3
2
  export declare function normalizeOptionalString(value: unknown): string | undefined;
4
- export declare function normalizeStringifiedOptionalString(value: unknown): string | undefined;
5
3
  export declare function normalizeOptionalLowercaseString(value: unknown): string | undefined;
6
4
  export declare function normalizeLowercaseStringOrEmpty(value: unknown): string;
7
- export declare function normalizeFastMode(raw?: string | boolean | null): boolean | undefined;
8
- export declare function lowercasePreservingWhitespace(value: string): string;
9
- export declare function localeLowercasePreservingWhitespace(value: string): string;
10
- export declare function resolvePrimaryStringValue(value: unknown): string | undefined;
11
- export declare function normalizeOptionalThreadValue(value: unknown): string | number | undefined;
12
- export declare function normalizeOptionalStringifiedId(value: unknown): string | undefined;
13
- export declare function hasNonEmptyString(value: unknown): value is string;
14
5
  //# sourceMappingURL=string-coerce.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"string-coerce.d.ts","sourceRoot":"","sources":["../src/string-coerce.ts"],"names":[],"mappings":"AAAA,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAElE;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAMrE;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAE1E;AAED,wBAAgB,kCAAkC,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAQrF;AAED,wBAAgB,gCAAgC,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAEnF;AAED,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEtE;AAED,wBAAgB,iBAAiB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,GAAG,OAAO,GAAG,SAAS,CAepF;AAED,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnE;AAED,wBAAgB,mCAAmC,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEzE;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAQ5E;AAED,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAKxF;AAED,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAGjF;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAEjE"}
1
+ {"version":3,"file":"string-coerce.d.ts","sourceRoot":"","sources":["../src/string-coerce.ts"],"names":[],"mappings":"AAAA,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAMrE;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAE1E;AAED,wBAAgB,gCAAgC,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAEnF;AAED,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEtE"}
@@ -1,6 +1,3 @@
1
- export function readStringValue(value) {
2
- return typeof value === "string" ? value : undefined;
3
- }
4
1
  export function normalizeNullableString(value) {
5
2
  if (typeof value !== "string") {
6
3
  return null;
@@ -11,62 +8,9 @@ export function normalizeNullableString(value) {
11
8
  export function normalizeOptionalString(value) {
12
9
  return normalizeNullableString(value) ?? undefined;
13
10
  }
14
- export function normalizeStringifiedOptionalString(value) {
15
- if (typeof value === "string") {
16
- return normalizeOptionalString(value);
17
- }
18
- if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
19
- return normalizeOptionalString(String(value));
20
- }
21
- return undefined;
22
- }
23
11
  export function normalizeOptionalLowercaseString(value) {
24
12
  return normalizeOptionalString(value)?.toLowerCase();
25
13
  }
26
14
  export function normalizeLowercaseStringOrEmpty(value) {
27
15
  return normalizeOptionalLowercaseString(value) ?? "";
28
16
  }
29
- export function normalizeFastMode(raw) {
30
- if (typeof raw === "boolean") {
31
- return raw;
32
- }
33
- if (!raw) {
34
- return undefined;
35
- }
36
- const key = normalizeLowercaseStringOrEmpty(raw);
37
- if (["off", "false", "no", "0", "disable", "disabled", "normal"].includes(key)) {
38
- return false;
39
- }
40
- if (["on", "true", "yes", "1", "enable", "enabled", "fast"].includes(key)) {
41
- return true;
42
- }
43
- return undefined;
44
- }
45
- export function lowercasePreservingWhitespace(value) {
46
- return value.toLowerCase();
47
- }
48
- export function localeLowercasePreservingWhitespace(value) {
49
- return value.toLocaleLowerCase();
50
- }
51
- export function resolvePrimaryStringValue(value) {
52
- if (typeof value === "string") {
53
- return normalizeOptionalString(value);
54
- }
55
- if (!value || typeof value !== "object") {
56
- return undefined;
57
- }
58
- return normalizeOptionalString(value.primary);
59
- }
60
- export function normalizeOptionalThreadValue(value) {
61
- if (typeof value === "number") {
62
- return Number.isFinite(value) ? Math.trunc(value) : undefined;
63
- }
64
- return normalizeOptionalString(value);
65
- }
66
- export function normalizeOptionalStringifiedId(value) {
67
- const normalized = normalizeOptionalThreadValue(value);
68
- return normalized == null ? undefined : String(normalized);
69
- }
70
- export function hasNonEmptyString(value) {
71
- return normalizeOptionalString(value) !== undefined;
72
- }
@@ -1 +1 @@
1
- {"version":3,"file":"temp-target.d.ts","sourceRoot":"","sources":["../src/temp-target.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AA0EF,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,MAAM,CAaT;AAsCD,wBAAsB,QAAQ,CAAC,MAAM,EAAE;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,GAAG,OAAO,CAAC,QAAQ,CAAC,CAwBpB;AAED,wBAAsB,YAAY,CAAC,CAAC,EAClC,MAAM,EAAE;IACN,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,EACD,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ"}
1
+ {"version":3,"file":"temp-target.d.ts","sourceRoot":"","sources":["../src/temp-target.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AA8DF,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,MAAM,CAaT;AAsCD,wBAAsB,QAAQ,CAAC,MAAM,EAAE;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,GAAG,OAAO,CAAC,QAAQ,CAAC,CAwBpB;AAED,wBAAsB,YAAY,CAAC,CAAC,EAClC,MAAM,EAAE;IACN,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,EACD,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ"}
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { sameFileIdentityForCleanup } from "./file-identity.js";
5
- import { assertSafePathSegment, sanitizeSafePathSegment } from "./safe-path-segment.js";
5
+ import { assertSafePathSegment, sanitizeSafePathSegment, trimHyphenEdges } from "./safe-path-segment.js";
6
6
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
7
7
  import { registerTempPathForExit } from "./temp-cleanup.js";
8
8
  const HYPHEN_CHAR_CODE = 0x2d;
@@ -14,17 +14,6 @@ const UPPERCASE_Z_CHAR_CODE = 0x5a;
14
14
  const UNDERSCORE_CHAR_CODE = 0x5f;
15
15
  const LOWERCASE_A_CHAR_CODE = 0x61;
16
16
  const LOWERCASE_Z_CHAR_CODE = 0x7a;
17
- function trimHyphenEdges(value) {
18
- let start = 0;
19
- let end = value.length;
20
- while (start < end && value.charCodeAt(start) === HYPHEN_CHAR_CODE) {
21
- start += 1;
22
- }
23
- while (end > start && value.charCodeAt(end - 1) === HYPHEN_CHAR_CODE) {
24
- end -= 1;
25
- }
26
- return start === 0 && end === value.length ? value : value.slice(start, end);
27
- }
28
17
  function isExtensionCharCode(charCode) {
29
18
  return ((charCode >= NUMBER_ZERO_CHAR_CODE && charCode <= NUMBER_NINE_CHAR_CODE) ||
30
19
  (charCode >= UPPERCASE_A_CHAR_CODE && charCode <= UPPERCASE_Z_CHAR_CODE) ||
package/docs/archive.md CHANGED
@@ -2,15 +2,12 @@
2
2
 
3
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
- The guarded JavaScript fallback uses optional runtime dependencies: `jszip` for
6
- ZIP and `tar` for TAR/gzip. The native path does not use those packages. Installs
7
- that omit optional dependencies can still import this subpath and use the
8
- native path or pure path/limit helpers.
9
-
10
- Some package managers and CI installs skip optional dependencies
11
- (`--no-optional`, `--omit=optional`, or equivalent). If an archive helper throws
12
- that an optional archive dependency is not installed, install `jszip` and/or
13
- `tar` explicitly in the consuming package.
5
+ TAR admission uses one Rust core compiled into both the native binding and a
6
+ bundled, import-free WebAssembly module. The guarded JavaScript fallback uses
7
+ that module for TAR/gzip and optional `jszip` for ZIP. TAR needs no optional
8
+ parser dependency, runtime download, install script, or consumer Rust toolchain.
9
+ Installs omitting optional dependencies can import every public subpath and use
10
+ TAR/gzip in `auto` or `off`; ZIP fallback still requires `jszip`.
14
11
 
15
12
  ```ts
16
13
  import { extractArchive, resolveArchiveKind } from "@openclaw/fs-safe/archive";
@@ -68,12 +65,12 @@ directories; ZIP UNIX creator records with zero attributes are explicit zero,
68
65
  while non-UNIX ZIP records use the absent-metadata defaults.
69
66
 
70
67
  TAR mode fields containing only NUL/ASCII-space padding use absent defaults.
71
- Native extraction also recognizes GNU binary modes, including signed values,
72
- within node-tar's JavaScript safe-integer range before masking permission bits.
73
- Malformed or unsupported mode representations retain existing decoder behavior:
74
- native falls back to zero, while JavaScript may default, parse an octal prefix,
75
- or reject. These representations are not newly admitted or standardized by the
76
- mode repair; raw framing and other numeric-field checks remain unchanged.
68
+ Both backends recognize GNU binary modes, including signed values,
69
+ within JavaScript's safe-integer range before masking permission bits.
70
+ Malformed or unsupported mode fields consistently fall back to zero, matching
71
+ the former native behavior. This replaces JavaScript's decoder-dependent octal
72
+ prefix parsing, defaulting, or rejection for malformed fields. Ordinary octal,
73
+ absent, explicit zero, and supported GNU binary fields retain their behavior.
77
74
 
78
75
  Final modes remain separate from private working staging permissions: files
79
76
  stay `0o600` and directories `0o700` until publication. Files receive their final
@@ -112,8 +109,8 @@ normalizing separators. For example, `./pkg/hello.txt` with
112
109
  `stripComponents: 1` extracts to `hello.txt` on both backends. Entries with no
113
110
  remaining components are skipped before the filter callback, but still count
114
111
  toward `maxEntries` and undergo traversal validation. JavaScript TAR extraction
115
- passes node-tar this accepted output path with its own stripping disabled, so
116
- depth checks, collision checks, writes, and mode application agree.
112
+ copies the admitted payload range to this accepted output path, so depth checks,
113
+ collision checks, writes, and mode application agree.
117
114
 
118
115
  An `entryFilter` sees the validated **canonical effective archive path before
119
116
  stripping**, entry kind, and declared size. On every JavaScript and native
@@ -127,6 +124,12 @@ Unicode Path names use the same canonicalization.
127
124
  Raw paths undergo traversal, absolute/drive-path, and NUL validation **before**
128
125
  canonicalization; normalization cannot turn an unsafe path into an accepted
129
126
  one. Stripping and output collision checks use this same canonical identity.
127
+ On Windows, archive admission also rejects reserved device segments such as
128
+ `NUL`, `CON.txt`, and `nul .txt` with `ArchiveSecurityError("entry-path")`,
129
+ before extraction or bounded member reads. Ignored trailing spaces in the stem
130
+ before an extension do not bypass this check. Ordinary members with these names
131
+ remain valid on POSIX; this is a host-specific device guard, not a portable-name
132
+ restriction.
130
133
  Filters that compare exact strings should use canonical pre-strip paths,
131
134
  including directory names without a trailing `/`.
132
135
  Returning `"skip"` rejects the whole archive unless `onFiltered` is
@@ -162,11 +165,10 @@ If skipping was not explicitly part of the restore contract, omit
162
165
  `onFiltered`; the first `"skip"` then rejects the complete archive with
163
166
  `ArchiveSecurityError("entry-filtered")`.
164
167
 
165
- Both TAR implementations finish bounded admission before TypeScript policy
166
- evaluation, so a rejected plan never starts extraction. The JavaScript path
167
- owns the extraction file stream and aborts node-tar through a pipeline on
168
- parser disagreement, validation, or timeout failure, destroying both ends
169
- instead of leaving a paused parser to drain indefinitely.
168
+ Both TAR routes finish bounded admission before TypeScript policy evaluation,
169
+ so a rejected plan never starts extraction. The JavaScript path owns its input,
170
+ decoder, and WASM parser streams, joining their teardown on validation, write,
171
+ or timeout failure.
170
172
 
171
173
  TAR character devices, block devices, and FIFOs are presented to the filter as
172
174
  `kind: "other"`. Accepted entries of these types reject with
@@ -183,8 +185,7 @@ and output collision checks in physical order. Each remaining record reaches
183
185
  `entryFilter` once with its canonical pre-strip path, `kind: "other"`, and
184
186
  declared effective size. A filter skip rejects with `"entry-filtered"` unless
185
187
  `onFiltered: "skip-entry"` is explicit. Accepted unsupported records are safely
186
- omitted and do not consume output payload budgets. This applies even when the
187
- underlying TAR parser suppresses the record. GNU long names describe one such
188
+ omitted and do not consume output payload budgets. The shared core admits these records explicitly. GNU long names describe one such
188
189
  record and are then cleared; local PAX on unsupported types and GNU sparse
189
190
  `S` records retain their existing fail-closed format policy.
190
191
 
@@ -219,7 +220,7 @@ type ArchiveExtractLimits = {
219
220
  };
220
221
  ```
221
222
 
222
- Defaults exist for each (`DEFAULT_MAX_ARCHIVE_BYTES_ZIP`, `DEFAULT_MAX_ENTRIES`, `DEFAULT_MAX_EXTRACTED_BYTES`, `DEFAULT_MAX_ENTRY_BYTES`, `DEFAULT_MAX_META_ENTRY_BYTES`, `DEFAULT_MAX_ENTRY_PATH_COMPONENTS`). An explicit zero remains zero rather than selecting the default. `maxEntries` counts every archive entry, including entries removed by `stripComponents` or an explicit filter. The path-component default is 256. It is evaluated after `stripComponents` and before TypeScript accepts an entry for either JavaScript or native extraction, so rejected entries cannot cause implicit parent-directory creation. The 1 MiB metadata default matches node-tar's `maxMetaEntrySize`; fs-safe passes the same resolved value to node-tar and the native TAR meter.
223
+ Defaults exist for each (`DEFAULT_MAX_ARCHIVE_BYTES_ZIP`, `DEFAULT_MAX_ENTRIES`, `DEFAULT_MAX_EXTRACTED_BYTES`, `DEFAULT_MAX_ENTRY_BYTES`, `DEFAULT_MAX_META_ENTRY_BYTES`, `DEFAULT_MAX_ENTRY_PATH_COMPONENTS`). An explicit zero remains zero rather than selecting the default. `maxEntries` counts every archive entry, including entries removed by `stripComponents` or an explicit filter. The path-component default is 256. It is evaluated after `stripComponents` and before TypeScript accepts an entry for either JavaScript or native extraction, so rejected entries cannot cause implicit parent-directory creation. The same resolved 1 MiB metadata default applies to the native and WASM core.
223
224
 
224
225
  A limit violation throws `ArchiveLimitError`. Its constant and string code are:
225
226
 
@@ -262,17 +263,17 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and
262
263
  - **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).
263
264
  - **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk.
264
265
  - **Corrupt ZIP payloads:** streamed output must match both the central-directory CRC and declared uncompressed size before it can leave private staging.
265
- - **Corrupt gzip streams:** truncated compressed bodies, missing trailers, and checksum failures reject before extraction publishes files or an entry read returns bytes, on both JavaScript and native backends.
266
+ - **Gzip container integrity:** every concatenated gzip member must have a complete valid header, body, CRC32, and ISIZE trailer. A completed member may be followed by all-zero compressed-container padding (including system-tar stdout padding), bounded by the original archive-byte limit. The padding must remain zero through physical EOF; nonzero bytes or another member after padding reject. Truncation and corruption reject before publication or selected bytes return on both backends. Compressed padding is separate from decoded TAR EOF and does not bypass its checks.
266
267
  - **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget for non-mutating work. Extraction is aborted on overrun; if a destination mutation is already in flight, that mutation and rollback are joined before rejection so archive-controlled publication cannot continue afterward.
267
- - **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.
268
+ - **Metadata bombs:** a streaming pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before buffering their bodies. 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.
268
269
 
269
270
  ### Raw TAR framing
270
271
 
271
272
  Extraction and bounded reads admit the complete decoded TAR stream through the
272
- raw meter before either backend's TAR parser runs. This applies to plain TAR,
273
+ shared Rust core. This applies to plain TAR,
273
274
  gzip, and native-supported zstd/bzip2, without changing native-mode availability
274
- or fallback policy. The existing TypeScript and Rust meters enforce the same
275
- framing rules before parser normalization:
275
+ or fallback policy. The native and WASM builds enforce the same
276
+ framing rules:
276
277
 
277
278
  - Every nonzero header must have a valid unsigned octal checksum, delimited
278
279
  within its field. Checksum validation precedes metadata allocation and member
@@ -301,22 +302,28 @@ Missing linknames on links and nonempty linknames on non-links still use the
301
302
  format error. PAX `x` and GNU long-name/long-link `L`/`K` payloads retain their
302
303
  existing support and metadata limits; the zero-body rule is not applied to all
303
304
  non-regular types.
304
- PAX effective sizes still determine regular-member framing. Admission preserves
305
- the input bytes, and all entry/path/byte limits and extraction deadlines remain
306
- in force. Native inspection now completes this admission pass before parsing,
307
- requiring one additional streaming read/decompression pass.
308
- JavaScript admission reports an ordered logical-member manifest from the raw
309
- meter, bounded by entry-count, manifest, and decoded limits. Policy runs once
310
- over that manifest; extraction checks parser-visible members against the
311
- accepted decisions before writing. Original member names and USTAR prefixes
312
- are validated even when overridden, and non-padding bytes after a fixed path
313
- field's NUL terminator reject rather than hiding an unsafe suffix.
314
- Both meters enforce the 255-byte component ceiling under NFC and NFD before
315
- metadata replaces a raw path, including Hangul decomposition expansion.
316
- Native extraction and entry reads also drain their metered readers through
317
- physical EOF after parser traversal, before completing directory modes,
318
- publishing staged files, or returning the requested bytes. Finding the requested
319
- member or reaching the parser's logical EOF cannot bypass trailing validation.
305
+ PAX effective sizes determine regular-member framing. Admission preserves input
306
+ bytes and emits an ordered manifest with exact effective names, types, modes,
307
+ sizes, and decoded payload offsets. TypeScript owns filtering, stripping,
308
+ collisions, permissions, and accepted-output limits. Executors replay admitted
309
+ ranges from the immutable staged input; no second TAR parser interprets PAX,
310
+ GNU names, or payload lengths. Native writes remain descriptor-relative;
311
+ JavaScript writes use the shared guarded private staging and pinned-write helpers.
312
+
313
+ Original member names and USTAR prefixes are validated even when overridden.
314
+ Non-padding bytes after a fixed path field's NUL terminator reject. The core
315
+ enforces the 255-byte component ceiling under NFC and NFD, including Hangul
316
+ expansion. Every replay drains and validates physical EOF before publication or
317
+ returning selected bytes. Unrequested, filtered, and stripped members cannot
318
+ bypass validation. Decompression remains streaming; no complete decoded archive
319
+ is retained in memory or written to a decoded spool.
320
+
321
+ The WASM transport has a fixed 64 KiB input buffer, one pending member event,
322
+ and a 256 MiB maximum linear memory per isolated parser instance. Metadata is
323
+ bounded before allocation; allocation failure rejects. Stream backpressure
324
+ bounds queued chunks, and completion/error destroys the instance's parser
325
+ state. The manifest retains the existing charged budget below; linear memory
326
+ is an additional execution resource bound, not a new public limit option.
320
327
 
321
328
  The raw meter enforces `maxEntries` before consuming each logical member's body,
322
329
  including members later skipped by filtering or stripping. PAX/GNU metadata
@@ -343,9 +350,7 @@ archive overhead with safe addition. Ordinary limits, including
343
350
  zero and the existing defaulting/rounding rules, retain their behavior.
344
351
  There is no new public option. This is an absolute decoded admission
345
352
  cap, not a decompression-ratio policy; bounded stream/codec read-ahead remains.
346
- After this complete preflight, the JavaScript backend disables node-tar's
347
- independent ratio threshold so it cannot reject data that the native backend
348
- accepts within the same absolute limits.
353
+ There is no independent TAR parser decompression-ratio threshold.
349
354
 
350
355
  ### Bounded local PAX support
351
356
 
@@ -359,13 +364,14 @@ permits link creation. Effective sizes drive framing, filters, and the existing
359
364
  budgets; `maxEntries` still counts members, not their metadata headers.
360
365
 
361
366
  Records must have exact byte lengths, ASCII keys, a final newline, and no
362
- duplicate keys, embedded newlines, or unconsumed bytes. Structural `path` and
363
- `linkpath` values and ownership names must be nonempty printable ASCII. A PAX
364
- member's raw name, USTAR prefix, and raw link target must also be printable
365
- ASCII; raw link targets must be present only on links, even when overridden.
366
- Unicode
367
- PAX structural text is deliberately unsupported because the underlying parsers
368
- do not agree when UTF-8 is split across input chunks. `size`, `uid`, and `gid`
367
+ duplicate keys or unconsumed bytes. `path` and `linkpath` must be nonempty strict
368
+ UTF-8 without NUL. Unicode, a leading BOM, numeric-looking names, and embedded
369
+ newlines preserve their exact spelling; newlines inside a byte-counted value
370
+ are data. Windows filesystem filename restrictions still apply during creation.
371
+ Ownership names retain the existing nonempty printable-ASCII contract. Raw name,
372
+ USTAR prefix, and link fields still require strict UTF-8 and NUL padding even
373
+ when metadata overrides them. Raw link targets must be present only on links.
374
+ `size`, `uid`, and `gid`
369
375
  must be canonical unsigned decimal safe integers (zero is valid; signs, leading
370
376
  zeros, fractions, and exponents are not). Padded member sizes must also fit the
371
377
  safe integer range. Raw and effective directory/link sizes must both be zero;
@@ -378,8 +384,7 @@ with optional fractional digits, within JavaScript's Date range), `uid`, `gid`,
378
384
  destination. `LIBARCHIVE.xattr.*` and `SCHILY.xattr.*` with nonempty ASCII
379
385
  alphanumeric/dot/underscore/hyphen suffixes are also accepted as inert metadata,
380
386
  never restored as extended attributes. Their values are byte-counted and may
381
- contain NUL or non-UTF8 bytes, including macOS provenance metadata; embedded
382
- newlines are rejected because they can disrupt downstream record parsing.
387
+ contain NUL, non-UTF8 bytes, or newlines, including macOS provenance metadata.
383
388
 
384
389
  Global `g`, old `X`, old GNU `N`, empty/dangling/repeated local headers, mixed
385
390
  PAX/GNU extension chains, unknown keys, charset declarations, ACL extensions,
@@ -393,11 +398,11 @@ chains without introducing a new limit or changing defaults.
393
398
 
394
399
  ### Bounded GNU long names and links
395
400
 
396
- Both raw meters buffer GNU long-name `L` and long-link `K` bodies within
397
- `maxMetaEntryBytes` before either TAR parser runs. A body must contain a nonempty
401
+ The shared core buffers GNU long-name `L` and long-link `K` bodies within
402
+ `maxMetaEntryBytes`. A body must contain a nonempty
398
403
  UTF-8 name, with either no NUL or exactly one terminal NUL. Embedded NULs,
399
404
  additional terminal NULs, bytes after a NUL, and invalid UTF-8 reject with
400
- `ArchiveFormatError("archive-header-invalid")`. The meters preserve original
405
+ `ArchiveFormatError("archive-header-invalid")`. The core preserves original
401
406
  archive bytes, including the optional terminator and block padding.
402
407
 
403
408
  One logical member may have at most one `L` and one `K`, in either order.
package/docs/atomic.md CHANGED
@@ -173,7 +173,8 @@ metadata where lower latency matters more than crash-durability.
173
173
 
174
174
  ## `movePathWithCopyFallback`
175
175
 
176
- Rename a path. If the rename fails with `EXDEV` (cross-device), fall back to
176
+ Rename a path. If the rename fails with `EXDEV` (cross-device), or `EPERM` on
177
+ Windows, fall back to
177
178
  copying into a staged sibling path, renaming that staged path into place, and
178
179
  then removing only the source entries that were copied. The fallback avoids
179
180
  buffering regular files into memory and does not tighten the destination parent
@@ -215,39 +216,72 @@ identity becomes the next cleanup receipt. This accounts for the operation's
215
216
  own link-count and ctime changes without suppressing unexpected external
216
217
  mutations.
217
218
 
218
- ### Final rename authorization
219
+ ### Mutation authority and publication receipts
219
220
 
220
- Pass `assertBeforeRename` when a move depends on a revocable lease or another
221
- caller-owned authorization. The helper captures this callback when called and
222
- runs it synchronously after asynchronous preparation and directory checks,
223
- immediately before each rename is dispatched:
221
+ Pass `assertBeforeMutation` when a move depends on a revocable lease or another
222
+ caller-owned authorization. The helper captures the callback when called and
223
+ runs it synchronously after asynchronous preparation and identity checks,
224
+ immediately before each rename, source-file or source-symlink unlink, and
225
+ source-directory removal. `assertBeforeRename` remains available for callers
226
+ that only guard publication. When both are set, the rename check runs first,
227
+ then the mutation check, without yielding before dispatch.
224
228
 
225
229
  ```ts
230
+ type MovePathPublicationReceipt = Readonly<{
231
+ path: string;
232
+ dev: bigint;
233
+ ino: bigint;
234
+ }>;
235
+
226
236
  type MovePathWithCopyFallbackOptions = {
227
237
  from: string;
228
238
  sourceHardlinks?: "allow" | "reject";
229
239
  to: string;
230
240
  assertBeforeRename?: () => void;
241
+ assertBeforeMutation?: () => void;
242
+ onDestinationPublished?: (receipt: MovePathPublicationReceipt) => void;
231
243
  };
232
244
  ```
233
245
 
234
- Throw to refuse publication. The original error is propagated, including errors
235
- with `EXDEV` or `EPERM` codes; an authorization failure never starts a copy
236
- fallback. A genuine rename failure may still require a second authorization
246
+ Throw to refuse the next mutation. The original error is propagated, including
247
+ errors with `EXDEV` or `EPERM` codes; an authorization failure never starts a
248
+ copy fallback. A genuine rename failure may still require a second authority
237
249
  check before publishing the staged copy.
238
250
 
239
- The callback must return `undefined` synchronously. Returning a Promise, thenable,
240
- or any other value refuses the rename with a `TypeError`; rejected asynchronous
241
- results are consumed without authorizing the operation. Perform asynchronous
242
- policy checks before calling the helper and use this callback to recheck the
243
- current owner at the mutation boundary.
251
+ All three callbacks must return `undefined` synchronously. Returning a Promise,
252
+ thenable, or any other value fails with a `TypeError`; rejected asynchronous
253
+ results are consumed. Perform asynchronous policy checks before calling the
254
+ helper and use the authority callback to recheck the current owner at each
255
+ mutation boundary. All callbacks are captured before the first await.
256
+
257
+ `onDestinationPublished` runs exactly once after a successful rename resolves,
258
+ before awaited post-rename directory checks or source cleanup. It receives a
259
+ frozen receipt with the resolved absolute destination path and the exact bigint
260
+ device/inode identity observed on the rename source before dispatch: the
261
+ original source for a direct rename, or the staged copy for fallback. Failed
262
+ rename attempts never emit receipts. The return contract remains `Promise<void>`.
263
+
264
+ The receipt records an observed identity, not authorization, durable storage,
265
+ or an atomic guarantee against concurrent pathname replacement. Before recovery,
266
+ recheck caller authority and compare the retained identity with a fresh bigint
267
+ stat of the destination. In particular, unknown Windows device/inode values
268
+ must not be treated as proof of ownership. Do not derive ownership from a new
269
+ post-failure snapshot alone.
244
270
 
245
271
  A refused rename leaves the source and destination unchanged; any private
246
- staged copy follows the helper's normal cleanup. The check does not cancel an
247
- already-dispatched rename or make an external lease store atomic with the
248
- filesystem. Cleanup after a successful move retains the existing source-identity
249
- checks. Omitting the
250
- callback preserves the usual move behavior.
272
+ staged copy follows normal cleanup. If publication has already happened, a
273
+ callback error or later verification failure preserves the published destination
274
+ and stops further source cleanup. Revocation during cleanup leaves all
275
+ as-yet-unremoved source entries intact; entries already removed remain removed.
276
+ The caller retains the receipt even if the move later rejects and owns recovery
277
+ from this partial-move state. An observer that throws must retain its receipt
278
+ before throwing if recovery needs it.
279
+
280
+ These callbacks do not cancel already-dispatched operations or make an external
281
+ lease store atomic with the filesystem. `assertBeforeMutation` guards renames
282
+ and removal of copied source entries; private staging creation, writes, and
283
+ failed-staging cleanup remain owned by the helper. Omitting the new callbacks
284
+ preserves the existing move and source-identity behavior.
251
285
 
252
286
  ## Difference from `root()`
253
287