@openclaw/fs-safe 0.1.0 → 0.1.2

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 (114) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +23 -3
  3. package/dist/archive-staging.d.ts.map +1 -1
  4. package/dist/archive-staging.js +51 -7
  5. package/dist/archive.js +2 -2
  6. package/dist/file-store-boundary.d.ts +27 -0
  7. package/dist/file-store-boundary.d.ts.map +1 -0
  8. package/dist/file-store-boundary.js +164 -0
  9. package/dist/file-store.d.ts.map +1 -1
  10. package/dist/file-store.js +62 -72
  11. package/dist/path.d.ts +1 -0
  12. package/dist/path.d.ts.map +1 -1
  13. package/dist/path.js +6 -3
  14. package/dist/private-file-store.d.ts +5 -7
  15. package/dist/private-file-store.d.ts.map +1 -1
  16. package/dist/private-file-store.js +21 -34
  17. package/dist/root-context.d.ts +23 -0
  18. package/dist/root-context.d.ts.map +1 -0
  19. package/dist/root-context.js +62 -0
  20. package/dist/root-errors.d.ts +4 -0
  21. package/dist/root-errors.d.ts.map +1 -0
  22. package/dist/root-errors.js +21 -0
  23. package/dist/root-impl.d.ts +108 -0
  24. package/dist/root-impl.d.ts.map +1 -0
  25. package/dist/{safe-root.js → root-impl.js} +464 -251
  26. package/dist/root.d.ts +1 -107
  27. package/dist/root.d.ts.map +1 -1
  28. package/dist/root.js +1 -1332
  29. package/docs/advanced.md +100 -0
  30. package/docs/archive.md +199 -0
  31. package/docs/atomic.md +163 -0
  32. package/docs/config.md +65 -0
  33. package/docs/contributing.md +80 -0
  34. package/docs/errors.md +154 -0
  35. package/docs/file-store.md +213 -0
  36. package/docs/filename.md +0 -0
  37. package/docs/index.md +76 -0
  38. package/docs/install-path.md +167 -0
  39. package/docs/install.md +150 -0
  40. package/docs/json-store.md +200 -0
  41. package/docs/json.md +158 -0
  42. package/docs/local-roots.md +164 -0
  43. package/docs/path-scope.md +130 -0
  44. package/docs/path.md +191 -0
  45. package/docs/permissions.md +95 -0
  46. package/docs/private-file-store.md +45 -0
  47. package/docs/python-helper.md +103 -0
  48. package/docs/quickstart.md +147 -0
  49. package/docs/reading.md +174 -0
  50. package/docs/regular-file.md +175 -0
  51. package/docs/root.md +205 -0
  52. package/docs/secret-file.md +154 -0
  53. package/docs/secure-file.md +82 -0
  54. package/docs/security-model.md +99 -0
  55. package/docs/sidecar-lock.md +229 -0
  56. package/docs/store.md +44 -0
  57. package/docs/temp.md +276 -0
  58. package/docs/test-hooks.md +74 -0
  59. package/docs/testing.md +168 -0
  60. package/docs/timing.md +121 -0
  61. package/docs/types.md +155 -0
  62. package/docs/walk.md +63 -0
  63. package/docs/writing.md +222 -0
  64. package/package.json +5 -2
  65. package/dist/archive-utils.d.ts +0 -3
  66. package/dist/archive-utils.d.ts.map +0 -1
  67. package/dist/archive-utils.js +0 -26
  68. package/dist/boundary-file-read.d.ts +0 -44
  69. package/dist/boundary-file-read.d.ts.map +0 -1
  70. package/dist/boundary-file-read.js +0 -129
  71. package/dist/boundary-path.d.ts +0 -39
  72. package/dist/boundary-path.d.ts.map +0 -1
  73. package/dist/boundary-path.js +0 -598
  74. package/dist/fs-pinned-path-helper.d.ts +0 -7
  75. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  76. package/dist/fs-pinned-path-helper.js +0 -182
  77. package/dist/fs-pinned-write-helper.d.ts +0 -21
  78. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  79. package/dist/fs-pinned-write-helper.js +0 -263
  80. package/dist/hardlink-guards.d.ts +0 -7
  81. package/dist/hardlink-guards.d.ts.map +0 -1
  82. package/dist/hardlink-guards.js +0 -30
  83. package/dist/install-safe-path.d.ts +0 -20
  84. package/dist/install-safe-path.d.ts.map +0 -1
  85. package/dist/install-safe-path.js +0 -94
  86. package/dist/json-file.d.ts +0 -3
  87. package/dist/json-file.d.ts.map +0 -1
  88. package/dist/json-file.js +0 -123
  89. package/dist/json-files.d.ts +0 -20
  90. package/dist/json-files.d.ts.map +0 -1
  91. package/dist/json-files.js +0 -153
  92. package/dist/path-alias-guards.d.ts +0 -19
  93. package/dist/path-alias-guards.d.ts.map +0 -1
  94. package/dist/path-alias-guards.js +0 -21
  95. package/dist/path-guards.d.ts +0 -7
  96. package/dist/path-guards.d.ts.map +0 -1
  97. package/dist/path-guards.js +0 -49
  98. package/dist/path-safety.d.ts +0 -12
  99. package/dist/path-safety.d.ts.map +0 -1
  100. package/dist/path-safety.js +0 -50
  101. package/dist/safe-open-sync.d.ts +0 -24
  102. package/dist/safe-open-sync.d.ts.map +0 -1
  103. package/dist/safe-open-sync.js +0 -71
  104. package/dist/safe-root.d.ts +0 -123
  105. package/dist/safe-root.d.ts.map +0 -1
  106. package/dist/secure-temp-workspace.d.ts +0 -25
  107. package/dist/secure-temp-workspace.d.ts.map +0 -1
  108. package/dist/secure-temp-workspace.js +0 -136
  109. package/dist/sibling-temp-file.d.ts +0 -16
  110. package/dist/sibling-temp-file.d.ts.map +0 -1
  111. package/dist/sibling-temp-file.js +0 -73
  112. package/dist/sibling-temp-write.d.ts +0 -8
  113. package/dist/sibling-temp-write.d.ts.map +0 -1
  114. package/dist/sibling-temp-write.js +0 -40
@@ -0,0 +1,100 @@
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
+ | `canonicalPathFromExistingAncestor`, `findExistingAncestor` | – | Canonicalize without requiring the leaf to exist. |
42
+ | `resolveAbsolutePathForRead`, `resolveAbsolutePathForWrite`, `ResolvedAbsolutePath`, `ResolvedWritableAbsolutePath`, `AbsolutePathSymlinkPolicy` | – | Validate an absolute path against a symlink policy before opening. |
43
+
44
+ ### Files and identity
45
+
46
+ | Export | Page | Notes |
47
+ |---|---|---|
48
+ | `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level no-follow open routed through the root-file path. |
49
+ | `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
50
+ | `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
51
+ | `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
52
+ | `assertNoSymlinkParents`, `assertNoSymlinkParentsSync`, `AssertNoSymlinkParentsOptions` | – | Reject paths whose ancestor chain contains symlinks. |
53
+ | `assertNoHardlinkedFinalPath`, `assertNoPathAliasEscape`, `PATH_ALIAS_POLICIES`, `PathAliasPolicy` | – | Hardlink/alias defense building blocks. |
54
+
55
+ ### Local roots and file URLs
56
+
57
+ | Export | Page | Notes |
58
+ |---|---|---|
59
+ | `resolveLocalPathFromRootsSync`, `readLocalFileFromRoots`, related options/result types | [local-roots.md](local-roots.md) | Resolve a path against a list of allowed local roots. |
60
+ | `assertNoWindowsNetworkPath`, `basenameFromMediaSource`, `hasEncodedFileUrlSeparator`, `isWindowsDriveLetterPath`, `isWindowsNetworkPath`, `safeFileURLToPath`, `trySafeFileURLToPath` | – | Defensive helpers around Windows paths and `file://` URLs. |
61
+
62
+ ### Install paths and filenames
63
+
64
+ | Export | Page | Notes |
65
+ |---|---|---|
66
+ | `safeDirName`, `safePathSegmentHashed`, `resolveSafeInstallDir`, `assertCanonicalPathWithinBase` | [install-path.md](install-path.md) | Build install-target directories from caller-supplied identifiers. |
67
+ | `sanitizeUntrustedFileName` | [filename.md](filename.md) | Coerce an untrusted string into a safe filename. |
68
+ | `resolveHomeRelativePath` | – | Expand `~`-prefixed paths. |
69
+
70
+ ### Temp targets and sibling-temp writes
71
+
72
+ | Export | Page | Notes |
73
+ |---|---|---|
74
+ | `tempFile`, `withTempFile`, `TempFile`, `buildRandomTempFilePath`, `sanitizeTempFileName` | [temp.md](temp.md) | One-file temp primitive; prefer `tempWorkspace` from `@openclaw/fs-safe/temp` for the stable surface. |
75
+ | `writeSiblingTempFile`, `writeViaSiblingTempPath`, `WriteSiblingTempFileOptions`, `WriteSiblingTempFileResult` | – | Sibling-temp write building block used by `replaceFileAtomic`. |
76
+
77
+ ### Permissions
78
+
79
+ | Export | Page | Notes |
80
+ |---|---|---|
81
+ | `formatPosixMode` | [permissions.md](permissions.md) | Format a POSIX mode bitmask. |
82
+ | `inspectWindowsAcl`, `summarizeWindowsAcl`, `formatWindowsAclSummary`, `parseIcaclsOutput`, `resolveWindowsUserPrincipal`, `createIcaclsResetCommand`, `formatIcaclsResetCommand`, `IcaclsResetCommandOptions`, `PermissionExec`, `WindowsAclEntry`, `WindowsAclSummary` | [permissions.md](permissions.md) | Windows ACL inspection and remediation. |
83
+
84
+ ### Concurrency, timing, trash
85
+
86
+ | Export | Page | Notes |
87
+ |---|---|---|
88
+ | `createAsyncLock` | – | In-process async lock (separate from cross-process file locks). |
89
+ | `withTimeout` | [timing.md](timing.md) | Wrap a promise with a timeout that raises `FsSafeError("timeout")`. |
90
+ | `movePathToTrash`, `MovePathToTrashOptions` | – | Best-effort move to the platform trash. |
91
+
92
+ ## Stability
93
+
94
+ 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.
95
+
96
+ ## Related pages
97
+
98
+ - [Root API](root.md) — built on top of these helpers.
99
+ - [Errors](errors.md) — every helper here surfaces failures as `FsSafeError`.
100
+ - [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,163 @@
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 `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.
72
+
73
+ ### Sync variant
74
+
75
+ `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.
76
+
77
+ ## `replaceDirectoryAtomic`
78
+
79
+ Atomically swap one directory's contents with another, using a temporary backup during the swap.
80
+
81
+ ```ts
82
+ import { replaceDirectoryAtomic } from "@openclaw/fs-safe/atomic";
83
+
84
+ await replaceDirectoryAtomic({
85
+ stagedDir: "/srv/workspace/staging/snapshot-2026-05-05",
86
+ targetDir: "/srv/workspace/snapshot",
87
+ });
88
+ ```
89
+
90
+ 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.
91
+
92
+ Use it when callers must see a whole staged tree at the target path. For single-file replacement, `replaceFileAtomic` is the right tool.
93
+
94
+ ## `writeTextAtomic`
95
+
96
+ Atomic UTF-8 text write with the same secure defaults as `writeJson`: sibling
97
+ temp file, temp fsync, rename, parent fsync, and final chmod best-effort.
98
+ It delegates to `replaceFileAtomic()` with a smaller call shape. Use it when
99
+ you do not need replacement hooks such as `beforeRename`, `preserveExistingMode`,
100
+ or custom copy-fallback policy.
101
+
102
+ ```ts
103
+ import { writeTextAtomic } from "@openclaw/fs-safe/atomic";
104
+
105
+ await writeTextAtomic("/srv/workspace/rendered.md", rendered, {
106
+ mode: 0o600,
107
+ dirMode: 0o700,
108
+ trailingNewline: true,
109
+ });
110
+ ```
111
+
112
+ ## `movePathWithCopyFallback`
113
+
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).
115
+
116
+ ```ts
117
+ import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
118
+
119
+ await movePathWithCopyFallback({
120
+ source: "/srv/cache/blob.bin",
121
+ destination: "/srv/persistent/blob.bin",
122
+ overwrite: true,
123
+ });
124
+ ```
125
+
126
+ Use it when source and destination might live on different filesystems (containers, tmpfs, separate volumes).
127
+
128
+ ## Difference from `root()`
129
+
130
+ | `Root` methods | `atomic` helpers |
131
+ |---|---|
132
+ | Take relative paths, bound to a `rootDir`. | Take absolute paths, no boundary. |
133
+ | Throw `FsSafeError` with `code`. | Throw `FsSafeError` *or* the underlying `NodeJS.ErrnoException`, depending on failure point. |
134
+ | Atomicity, mode, hooks, fsync are sane defaults. | Caller controls all of the above. |
135
+ | `mkdir`, identity check, hardlink reject built in. | No identity check, no hardlink reject — pair with [path helpers](path.md) if you need them. |
136
+
137
+ Use `Root` when the path is caller-controlled. Use `atomic` when the path is fully under your control and you want explicit knobs.
138
+
139
+ ## Test injection
140
+
141
+ 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:
142
+
143
+ ```ts
144
+ const ops: string[] = [];
145
+ await replaceFileAtomic({
146
+ filePath: "/tmp/x",
147
+ content: "hi",
148
+ fileSystem: {
149
+ promises: {
150
+ ...realFs,
151
+ writeFile: async (...args) => { ops.push("write"); return realFs.writeFile(...args); },
152
+ rename: async (...args) => { ops.push("rename"); return realFs.rename(...args); },
153
+ },
154
+ },
155
+ });
156
+ ```
157
+
158
+ ## See also
159
+
160
+ - [`root()`](root.md) — when you want method-style writes with the boundary baked in.
161
+ - [JSON files](json.md) — JSON/text helpers built on sibling-temp replacement.
162
+ - [Temp workspaces](temp.md) — for staging-then-swap directory builds.
163
+ - [Errors](errors.md) — code union for failures.
package/docs/config.md ADDED
@@ -0,0 +1,65 @@
1
+ ---
2
+ title: Config
3
+ description: "Process-global configuration for the optional Python helper used by fs-safe on POSIX."
4
+ ---
5
+
6
+ # `@openclaw/fs-safe/config`
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.
9
+
10
+ ```ts
11
+ import {
12
+ configureFsSafePython,
13
+ getFsSafePythonConfig,
14
+ type FsSafePythonConfig,
15
+ type FsSafePythonMode,
16
+ } from "@openclaw/fs-safe/config";
17
+ ```
18
+
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.
20
+
21
+ ## `configureFsSafePython(config)`
22
+
23
+ ```ts
24
+ function configureFsSafePython(config: Partial<FsSafePythonConfig>): void;
25
+
26
+ type FsSafePythonConfig = {
27
+ mode: FsSafePythonMode;
28
+ pythonPath?: string;
29
+ };
30
+
31
+ type FsSafePythonMode = "auto" | "off" | "require";
32
+ ```
33
+
34
+ Set the process-global policy. Calls merge into the existing override config, so passing `{ pythonPath: "/usr/bin/python3" }` keeps any previously set `mode`. Configure once at startup, before the first `root()` call — switching modes mid-process is supported but the helper may already be running.
35
+
36
+ | Mode | Behavior |
37
+ |---|---|
38
+ | `auto` | Default. Use the helper when it starts; fall back to Node-only behavior if Python is missing or fails to start. |
39
+ | `off` | Never spawn the helper. Read/write/move use Node fallbacks plus pre/post identity checks. |
40
+ | `require` | Fail closed if the helper cannot start. Operations that need the helper raise `FsSafeError("helper-unavailable")`. |
41
+
42
+ ## `getFsSafePythonConfig()`
43
+
44
+ ```ts
45
+ function getFsSafePythonConfig(): FsSafePythonConfig;
46
+ ```
47
+
48
+ Return the effective configuration: programmatic overrides win, then env vars, then the package default (`auto`).
49
+
50
+ ## Environment variables
51
+
52
+ The same policy can be set without code:
53
+
54
+ ```bash
55
+ FS_SAFE_PYTHON_MODE=auto # auto | off | require | true | false | on | off | 1 | 0 | never | required
56
+ FS_SAFE_PYTHON=/usr/bin/python3
57
+ ```
58
+
59
+ OpenClaw compatibility aliases are accepted: `OPENCLAW_FS_SAFE_PYTHON_MODE`, `OPENCLAW_FS_SAFE_PYTHON`, `OPENCLAW_PINNED_PYTHON`, and `OPENCLAW_PINNED_WRITE_PYTHON`. Programmatic overrides via `configureFsSafePython` always win.
60
+
61
+ ## Related pages
62
+
63
+ - [Python helper policy](python-helper.md) — when to pick `auto`, `off`, or `require`, and what each mode protects.
64
+ - [Root API](root.md) — the API whose POSIX hardening the helper backs.
65
+ - [Errors](errors.md) — `helper-unavailable` and `helper-failed`.
@@ -0,0 +1,80 @@
1
+ # Contributing
2
+
3
+ The `fs-safe` repo lives at [github.com/openclaw/fs-safe](https://github.com/openclaw/fs-safe). Contributions welcome — issues, bug reports, focused PRs.
4
+
5
+ ## Local setup
6
+
7
+ ```bash
8
+ git clone https://github.com/openclaw/fs-safe.git
9
+ cd fs-safe
10
+ pnpm install
11
+ ```
12
+
13
+ Node 20.11 or newer. The dev toolchain uses pnpm; `npm install` works too but pnpm is what the lockfile is keyed against.
14
+
15
+ ## Build
16
+
17
+ ```bash
18
+ pnpm build
19
+ ```
20
+
21
+ Runs `tsc -p tsconfig.json`. Output lands in `dist/`. The package's `prepack` hook re-runs the build before publishing — manual `pnpm build` is only required when you want to inspect the output or run a freshly-built copy locally.
22
+
23
+ ## Test
24
+
25
+ ```bash
26
+ pnpm test
27
+ ```
28
+
29
+ Vitest. Tests live in `test/` and follow `*.test.ts`. Run a single file with:
30
+
31
+ ```bash
32
+ pnpm test test/archive.test.ts
33
+ ```
34
+
35
+ Use `vi.mock` sparingly. Most tests should drive real disk operations in a `mkdtemp`-created scratch directory, asserting on observable behavior. The library has [test hooks](testing.md) for the rare cases where you need to inject a TOCTOU race deterministically.
36
+
37
+ ## Format and types
38
+
39
+ The repo doesn't ship a separate lint config; `tsc --noEmit` (run by `pnpm build`) is the type gate. Format with your editor's TypeScript Language Server defaults — keep diffs tight.
40
+
41
+ ## Docs
42
+
43
+ The docs site is rendered from `docs/*.md` by `scripts/build-docs-site.mjs`. Build locally to preview:
44
+
45
+ ```bash
46
+ node scripts/build-docs-site.mjs
47
+ open dist/docs-site/index.html
48
+ ```
49
+
50
+ The build validates internal links and embedded anchors. Broken links fail the build — fix them before pushing.
51
+
52
+ Adding a new doc page:
53
+
54
+ 1. Create `docs/<page>.md`. Use a leading `# Title` heading.
55
+ 2. Add the page to the appropriate section in `scripts/build-docs-site.mjs` (`sections` array near the top).
56
+ 3. Cross-link from `docs/index.md` if it's a major surface.
57
+ 4. Re-run the local build.
58
+
59
+ Internal links use relative `*.md` paths — the builder rewrites them to the rendered HTML. Code fences support GitHub-flavored markdown.
60
+
61
+ ## PRs
62
+
63
+ Small, focused PRs land faster. The general shape:
64
+
65
+ - One concern per PR. Bug fixes separate from new APIs.
66
+ - A regression test for every bug fix where the test framework can express it.
67
+ - A changelog entry under `## Unreleased` when behavior visibly changes.
68
+ - For new public APIs: a docs page in `docs/` plus a sidebar entry.
69
+
70
+ ## Releases
71
+
72
+ Release process lives in the maintainer's runbook. External contributors don't need to do anything beyond getting the PR merged.
73
+
74
+ ## Reporting security issues
75
+
76
+ Suspected security issues belong in private disclosure first. See [`SECURITY.md`](https://github.com/openclaw/fs-safe/blob/main/SECURITY.md) in the repo for the current contact path. Don't open a public issue for a credential-stealing or sandbox-escape bug — coordinate the disclosure first.
77
+
78
+ ## License
79
+
80
+ By contributing you agree that your contributions are licensed under the project's [MIT license](https://github.com/openclaw/fs-safe/blob/main/LICENSE).