@openclaw/fs-safe 0.1.2 → 0.2.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 (116) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +39 -2
  3. package/dist/absolute-path.d.ts +14 -0
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +203 -2
  6. package/dist/advanced.d.ts +2 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +2 -1
  9. package/dist/archive-staging.d.ts.map +1 -1
  10. package/dist/archive-staging.js +81 -7
  11. package/dist/archive.d.ts.map +1 -1
  12. package/dist/archive.js +25 -42
  13. package/dist/bounded-read-stream.d.ts +8 -0
  14. package/dist/bounded-read-stream.d.ts.map +1 -0
  15. package/dist/bounded-read-stream.js +20 -0
  16. package/dist/config.d.ts +1 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -0
  19. package/dist/directory-guard.d.ts +18 -0
  20. package/dist/directory-guard.d.ts.map +1 -0
  21. package/dist/directory-guard.js +70 -0
  22. package/dist/file-lock.d.ts +4 -2
  23. package/dist/file-lock.d.ts.map +1 -1
  24. package/dist/file-lock.js +13 -2
  25. package/dist/file-store-boundary.d.ts +2 -4
  26. package/dist/file-store-boundary.d.ts.map +1 -1
  27. package/dist/file-store-boundary.js +11 -7
  28. package/dist/file-store-prune.d.ts +12 -0
  29. package/dist/file-store-prune.d.ts.map +1 -0
  30. package/dist/file-store-prune.js +86 -0
  31. package/dist/file-store-source.d.ts +5 -0
  32. package/dist/file-store-source.d.ts.map +1 -0
  33. package/dist/file-store-source.js +30 -0
  34. package/dist/file-store.d.ts +2 -6
  35. package/dist/file-store.d.ts.map +1 -1
  36. package/dist/file-store.js +60 -53
  37. package/dist/guarded-mkdir.d.ts +6 -0
  38. package/dist/guarded-mkdir.d.ts.map +1 -0
  39. package/dist/guarded-mkdir.js +45 -0
  40. package/dist/guarded-mutation.d.ts +33 -0
  41. package/dist/guarded-mutation.d.ts.map +1 -0
  42. package/dist/guarded-mutation.js +76 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -0
  46. package/dist/json-document-store.d.ts +2 -0
  47. package/dist/json-document-store.d.ts.map +1 -1
  48. package/dist/json-document-store.js +7 -3
  49. package/dist/json-durable-queue.d.ts +45 -0
  50. package/dist/json-durable-queue.d.ts.map +1 -0
  51. package/dist/json-durable-queue.js +200 -0
  52. package/dist/json.d.ts +35 -5
  53. package/dist/json.d.ts.map +1 -1
  54. package/dist/json.js +69 -2
  55. package/dist/local-roots.d.ts.map +1 -1
  56. package/dist/local-roots.js +17 -3
  57. package/dist/lock-config.d.ts +10 -0
  58. package/dist/lock-config.d.ts.map +1 -0
  59. package/dist/lock-config.js +12 -0
  60. package/dist/move-path.d.ts +1 -0
  61. package/dist/move-path.d.ts.map +1 -1
  62. package/dist/move-path.js +195 -7
  63. package/dist/output.d.ts +13 -0
  64. package/dist/output.d.ts.map +1 -0
  65. package/dist/output.js +69 -0
  66. package/dist/path-stat.d.ts +4 -0
  67. package/dist/path-stat.d.ts.map +1 -0
  68. package/dist/path-stat.js +15 -0
  69. package/dist/pinned-write.d.ts.map +1 -1
  70. package/dist/pinned-write.js +44 -19
  71. package/dist/private-temp-workspace.d.ts.map +1 -1
  72. package/dist/private-temp-workspace.js +28 -15
  73. package/dist/regular-file.d.ts.map +1 -1
  74. package/dist/regular-file.js +52 -8
  75. package/dist/replace-directory.d.ts.map +1 -1
  76. package/dist/replace-directory.js +5 -4
  77. package/dist/replace-file.d.ts +1 -1
  78. package/dist/replace-file.d.ts.map +1 -1
  79. package/dist/replace-file.js +93 -31
  80. package/dist/root-impl.d.ts.map +1 -1
  81. package/dist/root-impl.js +57 -51
  82. package/dist/safe-path-segment.d.ts +9 -0
  83. package/dist/safe-path-segment.d.ts.map +1 -0
  84. package/dist/safe-path-segment.js +51 -0
  85. package/dist/secret-file.d.ts.map +1 -1
  86. package/dist/secret-file.js +6 -1
  87. package/dist/sibling-temp.d.ts +1 -0
  88. package/dist/sibling-temp.d.ts.map +1 -1
  89. package/dist/sibling-temp.js +32 -7
  90. package/dist/sidecar-lock.d.ts +2 -0
  91. package/dist/sidecar-lock.d.ts.map +1 -1
  92. package/dist/sidecar-lock.js +93 -13
  93. package/dist/store.d.ts +1 -0
  94. package/dist/store.d.ts.map +1 -1
  95. package/dist/store.js +1 -0
  96. package/dist/temp-target.d.ts.map +1 -1
  97. package/dist/temp-target.js +7 -4
  98. package/dist/test-hooks.d.ts +6 -0
  99. package/dist/test-hooks.d.ts.map +1 -1
  100. package/dist/text-atomic.d.ts +7 -0
  101. package/dist/text-atomic.d.ts.map +1 -1
  102. package/dist/text-atomic.js +3 -2
  103. package/dist/trash.d.ts.map +1 -1
  104. package/dist/trash.js +48 -13
  105. package/docs/advanced.md +10 -0
  106. package/docs/atomic.md +33 -5
  107. package/docs/config.md +32 -3
  108. package/docs/index.md +3 -2
  109. package/docs/json-store.md +4 -0
  110. package/docs/json.md +34 -0
  111. package/docs/output.md +92 -0
  112. package/docs/sidecar-lock.md +13 -12
  113. package/docs/store.md +33 -0
  114. package/docs/temp.md +10 -2
  115. package/docs/testing.md +10 -0
  116. package/package.json +7 -2
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,aAAa,EACb,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,qBAAqB,GAC3B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,SAAS,EACT,KAAK,SAAS,EACd,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,GACtB,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,aAAa,EACb,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,qBAAqB,GAC3B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,SAAS,EACT,KAAK,SAAS,EACd,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,GACtB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,wBAAwB,EACxB,0BAA0B,EAC1B,2BAA2B,EAC3B,yBAAyB,EACzB,kCAAkC,EAClC,iCAAiC,EACjC,yBAAyB,EACzB,iCAAiC,EACjC,gBAAgB,EAChB,0BAA0B,EAC1B,KAAK,0BAA0B,EAC/B,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,GAChC,MAAM,yBAAyB,CAAC"}
package/dist/store.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { fileStore, fileStoreSync, } from "./file-store.js";
2
2
  export { jsonStore, } from "./json-store.js";
3
+ export { ackJsonDurableQueueEntry, ensureJsonDurableQueueDirs, jsonDurableQueueEntryExists, loadJsonDurableQueueEntry, loadPendingJsonDurableQueueEntries, moveJsonDurableQueueEntryToFailed, readJsonDurableQueueEntry, resolveJsonDurableQueueEntryPaths, unlinkBestEffort, writeJsonDurableQueueEntry, } from "./json-durable-queue.js";
@@ -1 +1 @@
1
- {"version":3,"file":"temp-target.d.ts","sourceRoot":"","sources":["../src/temp-target.ts"],"names":[],"mappings":"AAMA,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;AAiBF,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,CAWT;AAyBD,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,CAqBpB;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":"AAOA,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;AAiBF,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;AAyBD,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,CAqBpB;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,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import { mkdtemp, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { assertSafePathSegment, sanitizeSafePathSegment } from "./safe-path-segment.js";
4
5
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
5
6
  import { registerTempPathForExit } from "./temp-cleanup.js";
6
7
  function sanitizePrefix(prefix) {
@@ -17,9 +18,9 @@ function sanitizeExtension(extension) {
17
18
  return token ? `.${token}` : "";
18
19
  }
19
20
  export function sanitizeTempFileName(fileName) {
20
- const base = path.basename(fileName).replace(/[^a-zA-Z0-9._-]+/g, "-");
21
- const normalized = base.replace(/^-+|-+$/g, "");
22
- return normalized || "download.bin";
21
+ return sanitizeSafePathSegment(path.basename(fileName), "download.bin", {
22
+ allowDotPrefix: true,
23
+ });
23
24
  }
24
25
  export function buildRandomTempFilePath(params) {
25
26
  const rootDir = resolveTempRoot(params.rootDir);
@@ -29,7 +30,9 @@ export function buildRandomTempFilePath(params) {
29
30
  const now = typeof nowCandidate === "number" && Number.isFinite(nowCandidate)
30
31
  ? Math.trunc(nowCandidate)
31
32
  : Date.now();
32
- const uuid = params.uuid?.trim() || crypto.randomUUID();
33
+ const uuid = params.uuid
34
+ ? assertSafePathSegment(params.uuid.trim(), { label: "temp uuid" })
35
+ : crypto.randomUUID();
33
36
  return path.join(rootDir, `${prefix}-${now}-${uuid}${extension}`);
34
37
  }
35
38
  function isNodeErrorWithCode(err, code) {
@@ -3,6 +3,12 @@ export type FsSafeTestHooks = {
3
3
  afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
4
4
  beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
5
5
  afterOpen?: (filePath: string, handle: FileHandle) => Promise<void> | void;
6
+ beforeArchiveOutputMutation?: (operation: "mkdir" | "chmod", targetPath: string) => Promise<void> | void;
7
+ beforeFileStorePruneDescend?: (dirPath: string) => Promise<void> | void;
8
+ beforeFileStoreSyncPrivateWrite?: (filePath: string) => void;
9
+ beforeRootFallbackMutation?: (operation: "mkdir" | "move" | "remove", targetPath: string) => Promise<void> | void;
10
+ beforeSiblingTempWrite?: (tempPath: string) => Promise<void> | void;
11
+ beforeTrashMove?: (targetPath: string, destPath: string) => void;
6
12
  };
7
13
  export declare function getFsSafeTestHooks(): FsSafeTestHooks | undefined;
8
14
  export declare function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
@@ -1 +1 @@
1
- {"version":3,"file":"test-hooks.d.ts","sourceRoot":"","sources":["../src/test-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,MAAM,MAAM,eAAe,GAAG;IAC5B,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC/D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CAC5E,CAAC;AAQF,wBAAgB,kBAAkB,IAAI,eAAe,GAAG,SAAS,CAEhE;AAED,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,EAAE,eAAe,GAAG,IAAI,CAKzE"}
1
+ {"version":3,"file":"test-hooks.d.ts","sourceRoot":"","sources":["../src/test-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,MAAM,MAAM,eAAe,GAAG;IAC5B,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC/D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC3E,2BAA2B,CAAC,EAAE,CAC5B,SAAS,EAAE,OAAO,GAAG,OAAO,EAC5B,UAAU,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1B,2BAA2B,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACxE,+BAA+B,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7D,0BAA0B,CAAC,EAAE,CAC3B,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,QAAQ,EACtC,UAAU,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1B,sBAAsB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACpE,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE,CAAC;AAQF,wBAAgB,kBAAkB,IAAI,eAAe,GAAG,SAAS,CAEhE;AAED,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,EAAE,eAAe,GAAG,IAAI,CAKzE"}
@@ -2,6 +2,13 @@ export type WriteTextAtomicOptions = {
2
2
  mode?: number;
3
3
  dirMode?: number;
4
4
  trailingNewline?: boolean;
5
+ /**
6
+ * When false, skip the temp-file and parent-directory fsync calls while
7
+ * preserving the temp-file replace/rename behavior.
8
+ *
9
+ * Defaults to true.
10
+ */
11
+ durable?: boolean;
5
12
  };
6
13
  export declare function writeTextAtomic(filePath: string, content: string, options?: WriteTextAtomicOptions): Promise<void>;
7
14
  //# sourceMappingURL=text-atomic.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"text-atomic.d.ts","sourceRoot":"","sources":["../src/text-atomic.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAWf"}
1
+ {"version":3,"file":"text-atomic.d.ts","sourceRoot":"","sources":["../src/text-atomic.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAYf"}
@@ -1,13 +1,14 @@
1
1
  import { replaceFileAtomic } from "./replace-file.js";
2
2
  export async function writeTextAtomic(filePath, content, options) {
3
3
  const payload = options?.trailingNewline && !content.endsWith("\n") ? `${content}\n` : content;
4
+ const durable = options?.durable ?? true;
4
5
  await replaceFileAtomic({
5
6
  filePath,
6
7
  content: payload,
7
8
  mode: options?.mode ?? 0o600,
8
9
  dirMode: options?.dirMode ?? (0o777 & ~process.umask()),
9
10
  copyFallbackOnPermissionError: true,
10
- syncTempFile: true,
11
- syncParentDir: true,
11
+ syncTempFile: durable,
12
+ syncParentDir: durable,
12
13
  });
13
14
  }
@@ -1 +1 @@
1
- {"version":3,"file":"trash.d.ts","sourceRoot":"","sources":["../src/trash.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,sBAAsB,GAAG;IACnC,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;CACjC,CAAC;AA4HF,wBAAsB,eAAe,CACnC,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,MAAM,CAAC,CAcjB"}
1
+ {"version":3,"file":"trash.d.ts","sourceRoot":"","sources":["../src/trash.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,sBAAsB,GAAG;IACnC,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;CACjC,CAAC;AAwKF,wBAAsB,eAAe,CACnC,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,MAAM,CAAC,CAcjB"}
package/dist/trash.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { sameFileIdentity } from "./file-identity.js";
5
+ import { guardedRenameSync, guardedRmSync } from "./guarded-mutation.js";
6
+ import { getFsSafeTestHooks } from "./test-hooks.js";
4
7
  const TRASH_DESTINATION_COLLISION_CODES = new Set(["EEXIST", "ENOTEMPTY", "ERR_FS_CP_EEXIST"]);
5
8
  const TRASH_DESTINATION_RETRY_LIMIT = 4;
6
9
  function getFsErrorCode(error) {
@@ -18,28 +21,56 @@ function isSameOrChildPath(candidate, parent) {
18
21
  return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
19
22
  }
20
23
  function resolveAllowedTrashRoots(allowedRoots) {
21
- const roots = [...(allowedRoots ?? [os.homedir(), os.tmpdir()])].map((root) => {
24
+ const roots = [...(allowedRoots ?? [os.homedir(), os.tmpdir()])].flatMap((root) => {
25
+ const lexicalRoot = path.resolve(root);
22
26
  try {
23
- return path.resolve(fs.realpathSync.native(root));
27
+ // Keep both spellings: broken symlink targets cannot be realpathed and
28
+ // may only compare equal to the caller's lexical allowed root.
29
+ return [path.resolve(fs.realpathSync.native(root)), lexicalRoot];
24
30
  }
25
31
  catch {
26
- return path.resolve(root);
32
+ return [lexicalRoot];
27
33
  }
28
34
  });
29
35
  return [...new Set(roots)];
30
36
  }
31
- function assertAllowedTrashTarget(targetPath, allowedRoots) {
32
- let resolvedTargetPath = path.resolve(targetPath);
37
+ function resolveTrashTargetPath(targetPath) {
33
38
  try {
34
- resolvedTargetPath = path.resolve(fs.realpathSync.native(targetPath));
39
+ return { path: path.resolve(fs.realpathSync.native(targetPath)), resolved: true };
35
40
  }
36
41
  catch {
37
- // The subsequent move will surface missing or inaccessible targets.
42
+ // Broken symlinks are valid trash targets. Fall back to the lexical path,
43
+ // then rely on lstat identity so the move renames the symlink itself.
44
+ return { path: path.resolve(targetPath), resolved: false };
38
45
  }
46
+ }
47
+ function assertAllowedTrashTarget(targetPath, allowedRoots) {
48
+ const stat = fs.lstatSync(path.resolve(targetPath));
49
+ const resolvedTarget = resolveTrashTargetPath(targetPath);
50
+ const resolvedTargetPath = resolvedTarget.path;
39
51
  const isAllowed = resolveAllowedTrashRoots(allowedRoots).some((root) => resolvedTargetPath !== root && isSameOrChildPath(resolvedTargetPath, root));
40
52
  if (!isAllowed) {
41
53
  throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
42
54
  }
55
+ return {
56
+ path: path.resolve(targetPath),
57
+ realPath: resolvedTargetPath,
58
+ realPathResolved: resolvedTarget.resolved,
59
+ stat,
60
+ };
61
+ }
62
+ function assertTrashTargetGuard(guard) {
63
+ const stat = fs.lstatSync(guard.path);
64
+ if (!sameFileIdentity(stat, guard.stat)) {
65
+ throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
66
+ }
67
+ const current = resolveTrashTargetPath(guard.path);
68
+ if (guard.realPathResolved && (!current.resolved || current.path !== guard.realPath)) {
69
+ throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
70
+ }
71
+ if (!guard.realPathResolved && current.resolved) {
72
+ throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
73
+ }
43
74
  }
44
75
  function resolveTrashDir() {
45
76
  const homeDir = os.homedir();
@@ -86,9 +117,11 @@ function reserveTrashDestination(trashDir, base, timestamp) {
86
117
  }
87
118
  return resolveContainedPath(container, base);
88
119
  }
89
- function movePathToDestination(targetPath, dest) {
120
+ function movePathToDestination(target, dest) {
121
+ getFsSafeTestHooks()?.beforeTrashMove?.(target.path, dest);
122
+ assertTrashTargetGuard(target);
90
123
  try {
91
- fs.renameSync(targetPath, dest);
124
+ guardedRenameSync({ from: target.path, to: dest });
92
125
  return true;
93
126
  }
94
127
  catch (error) {
@@ -100,8 +133,10 @@ function movePathToDestination(targetPath, dest) {
100
133
  }
101
134
  }
102
135
  try {
103
- fs.cpSync(targetPath, dest, { recursive: true, force: false, errorOnExist: true });
104
- fs.rmSync(targetPath, { recursive: true, force: false });
136
+ assertTrashTargetGuard(target);
137
+ fs.cpSync(target.path, dest, { recursive: true, force: false, errorOnExist: true });
138
+ assertTrashTargetGuard(target);
139
+ guardedRmSync({ target: target.path, recursive: true, force: false, verifyAfter: false });
105
140
  return true;
106
141
  }
107
142
  catch (error) {
@@ -114,12 +149,12 @@ function movePathToDestination(targetPath, dest) {
114
149
  export async function movePathToTrash(targetPath, options = {}) {
115
150
  // Avoid resolving external trash helpers through the service PATH during cleanup.
116
151
  const base = trashBaseName(targetPath);
117
- assertAllowedTrashTarget(targetPath, options.allowedRoots);
152
+ const target = assertAllowedTrashTarget(targetPath, options.allowedRoots);
118
153
  const trashDir = resolveTrashDir();
119
154
  const timestamp = Date.now();
120
155
  for (let attempt = 0; attempt < TRASH_DESTINATION_RETRY_LIMIT; attempt += 1) {
121
156
  const dest = reserveTrashDestination(trashDir, base, timestamp);
122
- if (movePathToDestination(targetPath, dest)) {
157
+ if (movePathToDestination(target, dest)) {
123
158
  return dest;
124
159
  }
125
160
  }
package/docs/advanced.md CHANGED
@@ -38,9 +38,19 @@ The exports group into a handful of themes. Each documented helper has its own p
38
38
  | Export | Page | Notes |
39
39
  |---|---|---|
40
40
  | `assertAbsolutePathInput` | – | Validate a caller-supplied absolute path string. |
41
+ | `ensureAbsoluteDirectory`, `EnsureAbsoluteDirectoryOptions`, `EnsureAbsoluteDirectoryResult` | – | Create a trusted absolute directory path one segment at a time, rejecting symlink or non-directory segments. |
41
42
  | `canonicalPathFromExistingAncestor`, `findExistingAncestor` | – | Canonicalize without requiring the leaf to exist. |
42
43
  | `resolveAbsolutePathForRead`, `resolveAbsolutePathForWrite`, `ResolvedAbsolutePath`, `ResolvedWritableAbsolutePath`, `AbsolutePathSymlinkPolicy` | – | Validate an absolute path against a symlink policy before opening. |
43
44
 
45
+ `ensureAbsoluteDirectory()` is for paths you already intend to trust as absolute
46
+ locations, such as a configured output root. It does not enforce a root boundary;
47
+ use `pathScope().ensureDir()` or `ensureDirectoryWithinRoot()` when the caller
48
+ supplies a path that must stay under a root.
49
+
50
+ The helper returns `{ ok: false, code, error }` for path-policy failures such as
51
+ relative paths, symlinks, non-directories, or directory swaps during creation.
52
+ Operational filesystem failures such as permissions or I/O errors are rethrown.
53
+
44
54
  ### Files and identity
45
55
 
46
56
  | Export | Page | Notes |
package/docs/atomic.md CHANGED
@@ -68,7 +68,12 @@ If `beforeRename` throws, the rename is skipped and the temp file is removed —
68
68
 
69
69
  ### `EPERM` and copy fallback
70
70
 
71
- On systems where `rename` fails with `EPERM`/`EEXIST`, pass `copyFallbackOnPermissionError: true` to fall back to copy + unlink. The fallback refuses symlink destinations before copying so it does not write through a replaced destination link.
71
+ On systems where `rename` fails with `EPERM`/`EEXIST`, pass
72
+ `copyFallbackOnPermissionError: true` to fall back to a non-atomic copy
73
+ replacement. The fallback removes the old destination, opens the replacement
74
+ with exclusive/no-follow flags where the platform supports them, and refuses
75
+ known symlink destinations so it does not write through a replaced destination
76
+ link.
72
77
 
73
78
  ### Sync variant
74
79
 
@@ -109,21 +114,44 @@ await writeTextAtomic("/srv/workspace/rendered.md", rendered, {
109
114
  });
110
115
  ```
111
116
 
117
+ Options:
118
+
119
+ ```ts
120
+ type WriteTextAtomicOptions = {
121
+ mode?: number; // file mode (default 0o600)
122
+ dirMode?: number; // mode for parent dirs created on demand
123
+ trailingNewline?: boolean; // append "\n" if missing
124
+ durable?: boolean; // default true; false skips temp/parent fsync
125
+ };
126
+ ```
127
+
128
+ `durable: false` keeps the sibling-temp replace/rename behavior but skips the
129
+ temp-file and parent-directory `fsync` calls. Use it only for reconstructible
130
+ metadata where lower latency matters more than crash-durability.
131
+
112
132
  ## `movePathWithCopyFallback`
113
133
 
114
- Rename a path. If the rename fails with `EXDEV` (cross-device) or `EPERM`, fall back to copy + remove. Preserves atomicity at the destination by writing the copy through `replaceFileAtomic` (for files) or staged-rename (for directories).
134
+ Rename a path. If the rename fails with `EXDEV` (cross-device), fall back to
135
+ copying into a staged sibling path, renaming that staged path into place, and
136
+ then removing only the source entries that were copied. The fallback avoids
137
+ buffering regular files into memory and does not tighten the destination parent
138
+ directory mode.
115
139
 
116
140
  ```ts
117
141
  import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
118
142
 
119
143
  await movePathWithCopyFallback({
120
- source: "/srv/cache/blob.bin",
121
- destination: "/srv/persistent/blob.bin",
122
- overwrite: true,
144
+ from: "/srv/cache/blob.bin",
145
+ sourceHardlinks: "reject",
146
+ to: "/srv/persistent/blob.bin",
123
147
  });
124
148
  ```
125
149
 
126
150
  Use it when source and destination might live on different filesystems (containers, tmpfs, separate volumes).
151
+ If another writer changes source entries during the fallback, the staged copy
152
+ throws `ESTALE` before commit when possible. If the destination has already
153
+ been committed, cleanup still preserves the changed source entries and throws
154
+ `ESTALE`.
127
155
 
128
156
  ## Difference from `root()`
129
157
 
package/docs/config.md CHANGED
@@ -1,22 +1,25 @@
1
1
  ---
2
2
  title: Config
3
- description: "Process-global configuration for the optional Python helper used by fs-safe on POSIX."
3
+ description: "Process-global defaults for optional fs-safe helpers."
4
4
  ---
5
5
 
6
6
  # `@openclaw/fs-safe/config`
7
7
 
8
- Process-global configuration knobs for the optional persistent Python helper that backs POSIX fd-relative operations in `root()`. The whole helper policy is described in the [Python helper policy](python-helper.md); this page is the API reference.
8
+ Process-global configuration knobs for optional fs-safe helpers. The Python helper policy is described in the [Python helper policy](python-helper.md); this page is the API reference.
9
9
 
10
10
  ```ts
11
11
  import {
12
12
  configureFsSafePython,
13
+ configureFsSafeLocks,
13
14
  getFsSafePythonConfig,
15
+ getFsSafeLockConfig,
16
+ type FsSafeLockConfig,
14
17
  type FsSafePythonConfig,
15
18
  type FsSafePythonMode,
16
19
  } from "@openclaw/fs-safe/config";
17
20
  ```
18
21
 
19
- `configureFsSafePython` is also re-exported from the main entry point, so `import { configureFsSafePython } from "@openclaw/fs-safe"` works too. Prefer the subpath when you only need helper configuration and want the smallest import surface.
22
+ These functions are also re-exported from the main entry point. Prefer the subpath when you only need helper configuration and want the smallest import surface.
20
23
 
21
24
  ## `configureFsSafePython(config)`
22
25
 
@@ -47,6 +50,31 @@ function getFsSafePythonConfig(): FsSafePythonConfig;
47
50
 
48
51
  Return the effective configuration: programmatic overrides win, then env vars, then the package default (`auto`).
49
52
 
53
+ ## `configureFsSafeLocks(config)`
54
+
55
+ ```ts
56
+ function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
57
+
58
+ type FsSafeLockConfig = {
59
+ staleRecovery: "fail-closed";
60
+ staleMs?: number;
61
+ timeoutMs?: number;
62
+ retry?: FileLockRetryOptions;
63
+ };
64
+ ```
65
+
66
+ Set process-wide defaults for sidecar lock options. This does **not** turn locking on globally; callers still need to pass `lock: true` or a lock options object for the specific JSON store/resource that needs cross-process coordination.
67
+
68
+ `staleRecovery` currently supports `"fail-closed"` only. Stale third-party sidecars are not deleted by path because Node cannot atomically bind that deletion to the file that was inspected.
69
+
70
+ ## `getFsSafeLockConfig()`
71
+
72
+ ```ts
73
+ function getFsSafeLockConfig(): FsSafeLockConfig;
74
+ ```
75
+
76
+ Return the current sidecar lock defaults.
77
+
50
78
  ## Environment variables
51
79
 
52
80
  The same policy can be set without code:
@@ -61,5 +89,6 @@ OpenClaw compatibility aliases are accepted: `OPENCLAW_FS_SAFE_PYTHON_MODE`, `OP
61
89
  ## Related pages
62
90
 
63
91
  - [Python helper policy](python-helper.md) — when to pick `auto`, `off`, or `require`, and what each mode protects.
92
+ - [File lock](sidecar-lock.md) — the per-resource lock API that consumes lock defaults.
64
93
  - [Root API](root.md) — the API whose POSIX hardening the helper backs.
65
94
  - [Errors](errors.md) — `helper-unavailable` and `helper-failed`.
package/docs/index.md CHANGED
@@ -49,9 +49,10 @@ await fs.remove("notes/archive/today.txt");
49
49
  | Surface | Use it for |
50
50
  |---|---|
51
51
  | [`root()`](root.md) | One boundary for read/write/move/remove inside a trusted directory. |
52
- | [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper configuration (`configureFsSafePython`, `getFsSafePythonConfig`). |
52
+ | [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper and lock-option defaults. |
53
53
  | [Python helper policy](python-helper.md) | Choose `auto`, `off`, or `require` for POSIX fd-relative hardening. |
54
54
  | [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
55
+ | [`writeExternalFileWithinRoot`](output.md) | Stage external-library file output in private temp storage, then finalize under a root. |
55
56
  | [`writeJson` / `readJson*`](json.md) | JSON state files with strict and lenient read variants. |
56
57
  | [`@openclaw/fs-safe/store`](store.md) | Overview of `fileStore`, `fileStoreSync`, and `jsonStore`. |
57
58
  | [`jsonStore`](json-store.md) | Single JSON state file with explicit fallback, atomic writes, and optional locking. |
@@ -63,7 +64,7 @@ await fs.remove("notes/archive/today.txt");
63
64
  | [`extractArchive`](archive.md) | ZIP/TAR extraction with size, count, link, and traversal limits. |
64
65
  | [Secret files](secret-file.md) | Mode-0600 credentials with size and TOCTOU defense. |
65
66
  | [Permissions](permissions.md) | POSIX mode and Windows ACL inspection/remediation helpers. |
66
- | [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and stale-lock recovery. |
67
+ | [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
67
68
  | [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
68
69
  | [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
69
70
  | [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
@@ -53,6 +53,7 @@ type JsonStoreLockOptions = {
53
53
  staleMs?: number; // default 30_000
54
54
  timeoutMs?: number; // default 30_000
55
55
  retry?: FileLockRetryOptions;
56
+ staleRecovery?: "fail-closed";
56
57
  managerKey?: string; // default `fs-safe.json-store:<filePath>`
57
58
  };
58
59
 
@@ -130,6 +131,7 @@ const counter = jsonStore<{ count: number }>({
130
131
  lock: {
131
132
  staleMs: 60_000,
132
133
  timeoutMs: 10_000,
134
+ staleRecovery: "fail-closed",
133
135
  retry: { retries: 30, minTimeout: 100, maxTimeout: 5_000, randomize: true },
134
136
  },
135
137
  });
@@ -137,6 +139,8 @@ const counter = jsonStore<{ count: number }>({
137
139
 
138
140
  When `lock` is falsy, `read` / `write` / `update` are unlocked. The `update` shape is still useful — it gives you a single function for the read-modify-write pattern — but it offers no concurrency guarantees if other processes also write to the file.
139
141
 
142
+ Process-wide lock defaults from `configureFsSafeLocks()` apply only after locking is explicitly enabled. They do not make JSON stores lock by default.
143
+
140
144
  The default `managerKey` namespaces the in-process `FileLockManager` per absolute file path, so two `jsonStore` calls on the same file share lock state automatically.
141
145
 
142
146
  ## Common patterns
package/docs/json.md CHANGED
@@ -9,6 +9,9 @@ import {
9
9
  readJsonIfExists,
10
10
  readJsonSync,
11
11
  tryReadJsonSync,
12
+ readRootJsonSync,
13
+ readRootJsonObjectSync,
14
+ readRootStructuredFileSync,
12
15
  writeJson,
13
16
  writeJsonSync,
14
17
  JsonFileReadError,
@@ -69,6 +72,32 @@ Synchronous strict reader. Throws `JsonFileReadError` on missing or invalid inpu
69
72
 
70
73
  Synchronous, generic, lenient. Returns `T | null`. Useful in boot paths where you want a typed result without async.
71
74
 
75
+ ## Root-bounded structured reads
76
+
77
+ Use the root-bounded readers when you already have a trusted root directory and
78
+ a caller-controlled relative path, but you only need one synchronous structured
79
+ read instead of a full `root()` handle.
80
+
81
+ ```ts
82
+ const result = readRootJsonObjectSync({
83
+ rootDir: "/safe/workspace",
84
+ relativePath: "plugin/openclaw.plugin.json",
85
+ boundaryLabel: "plugin manifest",
86
+ });
87
+
88
+ if (!result.ok) {
89
+ // reason is "open", "parse", or "invalid"
90
+ throw new Error(result.reason);
91
+ }
92
+
93
+ console.log(result.value);
94
+ ```
95
+
96
+ `readRootJsonSync()` parses any JSON value. `readRootJsonObjectSync()` only
97
+ accepts objects. `readRootStructuredFileSync()` accepts a custom parser and
98
+ validator so callers can layer JSON5, TOML, YAML, or domain-specific validation
99
+ without making `fs-safe` depend on those formats.
100
+
72
101
  ## Writing
73
102
 
74
103
  ### `writeJson(filePath, value, options?)`
@@ -86,9 +115,14 @@ type WriteJsonOptions = {
86
115
  mode?: number; // file mode (default 0o600)
87
116
  dirMode?: number; // mode for parent dirs created on demand
88
117
  trailingNewline?: boolean; // append "\n" if missing (default false)
118
+ durable?: boolean; // default true; false skips temp/parent fsync
89
119
  };
90
120
  ```
91
121
 
122
+ `durable: false` preserves atomic temp-file replacement but skips the temp-file
123
+ and parent-directory `fsync` calls. Use it only for reconstructible JSON state
124
+ where lower latency matters more than crash-durability.
125
+
92
126
  ### `writeJsonSync(pathname, data)`
93
127
 
94
128
  Synchronous variant. Convenience wrapper that uses the sync atomic-write path with sensible defaults.
package/docs/output.md ADDED
@@ -0,0 +1,92 @@
1
+ # External outputs
2
+
3
+ `@openclaw/fs-safe/output` covers the case where another library insists on
4
+ writing to an absolute path you give it. Browser downloads, renderers, media
5
+ tools, and native libraries often have this shape:
6
+
7
+ ```ts
8
+ import { writeExternalFileWithinRoot } from "@openclaw/fs-safe/output";
9
+
10
+ await writeExternalFileWithinRoot({
11
+ rootDir: "/srv/workspace/downloads",
12
+ path: "reports/today.pdf",
13
+ write: async (filePath) => {
14
+ await download.saveAs(filePath);
15
+ },
16
+ });
17
+ ```
18
+
19
+ The external writer never receives the final destination path. It receives a
20
+ private temp file path instead. After the callback returns, fs-safe copies that
21
+ staged file into the requested target through the same root boundary used by
22
+ `Root.copyIn()`.
23
+
24
+ ## Signature
25
+
26
+ ```ts
27
+ function writeExternalFileWithinRoot<T = void>(
28
+ options: ExternalFileWriteOptions<T>,
29
+ ): Promise<ExternalFileWriteResult<T>>;
30
+
31
+ type ExternalFileWriteOptions<T = void> = {
32
+ rootDir: string;
33
+ path: string; // relative or absolute, but must stay under rootDir
34
+ write: (filePath: string) => Promise<T>;
35
+ maxBytes?: number;
36
+ mode?: number;
37
+ };
38
+
39
+ type ExternalFileWriteResult<T = void> = {
40
+ path: string; // final absolute path under the canonical root
41
+ result: T; // value returned by write()
42
+ };
43
+ ```
44
+
45
+ The requested `path` must name a file. Missing destination parents are created
46
+ by the helper because the operation is "produce this output file under the
47
+ root"; callers should choose the filename before calling this API.
48
+
49
+ Use `maxBytes` when the external producer can create arbitrarily large files.
50
+ Use `mode` when the finalized file needs a specific POSIX mode. Both are
51
+ enforced during the `Root.copyIn()` finalization step, after the external writer
52
+ has produced the staged file and before the final target is committed.
53
+
54
+ ## Why not pass the final path to the library?
55
+
56
+ If a target parent can be swapped after validation, handing an external library
57
+ the final path can make the library write outside the intended root before
58
+ fs-safe has a chance to finalize or reject the operation. This helper stages in
59
+ a private temp workspace first, then finalizes with `Root.copyIn()`. That keeps
60
+ the trust-boundary write inside fs-safe's root-aware copy/atomic-write path.
61
+
62
+ ## Browser download example
63
+
64
+ ```ts
65
+ const outputPath = requestedOutputPath || sanitizeBrowserSuggestedName(suggestedFilename);
66
+
67
+ await writeExternalFileWithinRoot({
68
+ rootDir: downloadsRoot,
69
+ path: outputPath,
70
+ maxBytes: 512 * 1024 * 1024,
71
+ write: async (filePath) => {
72
+ await download.saveAs(filePath);
73
+ },
74
+ });
75
+ ```
76
+
77
+ The chosen path may be absolute if it is already inside `downloadsRoot`, or
78
+ relative to `downloadsRoot`. Traversal, symlink parent escapes, hardlinked final
79
+ targets, over-large staged files, and missing temp files surface as
80
+ `FsSafeError`s.
81
+
82
+ This helper is not the right fit when the final filename depends on inspecting
83
+ the produced bytes. In that case, write to a private temp workspace, sniff or
84
+ validate the file, choose the final name, then copy or write into the root with
85
+ the normal root APIs.
86
+
87
+ ## See also
88
+
89
+ - [Root writes](writing.md) — `write`, `copyIn`, `move`, and `mkdir`.
90
+ - [Temp workspaces](temp.md) — private scratch directories for longer workflows.
91
+ - [`pathScope()`](path-scope.md) — validation-only helper when you must pass an
92
+ absolute path directly to another library.