@openclaw/fs-safe 0.1.1 → 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 (207) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +58 -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 +25 -0
  26. package/dist/file-store-boundary.d.ts.map +1 -0
  27. package/dist/file-store-boundary.js +168 -0
  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 +122 -125
  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/path.d.ts +1 -0
  70. package/dist/path.d.ts.map +1 -1
  71. package/dist/path.js +6 -3
  72. package/dist/pinned-write.d.ts.map +1 -1
  73. package/dist/pinned-write.js +44 -19
  74. package/dist/private-file-store.d.ts +5 -7
  75. package/dist/private-file-store.d.ts.map +1 -1
  76. package/dist/private-file-store.js +21 -34
  77. package/dist/private-temp-workspace.d.ts.map +1 -1
  78. package/dist/private-temp-workspace.js +28 -15
  79. package/dist/regular-file.d.ts.map +1 -1
  80. package/dist/regular-file.js +52 -8
  81. package/dist/replace-directory.d.ts.map +1 -1
  82. package/dist/replace-directory.js +5 -4
  83. package/dist/replace-file.d.ts +1 -1
  84. package/dist/replace-file.d.ts.map +1 -1
  85. package/dist/replace-file.js +93 -31
  86. package/dist/root-context.d.ts +23 -0
  87. package/dist/root-context.d.ts.map +1 -0
  88. package/dist/root-context.js +62 -0
  89. package/dist/root-errors.d.ts +4 -0
  90. package/dist/root-errors.d.ts.map +1 -0
  91. package/dist/root-errors.js +21 -0
  92. package/dist/root-impl.d.ts +108 -0
  93. package/dist/root-impl.d.ts.map +1 -0
  94. package/dist/{safe-root.js → root-impl.js} +484 -265
  95. package/dist/root.d.ts +1 -107
  96. package/dist/root.d.ts.map +1 -1
  97. package/dist/root.js +1 -1332
  98. package/dist/safe-path-segment.d.ts +9 -0
  99. package/dist/safe-path-segment.d.ts.map +1 -0
  100. package/dist/safe-path-segment.js +51 -0
  101. package/dist/secret-file.d.ts.map +1 -1
  102. package/dist/secret-file.js +6 -1
  103. package/dist/sibling-temp.d.ts +1 -0
  104. package/dist/sibling-temp.d.ts.map +1 -1
  105. package/dist/sibling-temp.js +32 -7
  106. package/dist/sidecar-lock.d.ts +2 -0
  107. package/dist/sidecar-lock.d.ts.map +1 -1
  108. package/dist/sidecar-lock.js +93 -13
  109. package/dist/store.d.ts +1 -0
  110. package/dist/store.d.ts.map +1 -1
  111. package/dist/store.js +1 -0
  112. package/dist/temp-target.d.ts.map +1 -1
  113. package/dist/temp-target.js +7 -4
  114. package/dist/test-hooks.d.ts +6 -0
  115. package/dist/test-hooks.d.ts.map +1 -1
  116. package/dist/text-atomic.d.ts +7 -0
  117. package/dist/text-atomic.d.ts.map +1 -1
  118. package/dist/text-atomic.js +3 -2
  119. package/dist/trash.d.ts.map +1 -1
  120. package/dist/trash.js +48 -13
  121. package/docs/advanced.md +110 -0
  122. package/docs/archive.md +199 -0
  123. package/docs/atomic.md +191 -0
  124. package/docs/config.md +94 -0
  125. package/docs/contributing.md +80 -0
  126. package/docs/errors.md +154 -0
  127. package/docs/file-store.md +213 -0
  128. package/docs/filename.md +0 -0
  129. package/docs/index.md +77 -0
  130. package/docs/install-path.md +167 -0
  131. package/docs/install.md +150 -0
  132. package/docs/json-store.md +204 -0
  133. package/docs/json.md +192 -0
  134. package/docs/local-roots.md +164 -0
  135. package/docs/output.md +92 -0
  136. package/docs/path-scope.md +130 -0
  137. package/docs/path.md +191 -0
  138. package/docs/permissions.md +95 -0
  139. package/docs/private-file-store.md +45 -0
  140. package/docs/python-helper.md +103 -0
  141. package/docs/quickstart.md +147 -0
  142. package/docs/reading.md +174 -0
  143. package/docs/regular-file.md +175 -0
  144. package/docs/root.md +205 -0
  145. package/docs/secret-file.md +154 -0
  146. package/docs/secure-file.md +82 -0
  147. package/docs/security-model.md +99 -0
  148. package/docs/sidecar-lock.md +230 -0
  149. package/docs/store.md +77 -0
  150. package/docs/temp.md +284 -0
  151. package/docs/test-hooks.md +74 -0
  152. package/docs/testing.md +178 -0
  153. package/docs/timing.md +121 -0
  154. package/docs/types.md +155 -0
  155. package/docs/walk.md +63 -0
  156. package/docs/writing.md +222 -0
  157. package/package.json +10 -2
  158. package/dist/archive-utils.d.ts +0 -3
  159. package/dist/archive-utils.d.ts.map +0 -1
  160. package/dist/archive-utils.js +0 -26
  161. package/dist/boundary-file-read.d.ts +0 -44
  162. package/dist/boundary-file-read.d.ts.map +0 -1
  163. package/dist/boundary-file-read.js +0 -129
  164. package/dist/boundary-path.d.ts +0 -39
  165. package/dist/boundary-path.d.ts.map +0 -1
  166. package/dist/boundary-path.js +0 -598
  167. package/dist/fs-pinned-path-helper.d.ts +0 -7
  168. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  169. package/dist/fs-pinned-path-helper.js +0 -182
  170. package/dist/fs-pinned-write-helper.d.ts +0 -21
  171. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  172. package/dist/fs-pinned-write-helper.js +0 -263
  173. package/dist/hardlink-guards.d.ts +0 -7
  174. package/dist/hardlink-guards.d.ts.map +0 -1
  175. package/dist/hardlink-guards.js +0 -30
  176. package/dist/install-safe-path.d.ts +0 -20
  177. package/dist/install-safe-path.d.ts.map +0 -1
  178. package/dist/install-safe-path.js +0 -94
  179. package/dist/json-file.d.ts +0 -3
  180. package/dist/json-file.d.ts.map +0 -1
  181. package/dist/json-file.js +0 -123
  182. package/dist/json-files.d.ts +0 -20
  183. package/dist/json-files.d.ts.map +0 -1
  184. package/dist/json-files.js +0 -153
  185. package/dist/path-alias-guards.d.ts +0 -19
  186. package/dist/path-alias-guards.d.ts.map +0 -1
  187. package/dist/path-alias-guards.js +0 -21
  188. package/dist/path-guards.d.ts +0 -7
  189. package/dist/path-guards.d.ts.map +0 -1
  190. package/dist/path-guards.js +0 -49
  191. package/dist/path-safety.d.ts +0 -12
  192. package/dist/path-safety.d.ts.map +0 -1
  193. package/dist/path-safety.js +0 -50
  194. package/dist/safe-open-sync.d.ts +0 -24
  195. package/dist/safe-open-sync.d.ts.map +0 -1
  196. package/dist/safe-open-sync.js +0 -71
  197. package/dist/safe-root.d.ts +0 -123
  198. package/dist/safe-root.d.ts.map +0 -1
  199. package/dist/secure-temp-workspace.d.ts +0 -25
  200. package/dist/secure-temp-workspace.d.ts.map +0 -1
  201. package/dist/secure-temp-workspace.js +0 -136
  202. package/dist/sibling-temp-file.d.ts +0 -16
  203. package/dist/sibling-temp-file.d.ts.map +0 -1
  204. package/dist/sibling-temp-file.js +0 -73
  205. package/dist/sibling-temp-write.d.ts +0 -8
  206. package/dist/sibling-temp-write.d.ts.map +0 -1
  207. package/dist/sibling-temp-write.js +0 -40
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
  }
@@ -0,0 +1,110 @@
1
+ ---
2
+ title: Advanced
3
+ description: "Lower-level composition helpers under @openclaw/fs-safe/advanced. Less stable than focused public subpaths."
4
+ ---
5
+
6
+ # `@openclaw/fs-safe/advanced`
7
+
8
+ Composition primitives that OpenClaw uses to build higher-level APIs. They are public — semver applies — but treated as a less stable surface than the focused subpaths (`root`, `json`, `store`, `temp`, `archive`, `errors`). Reach for them only when you are building a primitive of your own and the focused subpaths do not cover it.
9
+
10
+ ```ts
11
+ import {
12
+ pathScope,
13
+ withTimeout,
14
+ pathExists,
15
+ sanitizeUntrustedFileName,
16
+ // …
17
+ } from "@openclaw/fs-safe/advanced";
18
+ ```
19
+
20
+ ## What lives here
21
+
22
+ The exports group into a handful of themes. Each documented helper has its own page; everything else is reference-only and tracked here.
23
+
24
+ ### Path scopes and root paths
25
+
26
+ | Export | Page | Notes |
27
+ |---|---|---|
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. |
30
+ | `resolvePathWithinRoot`, `resolvePathsWithinRoot` | – | Resolve one or many relative paths against a trusted root. |
31
+ | `resolveExistingPathsWithinRoot`, `resolveStrictExistingPathsWithinRoot` | – | Same, but require the targets to exist. |
32
+ | `resolveWritablePathWithinRoot` | – | Resolve a write target inside a root. |
33
+ | `resolveRootPath`, `resolveRootPathSync`, `ResolvedRootPath`, `ROOT_PATH_ALIAS_POLICIES`, `RootPathAliasPolicy` | – | Resolve a root directory honoring alias policy. |
34
+ | `resolvePathViaExistingAncestorSync` | – | Walk to an existing ancestor for paths whose tail does not yet exist. |
35
+
36
+ ### Absolute path helpers
37
+
38
+ | Export | Page | Notes |
39
+ |---|---|---|
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. |
42
+ | `canonicalPathFromExistingAncestor`, `findExistingAncestor` | – | Canonicalize without requiring the leaf to exist. |
43
+ | `resolveAbsolutePathForRead`, `resolveAbsolutePathForWrite`, `ResolvedAbsolutePath`, `ResolvedWritableAbsolutePath`, `AbsolutePathSymlinkPolicy` | – | Validate an absolute path against a symlink policy before opening. |
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
+
54
+ ### Files and identity
55
+
56
+ | Export | Page | Notes |
57
+ |---|---|---|
58
+ | `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level no-follow open routed through the root-file path. |
59
+ | `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
60
+ | `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
61
+ | `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
62
+ | `assertNoSymlinkParents`, `assertNoSymlinkParentsSync`, `AssertNoSymlinkParentsOptions` | – | Reject paths whose ancestor chain contains symlinks. |
63
+ | `assertNoHardlinkedFinalPath`, `assertNoPathAliasEscape`, `PATH_ALIAS_POLICIES`, `PathAliasPolicy` | – | Hardlink/alias defense building blocks. |
64
+
65
+ ### Local roots and file URLs
66
+
67
+ | Export | Page | Notes |
68
+ |---|---|---|
69
+ | `resolveLocalPathFromRootsSync`, `readLocalFileFromRoots`, related options/result types | [local-roots.md](local-roots.md) | Resolve a path against a list of allowed local roots. |
70
+ | `assertNoWindowsNetworkPath`, `basenameFromMediaSource`, `hasEncodedFileUrlSeparator`, `isWindowsDriveLetterPath`, `isWindowsNetworkPath`, `safeFileURLToPath`, `trySafeFileURLToPath` | – | Defensive helpers around Windows paths and `file://` URLs. |
71
+
72
+ ### Install paths and filenames
73
+
74
+ | Export | Page | Notes |
75
+ |---|---|---|
76
+ | `safeDirName`, `safePathSegmentHashed`, `resolveSafeInstallDir`, `assertCanonicalPathWithinBase` | [install-path.md](install-path.md) | Build install-target directories from caller-supplied identifiers. |
77
+ | `sanitizeUntrustedFileName` | [filename.md](filename.md) | Coerce an untrusted string into a safe filename. |
78
+ | `resolveHomeRelativePath` | – | Expand `~`-prefixed paths. |
79
+
80
+ ### Temp targets and sibling-temp writes
81
+
82
+ | Export | Page | Notes |
83
+ |---|---|---|
84
+ | `tempFile`, `withTempFile`, `TempFile`, `buildRandomTempFilePath`, `sanitizeTempFileName` | [temp.md](temp.md) | One-file temp primitive; prefer `tempWorkspace` from `@openclaw/fs-safe/temp` for the stable surface. |
85
+ | `writeSiblingTempFile`, `writeViaSiblingTempPath`, `WriteSiblingTempFileOptions`, `WriteSiblingTempFileResult` | – | Sibling-temp write building block used by `replaceFileAtomic`. |
86
+
87
+ ### Permissions
88
+
89
+ | Export | Page | Notes |
90
+ |---|---|---|
91
+ | `formatPosixMode` | [permissions.md](permissions.md) | Format a POSIX mode bitmask. |
92
+ | `inspectWindowsAcl`, `summarizeWindowsAcl`, `formatWindowsAclSummary`, `parseIcaclsOutput`, `resolveWindowsUserPrincipal`, `createIcaclsResetCommand`, `formatIcaclsResetCommand`, `IcaclsResetCommandOptions`, `PermissionExec`, `WindowsAclEntry`, `WindowsAclSummary` | [permissions.md](permissions.md) | Windows ACL inspection and remediation. |
93
+
94
+ ### Concurrency, timing, trash
95
+
96
+ | Export | Page | Notes |
97
+ |---|---|---|
98
+ | `createAsyncLock` | – | In-process async lock (separate from cross-process file locks). |
99
+ | `withTimeout` | [timing.md](timing.md) | Wrap a promise with a timeout that raises `FsSafeError("timeout")`. |
100
+ | `movePathToTrash`, `MovePathToTrashOptions` | – | Best-effort move to the platform trash. |
101
+
102
+ ## Stability
103
+
104
+ Items in this surface can change shape between minor versions if a higher-level primitive needs them to. Pin to a minor version if you depend on a specific helper, or open an issue at the [GitHub repo](https://github.com/openclaw/fs-safe) and we will discuss promoting it to a focused subpath.
105
+
106
+ ## Related pages
107
+
108
+ - [Root API](root.md) — built on top of these helpers.
109
+ - [Errors](errors.md) — every helper here surfaces failures as `FsSafeError`.
110
+ - [Security model](security-model.md) — what the underlying boundary checks promise.
@@ -0,0 +1,199 @@
1
+ # Archive extraction
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. Extraction stages into a private directory and 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
+
5
+ Archive extraction uses optional runtime dependencies: `jszip` for ZIP and `tar`
6
+ for TAR. Installs that omit optional dependencies can still import this subpath,
7
+ inspect archive kinds, and use pure path/limit helpers, but extraction or ZIP
8
+ loading fails with a clear message until the matching optional dependency is
9
+ installed.
10
+
11
+ Some package managers and CI installs skip optional dependencies
12
+ (`--no-optional`, `--omit=optional`, or equivalent). If an archive helper throws
13
+ that an optional archive dependency is not installed, install `jszip` and/or
14
+ `tar` explicitly in the consuming package.
15
+
16
+ ```ts
17
+ import { extractArchive, resolveArchiveKind } from "@openclaw/fs-safe/archive";
18
+ ```
19
+
20
+ ## `extractArchive`
21
+
22
+ ```ts
23
+ await extractArchive({
24
+ archivePath: "/srv/uploads/plugin.zip",
25
+ destDir: "/srv/workspace/plugins/plugin",
26
+ kind: "zip", // optional; resolveArchiveKind() can infer
27
+ timeoutMs: 15_000, // hard ceiling for the whole extraction
28
+ stripComponents: 0, // tar-style strip-leading-dirs
29
+ limits: {
30
+ maxArchiveBytes: 256 * 1024 * 1024,
31
+ maxEntries: 50_000,
32
+ maxExtractedBytes: 512 * 1024 * 1024,
33
+ maxEntryBytes: 256 * 1024 * 1024,
34
+ },
35
+ });
36
+ ```
37
+
38
+ ### Parameters
39
+
40
+ ```ts
41
+ type ExtractArchiveParams = {
42
+ archivePath: string; // absolute path to the archive
43
+ destDir: string; // absolute destination directory; must already exist
44
+ timeoutMs: number; // wall-clock cap; throws on overrun
45
+ kind?: ArchiveKind; // "zip" | "tar"; inferred from filename when omitted
46
+ stripComponents?: number; // strip N leading dirs from entry paths
47
+ tarGzip?: boolean; // when archive is .tar.gz/.tgz
48
+ limits?: ArchiveExtractLimits;
49
+ logger?: ArchiveLogger; // { info?, warn? }
50
+ };
51
+ ```
52
+
53
+ If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and throws if the extension is not recognized. Pass `kind` explicitly when the archive name doesn't carry the type (e.g. content-addressed names).
54
+
55
+ ### Limits
56
+
57
+ ```ts
58
+ type ArchiveExtractLimits = {
59
+ maxArchiveBytes?: number; // refuse if archivePath stat'd size exceeds this
60
+ maxEntries?: number; // refuse before extracting if entry count > this
61
+ maxExtractedBytes?: number; // refuse mid-stream if total extracted bytes > this
62
+ maxEntryBytes?: number; // refuse a single entry larger than this
63
+ };
64
+ ```
65
+
66
+ Defaults exist for each (`DEFAULT_MAX_ARCHIVE_BYTES_ZIP`, `DEFAULT_MAX_ENTRIES`, `DEFAULT_MAX_EXTRACTED_BYTES`, `DEFAULT_MAX_ENTRY_BYTES`). They are conservative — pass explicit values when you know your domain's actual ceiling.
67
+
68
+ A limit violation throws `ArchiveLimitError`. The error's code is one of:
69
+
70
+ ```ts
71
+ ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT
72
+ ARCHIVE_LIMIT_ERROR_CODE.ENTRY_COUNT_EXCEEDS_LIMIT
73
+ ARCHIVE_LIMIT_ERROR_CODE.EXTRACTED_BYTES_EXCEEDS_LIMIT
74
+ ARCHIVE_LIMIT_ERROR_CODE.ENTRY_BYTES_EXCEEDS_LIMIT
75
+ ```
76
+
77
+ Catch and branch on the code to surface a meaningful response to the caller.
78
+
79
+ ## What it defends against
80
+
81
+ - **Path traversal:** entries with `..`, absolute paths, or Windows drive prefixes are rejected (`ArchiveSecurityError`).
82
+ - **Symlink/hardlink entries:** rejected by default. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
83
+ - **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. A symlink swap in the destination tree mid-merge is caught.
84
+ - **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk.
85
+ - **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget. Extraction is aborted on overrun.
86
+
87
+ ## `resolveArchiveKind`
88
+
89
+ ```ts
90
+ import { resolveArchiveKind, type ArchiveKind } from "@openclaw/fs-safe/archive";
91
+
92
+ const kind = resolveArchiveKind("upload.zip"); // "zip"
93
+ const tar = resolveArchiveKind("upload.tar.gz"); // "tar"
94
+ const unknown = resolveArchiveKind("upload.bin"); // undefined
95
+ ```
96
+
97
+ Recognizes:
98
+
99
+ - `*.zip` → `"zip"`
100
+ - `*.tar`, `*.tar.gz`, `*.tgz`, `*.tar.bz2`, `*.tbz`, `*.tbz2` → `"tar"`
101
+
102
+ Returns `undefined` for unknown extensions; check the result before calling `extractArchive` if the filename is caller-controlled.
103
+
104
+ ## Lower-level building blocks
105
+
106
+ The archive subpath also exports the helpers `extractArchive` is built on. Most callers will not need them, but they are stable and documented:
107
+
108
+ | Function | Purpose |
109
+ |---|---|
110
+ | `withStagedArchiveDestination(opts)` | Creates a private staging dir outside the destination, calls your `run(stagingDir)`, then cleans it up. |
111
+ | `mergeExtractedTreeIntoDestination(opts)` | The merge step alone — staged tree → destination through boundary checks. |
112
+ | `prepareArchiveDestinationDir(destDir)` | Canonicalizes and asserts the destination directory. |
113
+ | `prepareArchiveOutputPath(opts)` | Resolves a single entry's output path against the staging dir. |
114
+ | `loadZipArchiveWithPreflight(opts)` | Loads a JSZip with size/entry-count preflight before unzipping. |
115
+ | `readZipCentralDirectoryEntryCount(path)` | Returns the entry count from a ZIP's central directory without reading any payloads. |
116
+ | `createTarEntryPreflightChecker(opts)` | Returns a per-entry checker for use as a `tar.x` `onReadEntry` hook. |
117
+
118
+ These let you build custom extractors that share the same safety machinery — for example, a streaming uploader that wants to refuse archives with too many entries before reading any payloads.
119
+
120
+ ## Path helpers
121
+
122
+ `archive-entry` exports a handful of low-level helpers for entry-path normalization:
123
+
124
+ ```ts
125
+ import {
126
+ isWindowsDrivePath,
127
+ normalizeArchiveEntryPath,
128
+ resolveArchiveOutputPath,
129
+ stripArchivePath,
130
+ validateArchiveEntryPath,
131
+ } from "@openclaw/fs-safe/archive";
132
+ ```
133
+
134
+ - `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, drive-prefixed, or otherwise unsafe entry paths.
135
+ - `normalizeArchiveEntryPath(raw)` — POSIX-normalizes the entry path (forward slashes, no `.` segments).
136
+ - `stripArchivePath(entryPath, n)` — strip the leading N path components, returning `null` if not enough remain.
137
+ - `resolveArchiveOutputPath({ destDir, entryPath })` — combines the entry path with the destination, after validation.
138
+ - `isWindowsDrivePath(value)` — detects `C:\…` style entries that should be rejected.
139
+
140
+ ## Common patterns
141
+
142
+ ### Extract an upload, surface budget violations
143
+
144
+ ```ts
145
+ import { extractArchive, ArchiveLimitError, ARCHIVE_LIMIT_ERROR_CODE } from "@openclaw/fs-safe/archive";
146
+
147
+ try {
148
+ await extractArchive({
149
+ archivePath: upload.path,
150
+ destDir: targetDir,
151
+ kind: "zip",
152
+ timeoutMs: 30_000,
153
+ limits: {
154
+ maxArchiveBytes: 100 * 1024 * 1024,
155
+ maxEntries: 10_000,
156
+ maxExtractedBytes: 200 * 1024 * 1024,
157
+ maxEntryBytes: 50 * 1024 * 1024,
158
+ },
159
+ });
160
+ } catch (err) {
161
+ if (err instanceof ArchiveLimitError) {
162
+ return reply(413, { code: err.code, message: err.message });
163
+ }
164
+ throw err;
165
+ }
166
+ ```
167
+
168
+ ### Decide kind from MIME, not filename
169
+
170
+ ```ts
171
+ const kind: ArchiveKind = mime === "application/zip" ? "zip" : "tar";
172
+ await extractArchive({ archivePath, destDir, kind, timeoutMs: 10_000 });
173
+ ```
174
+
175
+ ### Stage to private dir, then commit as a directory
176
+
177
+ ```ts
178
+ import { withTempWorkspace } from "@openclaw/fs-safe/temp";
179
+ import { replaceDirectoryAtomic } from "@openclaw/fs-safe/atomic";
180
+
181
+ await withTempWorkspace({ rootDir: "/srv/site/tmp", prefix: "extract-" }, async (ws) => {
182
+ await extractArchive({
183
+ archivePath: upload.path,
184
+ destDir: ws.dir,
185
+ timeoutMs: 30_000,
186
+ });
187
+ await replaceDirectoryAtomic({
188
+ stagedDir: ws.dir,
189
+ targetDir: "/srv/site/plugin",
190
+ });
191
+ });
192
+ ```
193
+
194
+ ## See also
195
+
196
+ - [Atomic writes](atomic.md) — `replaceDirectoryAtomic` for staged directory replacement.
197
+ - [Temp workspaces](temp.md) — extract into a private workspace and commit as one step.
198
+ - [Errors](errors.md) — `FsSafeError` codes the underlying writes can raise.
199
+ - [`extractArchive` source](https://github.com/openclaw/fs-safe/blob/main/src/archive.ts).
package/docs/atomic.md ADDED
@@ -0,0 +1,191 @@
1
+ # Atomic writes
2
+
3
+ `@openclaw/fs-safe/atomic` re-exports the lower-level helpers that `root()`'s write methods are built on. Reach for them when you have an absolute path you trust and want sibling-temp + rename without setting up a `Root`, or when you need finer control over `fsync`, mode preservation, or pre-rename hooks.
4
+
5
+ ```ts
6
+ import {
7
+ replaceFileAtomic,
8
+ replaceFileAtomicSync,
9
+ writeTextAtomic,
10
+ replaceDirectoryAtomic,
11
+ movePathWithCopyFallback,
12
+ } from "@openclaw/fs-safe/atomic";
13
+ ```
14
+
15
+ ## `replaceFileAtomic` / `replaceFileAtomicSync`
16
+
17
+ Write `content` to a sibling temp file in the destination directory, optionally `fsync` the temp file, optionally `fsync` the parent directory after rename, then atomically rename over the destination.
18
+
19
+ Async replacements to the same destination are serialized inside the current process, so two overlapping `replaceFileAtomic()` calls do not interleave their temp-write/rename phases. Use a sidecar lock when multiple processes may write the same target.
20
+
21
+ ```ts
22
+ import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
23
+
24
+ await replaceFileAtomic({
25
+ filePath: "/srv/workspace/state.json",
26
+ content: JSON.stringify(state, null, 2),
27
+ mode: 0o600,
28
+ syncTempFile: true,
29
+ syncParentDir: true,
30
+ });
31
+ ```
32
+
33
+ ### Options
34
+
35
+ ```ts
36
+ type ReplaceFileAtomicOptions = {
37
+ filePath: string; // destination
38
+ content: string | Uint8Array;
39
+ dirMode?: number; // mode for parent dirs created by the helper
40
+ mode?: number; // explicit mode for the new file (e.g. 0o600)
41
+ preserveExistingMode?: boolean; // copy mode from existing destination, when present
42
+ tempPrefix?: string;
43
+ renameMaxRetries?: number;
44
+ renameRetryBaseDelayMs?: number;
45
+ copyFallbackOnPermissionError?: boolean;
46
+ syncTempFile?: boolean; // fsync(temp) before rename
47
+ syncParentDir?: boolean; // fsync(parent) after rename (POSIX only)
48
+ beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
49
+ fileSystem?: ReplaceFileAtomicFileSystem; // injectable fs for tests
50
+ };
51
+ ```
52
+
53
+ ### `beforeRename`
54
+
55
+ Runs after the temp file is fully written and before the rename. Use it to take a backup snapshot, capture the about-to-be-replaced contents, or notify an observer:
56
+
57
+ ```ts
58
+ await replaceFileAtomic({
59
+ filePath: "/srv/workspace/config.toml",
60
+ content: rendered,
61
+ beforeRename: async ({ filePath }) => {
62
+ await fs.copyFile(filePath, `${filePath}.bak`); // snapshot existing
63
+ },
64
+ });
65
+ ```
66
+
67
+ If `beforeRename` throws, the rename is skipped and the temp file is removed — the destination is unchanged.
68
+
69
+ ### `EPERM` and copy fallback
70
+
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.
77
+
78
+ ### Sync variant
79
+
80
+ `replaceFileAtomicSync` accepts the same options shape, with the obvious removal of the async-only hooks. Use it inside synchronous boot paths or test setup code.
81
+
82
+ ## `replaceDirectoryAtomic`
83
+
84
+ Atomically swap one directory's contents with another, using a temporary backup during the swap.
85
+
86
+ ```ts
87
+ import { replaceDirectoryAtomic } from "@openclaw/fs-safe/atomic";
88
+
89
+ await replaceDirectoryAtomic({
90
+ stagedDir: "/srv/workspace/staging/snapshot-2026-05-05",
91
+ targetDir: "/srv/workspace/snapshot",
92
+ });
93
+ ```
94
+
95
+ The helper renames `targetDir` to a generated backup path, renames `stagedDir → targetDir`, then removes the backup. If the second rename fails, it tries to restore the original target before rethrowing.
96
+
97
+ Use it when callers must see a whole staged tree at the target path. For single-file replacement, `replaceFileAtomic` is the right tool.
98
+
99
+ ## `writeTextAtomic`
100
+
101
+ Atomic UTF-8 text write with the same secure defaults as `writeJson`: sibling
102
+ temp file, temp fsync, rename, parent fsync, and final chmod best-effort.
103
+ It delegates to `replaceFileAtomic()` with a smaller call shape. Use it when
104
+ you do not need replacement hooks such as `beforeRename`, `preserveExistingMode`,
105
+ or custom copy-fallback policy.
106
+
107
+ ```ts
108
+ import { writeTextAtomic } from "@openclaw/fs-safe/atomic";
109
+
110
+ await writeTextAtomic("/srv/workspace/rendered.md", rendered, {
111
+ mode: 0o600,
112
+ dirMode: 0o700,
113
+ trailingNewline: true,
114
+ });
115
+ ```
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
+
132
+ ## `movePathWithCopyFallback`
133
+
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.
139
+
140
+ ```ts
141
+ import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
142
+
143
+ await movePathWithCopyFallback({
144
+ from: "/srv/cache/blob.bin",
145
+ sourceHardlinks: "reject",
146
+ to: "/srv/persistent/blob.bin",
147
+ });
148
+ ```
149
+
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`.
155
+
156
+ ## Difference from `root()`
157
+
158
+ | `Root` methods | `atomic` helpers |
159
+ |---|---|
160
+ | Take relative paths, bound to a `rootDir`. | Take absolute paths, no boundary. |
161
+ | Throw `FsSafeError` with `code`. | Throw `FsSafeError` *or* the underlying `NodeJS.ErrnoException`, depending on failure point. |
162
+ | Atomicity, mode, hooks, fsync are sane defaults. | Caller controls all of the above. |
163
+ | `mkdir`, identity check, hardlink reject built in. | No identity check, no hardlink reject — pair with [path helpers](path.md) if you need them. |
164
+
165
+ Use `Root` when the path is caller-controlled. Use `atomic` when the path is fully under your control and you want explicit knobs.
166
+
167
+ ## Test injection
168
+
169
+ Both `replaceFileAtomic` and `replaceFileAtomicSync` accept a `fileSystem` option that overrides the small set of `fs` calls they make. Pass a stub in unit tests to assert order, simulate `EPERM`, or capture the temp filename:
170
+
171
+ ```ts
172
+ const ops: string[] = [];
173
+ await replaceFileAtomic({
174
+ filePath: "/tmp/x",
175
+ content: "hi",
176
+ fileSystem: {
177
+ promises: {
178
+ ...realFs,
179
+ writeFile: async (...args) => { ops.push("write"); return realFs.writeFile(...args); },
180
+ rename: async (...args) => { ops.push("rename"); return realFs.rename(...args); },
181
+ },
182
+ },
183
+ });
184
+ ```
185
+
186
+ ## See also
187
+
188
+ - [`root()`](root.md) — when you want method-style writes with the boundary baked in.
189
+ - [JSON files](json.md) — JSON/text helpers built on sibling-temp replacement.
190
+ - [Temp workspaces](temp.md) — for staging-then-swap directory builds.
191
+ - [Errors](errors.md) — code union for failures.