@openclaw/fs-safe 0.5.5 → 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 (164) hide show
  1. package/CHANGELOG.md +49 -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-identity.d.ts +1 -0
  43. package/dist/file-identity.d.ts.map +1 -1
  44. package/dist/file-identity.js +10 -0
  45. package/dist/file-lock-sync.d.ts.map +1 -1
  46. package/dist/file-lock-sync.js +19 -7
  47. package/dist/move-path.js +2 -2
  48. package/dist/native-binding.d.ts +3 -0
  49. package/dist/native-binding.d.ts.map +1 -1
  50. package/dist/native-operations.d.ts +4 -1
  51. package/dist/native-operations.d.ts.map +1 -1
  52. package/dist/native-operations.js +24 -8
  53. package/dist/native-pinned-write-windows.d.ts +8 -0
  54. package/dist/native-pinned-write-windows.d.ts.map +1 -0
  55. package/dist/native-pinned-write-windows.js +92 -0
  56. package/dist/native-pinned-write.d.ts.map +1 -1
  57. package/dist/native-pinned-write.js +136 -127
  58. package/dist/native-staged-file.d.ts +24 -0
  59. package/dist/native-staged-file.d.ts.map +1 -0
  60. package/dist/native-staged-file.js +337 -0
  61. package/dist/native.d.ts.map +1 -1
  62. package/dist/native.js +4 -4
  63. package/dist/opened-realpath.d.ts +2 -0
  64. package/dist/opened-realpath.d.ts.map +1 -1
  65. package/dist/opened-realpath.js +12 -7
  66. package/dist/output-sibling.js +3 -3
  67. package/dist/owner-dacl.d.ts.map +1 -1
  68. package/dist/owner-dacl.js +2 -1
  69. package/dist/permission-exec.d.ts +19 -0
  70. package/dist/permission-exec.d.ts.map +1 -1
  71. package/dist/permission-exec.js +57 -11
  72. package/dist/permissions-public.d.ts +1 -1
  73. package/dist/permissions-public.d.ts.map +1 -1
  74. package/dist/permissions-windows.d.ts +3 -0
  75. package/dist/permissions-windows.d.ts.map +1 -1
  76. package/dist/permissions-windows.js +16 -5
  77. package/dist/permissions.d.ts +5 -0
  78. package/dist/permissions.d.ts.map +1 -1
  79. package/dist/pinned-write.d.ts +6 -0
  80. package/dist/pinned-write.d.ts.map +1 -1
  81. package/dist/pinned-write.js +19 -29
  82. package/dist/private-directory.d.ts.map +1 -1
  83. package/dist/private-directory.js +3 -2
  84. package/dist/private-temp-workspace.d.ts.map +1 -1
  85. package/dist/private-temp-workspace.js +23 -15
  86. package/dist/publish-file.d.ts.map +1 -1
  87. package/dist/publish-file.js +2 -2
  88. package/dist/replace-file-descriptor.d.ts +3 -3
  89. package/dist/replace-file-descriptor.d.ts.map +1 -1
  90. package/dist/replace-file-descriptor.js +2 -2
  91. package/dist/root-impl.d.ts.map +1 -1
  92. package/dist/root-impl.js +84 -75
  93. package/dist/root-paths.d.ts +11 -14
  94. package/dist/root-paths.d.ts.map +1 -1
  95. package/dist/root-paths.js +36 -27
  96. package/dist/root-write-verification.d.ts +11 -0
  97. package/dist/root-write-verification.d.ts.map +1 -0
  98. package/dist/root-write-verification.js +91 -0
  99. package/dist/secret-file.d.ts +1 -6
  100. package/dist/secret-file.d.ts.map +1 -1
  101. package/dist/secret-file.js +43 -117
  102. package/dist/secret-read-async.d.ts +1 -1
  103. package/dist/secret-read-async.d.ts.map +1 -1
  104. package/dist/secret-read-async.js +45 -67
  105. package/dist/secret-read-policy.d.ts +13 -0
  106. package/dist/secret-read-policy.d.ts.map +1 -0
  107. package/dist/secret-read-policy.js +28 -0
  108. package/dist/secret.d.ts +2 -1
  109. package/dist/secret.d.ts.map +1 -1
  110. package/dist/secret.js +2 -1
  111. package/dist/secure-file.d.ts.map +1 -1
  112. package/dist/secure-file.js +25 -16
  113. package/dist/sibling-temp.js +1 -1
  114. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  115. package/dist/sidecar-lock-acquire.js +4 -1
  116. package/dist/staged-directory.d.ts +16 -0
  117. package/dist/staged-directory.d.ts.map +1 -0
  118. package/dist/staged-directory.js +60 -0
  119. package/dist/staged-file-types.d.ts +56 -0
  120. package/dist/staged-file-types.d.ts.map +1 -0
  121. package/dist/staged-file-types.js +1 -0
  122. package/dist/staged-file.d.ts +10 -0
  123. package/dist/staged-file.d.ts.map +1 -0
  124. package/dist/staged-file.js +15 -0
  125. package/dist/strict-file-identity.d.ts +6 -0
  126. package/dist/strict-file-identity.d.ts.map +1 -0
  127. package/dist/strict-file-identity.js +48 -0
  128. package/dist/temp-cleanup.d.ts.map +1 -1
  129. package/dist/temp-cleanup.js +3 -3
  130. package/dist/temp-target.d.ts.map +1 -1
  131. package/dist/temp-target.js +9 -7
  132. package/dist/windows-owner.d.ts +3 -0
  133. package/dist/windows-owner.d.ts.map +1 -1
  134. package/dist/windows-owner.js +10 -2
  135. package/docs/advanced.md +11 -1
  136. package/docs/archive.md +62 -15
  137. package/docs/config.md +7 -0
  138. package/docs/contributing.md +45 -7
  139. package/docs/durability.md +17 -4
  140. package/docs/errors.md +16 -1
  141. package/docs/index.md +3 -1
  142. package/docs/install.md +15 -7
  143. package/docs/migrating-to-0.5.md +7 -7
  144. package/docs/migrating-to-0.6.md +43 -0
  145. package/docs/native-helper.md +15 -5
  146. package/docs/native.md +40 -11
  147. package/docs/path-scope.md +28 -2
  148. package/docs/permissions.md +13 -2
  149. package/docs/root.md +1 -1
  150. package/docs/secret-file.md +8 -0
  151. package/docs/secure-file.md +17 -1
  152. package/docs/security-model.md +15 -1
  153. package/docs/sidecar-lock.md +13 -3
  154. package/docs/staged-file.md +178 -0
  155. package/docs/testing.md +1 -1
  156. package/docs/writing.md +19 -0
  157. package/package.json +16 -9
  158. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  159. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  160. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  161. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  162. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  163. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  164. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
@@ -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 +1 @@
1
- {"version":3,"file":"temp-cleanup.d.ts","sourceRoot":"","sources":["../src/temp-cleanup.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAE7E,MAAM,MAAM,uBAAuB,GAAG,gBAAgB,CAAC;AAEvD,MAAM,MAAM,oBAAoB,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG;IAChD,WAAW,CAAC,QAAQ,EAAE,gBAAgB,GAAG,IAAI,CAAC;CAC/C,CAAC;AAmCF,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,EAAE,gBAAgB,CAAA;CAAE,GAC7D,oBAAoB,CAyBtB;AAED,wBAAgB,mCAAmC,IAAI,IAAI,CAE1D;AAED,wBAAgB,kCAAkC,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAYzE"}
1
+ {"version":3,"file":"temp-cleanup.d.ts","sourceRoot":"","sources":["../src/temp-cleanup.ts"],"names":[],"mappings":"AACA,OAAO,EAA8B,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAEvF,MAAM,MAAM,uBAAuB,GAAG,gBAAgB,CAAC;AAEvD,MAAM,MAAM,oBAAoB,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG;IAChD,WAAW,CAAC,QAAQ,EAAE,gBAAgB,GAAG,IAAI,CAAC;CAC/C,CAAC;AAsCF,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,EAAE,gBAAgB,CAAA;CAAE,GAC7D,oBAAoB,CAyBtB;AAED,wBAAgB,mCAAmC,IAAI,IAAI,CAE1D;AAED,wBAAgB,kCAAkC,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAYzE"}
@@ -1,5 +1,5 @@
1
1
  import fsSync from "node:fs";
2
- import { sameFileIdentity } from "./file-identity.js";
2
+ import { sameFileIdentityForCleanup } from "./file-identity.js";
3
3
  const tempCleanupEntries = new Map();
4
4
  let cleanupRegistered = false;
5
5
  function pathStillMatchesReceipt(entry) {
@@ -7,7 +7,7 @@ function pathStillMatchesReceipt(entry) {
7
7
  return false;
8
8
  }
9
9
  try {
10
- return sameFileIdentity(fsSync.lstatSync(entry.path), entry.identity);
10
+ return sameFileIdentityForCleanup(fsSync.lstatSync(entry.path, { bigint: true }), entry.identity);
11
11
  }
12
12
  catch (error) {
13
13
  return error.code === "ENOENT";
@@ -38,7 +38,7 @@ export function registerTempPathForExit(tempPath, options) {
38
38
  };
39
39
  if (!entry.identity) {
40
40
  try {
41
- entry.identity = fsSync.lstatSync(tempPath);
41
+ entry.identity = fsSync.lstatSync(tempPath, { bigint: true });
42
42
  }
43
43
  catch {
44
44
  // Callers that register before creation set the identity after opening.
@@ -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,CAsBpB;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;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,7 +1,7 @@
1
1
  import crypto from "node:crypto";
2
- import { lstat, mkdtemp, rm } from "node:fs/promises";
2
+ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { sameFileIdentity } from "./file-identity.js";
4
+ import { sameFileIdentityForCleanup } from "./file-identity.js";
5
5
  import { assertSafePathSegment, sanitizeSafePathSegment } from "./safe-path-segment.js";
6
6
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
7
7
  import { registerTempPathForExit } from "./temp-cleanup.js";
@@ -92,16 +92,16 @@ function isNodeErrorWithCode(err, code) {
92
92
  }
93
93
  async function cleanupTempDir(dir, identity, onCleanupError) {
94
94
  try {
95
- const current = await lstat(dir).catch((error) => {
95
+ const current = await fs.lstat(dir, { bigint: true }).catch((error) => {
96
96
  if (isNodeErrorWithCode(error, "ENOENT")) {
97
97
  return undefined;
98
98
  }
99
99
  throw error;
100
100
  });
101
- if (!current || !sameFileIdentity(current, identity)) {
101
+ if (!current || !sameFileIdentityForCleanup(current, identity)) {
102
102
  return;
103
103
  }
104
- await rm(dir, { recursive: true, force: true });
104
+ await fs.rm(dir, { recursive: true, force: true });
105
105
  }
106
106
  catch (err) {
107
107
  if (!isNodeErrorWithCode(err, "ENOENT")) {
@@ -115,8 +115,10 @@ function resolveTempRoot(rootDir) {
115
115
  export async function tempFile(params) {
116
116
  const rootDir = resolveTempRoot(params.rootDir);
117
117
  const prefix = `${sanitizePrefix(params.prefix)}-`;
118
- const dir = await mkdtemp(path.join(rootDir, prefix));
119
- const identity = await lstat(dir);
118
+ const dir = await fs.mkdtemp(path.join(rootDir, prefix));
119
+ // Windows file indexes can exceed Number.MAX_SAFE_INTEGER. Cleanup receipts
120
+ // must retain the exact identity or adjacent directories can compare equal.
121
+ const identity = await fs.lstat(dir, { bigint: true });
120
122
  const unregisterTempDir = registerTempPathForExit(dir, { recursive: true, identity });
121
123
  const file = (fileName) => path.join(dir, sanitizeTempFileName(fileName ?? params.fileName ?? "download.bin"));
122
124
  const cleanup = async () => {
@@ -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`.
@@ -46,23 +46,55 @@ pnpm check
46
46
  This runs the filesystem boundary checks, build, tests, and package
47
47
  tarball/import validation.
48
48
 
49
+ ### Native consumer installs
50
+
51
+ After `pnpm build` and a fresh `pnpm native:build`, run `pnpm package:smoke`.
52
+ It packs the real root and host binding, then runs root-only npm and the
53
+ declared pnpm version against a disposable loopback registry. The root's exact
54
+ optional dependencies stay unchanged. Each consumer lives outside the workspace
55
+ with isolated configuration, caches, and stores; the registry never proxies to
56
+ the Internet. The smoke verifies root integrity, consumer-local resolution,
57
+ OS/CPU/libc selection, a native-required SHA-256 operation, and fresh-process
58
+ `auto`/`off` fallbacks and `require` failures for missing bindings and omitted
59
+ optionals. Omitted-optionals installs also verify that all public subpaths can
60
+ be imported, without implying every operation remains available.
61
+
62
+ Host-only smoke supplies the six foreign packages using their unchanged real
63
+ manifests and clearly marked synthetic, non-executable payloads. Every foreign
64
+ metadata/tarball endpoint is checked before installation, so a missing fixture
65
+ cannot masquerade as successful platform filtering. These temporary fixtures
66
+ never enter `packages/`, release artifacts, or the publish manifest. They prove
67
+ installer filtering, not foreign native compilation or execution. Full release
68
+ collection uses the actual seven collected native tarballs instead. Run it with
69
+ `pnpm package:collect` after assembling all seven real bindings; missing targets
70
+ fail collection. `pnpm package:collect --allow-host-only` exercises the same
71
+ lifecycle boundary locally but proves only the host. Both collection commands
72
+ require the pnpm lifecycle CLI path; direct `node` invocation is unsupported. Archive
73
+ codecs and their dependencies are packed from the installed dependency graph.
74
+
75
+ PR CI builds and executes four host targets: Linux x64 glibc, Linux x64 musl
76
+ (Alpine), macOS arm64, and Windows x64. The root-only smoke runs on each. The
77
+ seven-target source build matrix runs on release tags; packaging all seven is
78
+ not execution proof for every architecture. The smoke writes manager versions,
79
+ cases, and synthetic-fixture scope to `release-artifacts/consumer-proof.json`.
80
+
49
81
  ## Docs
50
82
 
51
- The docs site is rendered from `docs/*.md` by `scripts/build-docs-site.mjs`. Build locally to preview:
83
+ The docs site is rendered recursively from Markdown files under `docs/` by `scripts/build-docs-site.mjs`. Build locally to preview:
52
84
 
53
85
  ```bash
54
- node scripts/build-docs-site.mjs
86
+ pnpm docs:site
55
87
  open dist/docs-site/index.html
56
88
  ```
57
89
 
58
- The build validates internal links and embedded anchors. Broken links fail the build — fix them before pushing.
90
+ The build validates internal links and embedded anchors. Broken links fail the build — fix them before pushing. Navigation must list every non-excluded Markdown page exactly once: missing, nonexistent, and duplicate entries fail before the build replaces existing site output. The builder and navigation tests share discovery and validation in `scripts/docs-site-navigation.mjs`.
59
91
 
60
92
  Adding a new doc page:
61
93
 
62
94
  1. Create `docs/<page>.md`. Use a leading `# Title` heading.
63
- 2. Add the page to the appropriate section in `scripts/build-docs-site.mjs` (`sections` array near the top).
95
+ 2. Add the page to the appropriate section in `scripts/docs-site-navigation.mjs` (`sections` array near the top). Nested pages use slash-separated paths relative to `docs/`, such as `guides/example.md`.
64
96
  3. Cross-link from `docs/index.md` if it's a major surface.
65
- 4. Re-run the local build.
97
+ 4. Run `pnpm test test/docs-site-navigation.test.ts` and re-run the local build.
66
98
 
67
99
  Internal links use relative `*.md` paths — the builder rewrites them to the rendered HTML. Code fences support GitHub-flavored markdown.
68
100
 
@@ -79,8 +111,14 @@ Small, focused PRs land faster. The general shape:
79
111
 
80
112
  Maintainers publish from a protected `vX.Y.Z` tag on `main` through
81
113
  `.github/workflows/release.yml`. The workflow requires the package version and a
82
- dated `CHANGELOG.md` section to match the tag, then publishes with npm trusted
83
- publishing and provenance before creating the GitHub release.
114
+ dated `CHANGELOG.md` section to match the tag. It builds and publishes all seven
115
+ platform packages before publishing `@openclaw/fs-safe`, verifies every registry
116
+ artifact and provenance statement, and then creates the GitHub release.
117
+
118
+ Each package needs its own npm trusted-publisher configuration for
119
+ `openclaw/fs-safe` and `release.yml`. A new platform package must be created and
120
+ configured on npm before the first tag that references it; npm trust is
121
+ package-specific and cannot be bootstrapped by the tag workflow itself.
84
122
 
85
123
  External contributors do not need to do anything beyond getting the pull
86
124
  request merged. Maintainers must not publish locally or add npm automation
@@ -63,6 +63,12 @@ into proof for a different directory.
63
63
 
64
64
  Call `close()` in `finally`. Closing is idempotent; using a closed pin fails.
65
65
 
66
+ These checks intentionally reject a moved or replaced pathname. For one file's
67
+ abort cleanup through its original directory after a move, use the separate
68
+ [retained-directory staging lifecycle](staged-file.md). Its cleanup authority
69
+ does not weaken `pinDirectory().assertCurrent()` or `.sync()`, and namespace
70
+ cleanup is not proof of crash durability.
71
+
66
72
  ## Durable directory creation
67
73
 
68
74
  `ensureDurableDirectory()` finds and pins the nearest existing ancestor,
@@ -204,10 +210,17 @@ try {
204
210
  The result is `{ bytes, digest }`, where `digest` is lowercase hexadecimal.
205
211
  The handle overload never closes the caller's descriptor and uses positioned
206
212
  reads, so it does not alter the descriptor's current offset. The path overload
207
- rejects symbolic links and non-regular files, verifies the opened descriptor
208
- still names the requested path, and closes its own handle. POSIX opens are
209
- nonblocking, so a raced FIFO or device is rejected after descriptor inspection
210
- rather than waiting for a writer.
213
+ rejects symbolic links and non-regular files, compares lossless bigint identities
214
+ from the pre-open pathname inspection to the opened descriptor and from that
215
+ descriptor to the current pathname, and closes its own handle. All identity
216
+ checks complete before any JavaScript or native hashing. Each inspection allows
217
+ one bounded retry for unknown Windows identity components, retaining known
218
+ components and rejecting known differences immediately. Persistent unknown
219
+ identity fails closed with `path-mismatch`, even for benign files: this trades
220
+ availability for verifiable identity. Retries inspect the same descriptor or
221
+ pathname without reopening the file and repeat the symlink and file-type checks.
222
+ POSIX opens are nonblocking, so a raced FIFO or device is rejected after
223
+ descriptor inspection rather than waiting for a writer.
211
224
 
212
225
  When the optional binding is active, hashing runs as an async native task and
213
226
  does not occupy the JavaScript event loop with digest updates. With native mode
package/docs/errors.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  Every failure that's the library's job to surface lands as an `FsSafeError` with a closed `code` union you can branch on. Catch by code, not by message text — messages may change, codes will not.
4
4
 
5
+ Result-based APIs are an exception to throwing: [`pathScope().ensureDir()`](path-scope.md#ensuredir-rel-options)
6
+ and `ensureDirectoryWithinRoot()` return operational failures as
7
+ `{ ok: false, error: string, diagnostic: FsSafeError }`. The diagnostic uses
8
+ `helper-failed` / `operational`, retains the exact native error in `cause`, and
9
+ has the same message as `error`. Its display text names the native code/syscall
10
+ when available but omits raw paths and native messages. Policy failures keep
11
+ the string-only result without `diagnostic`; other `pathScope` methods do not
12
+ gain this field. Directory preparation can partially complete before failing.
13
+
5
14
  Path and archive-entry details embedded in diagnostics escape control characters
6
15
  as `\\uXXXX` sequences. This keeps attacker-controlled names on one log line;
7
16
  the escaped message is for diagnosis, not for reconstructing the original path.
@@ -105,7 +114,7 @@ type FsSafeErrorCode =
105
114
  | `device-path` | A read/open target is a known unsafe device or process-fd path. | `/dev/zero`, `/dev/random`, `/dev/stdin`, `/dev/fd/*`, `/proc/*/fd/*`, or a Windows reserved device name. |
106
115
  | `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. |
107
116
  | `helper-failed` | A native mechanism or multi-step operational helper failed. | Inspect `cause` and any operation-specific `details`; retrying may be unsafe if the operation partially completed. |
108
- | `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, missing/incompatible bundled binary, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
117
+ | `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, omitted/missing/incompatible platform package, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
109
118
  | `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
110
119
  | `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows, a drive-relative segment in a portable relative path or store key, or a leading drive-relative spelling such as `C:name` used as a Root destination. Existing-object Root lookups retain legal POSIX drive-like names. |
111
120
  | `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
@@ -125,6 +134,12 @@ type FsSafeErrorCode =
125
134
  | `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
126
135
  | `unsupported-platform` | Reserved compatibility code for a platform-specific operation. | No current public helper emits this `FsSafeError` code. Platform-specific APIs currently return a typed unsupported result or use `helper-unavailable`; keep the union member when exhaustively switching across supported package versions. |
127
136
 
137
+ Pathname `sha256File()` also reports `path-mismatch` when pre-open, descriptor,
138
+ or current-path identity remains unknown after one bounded Windows retry, even
139
+ if the file is benign. It never reopens to recover identity. Preview symlinks
140
+ report `symlink`, preview or descriptor non-files report `not-file`, and a
141
+ current-path symlink or non-file reports `path-mismatch`.
142
+
128
143
  ## Branching
129
144
 
130
145
  ```ts