@openclaw/fs-safe 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +19 -6
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +20 -12
- package/dist/archive-entry.d.ts +1 -0
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +37 -6
- package/dist/archive-errors.d.ts +1 -0
- package/dist/archive-errors.d.ts.map +1 -1
- package/dist/archive-errors.js +4 -0
- package/dist/archive-input.d.ts +7 -0
- package/dist/archive-input.d.ts.map +1 -1
- package/dist/archive-input.js +11 -1
- package/dist/archive-limits.js +1 -1
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +12 -6
- package/dist/archive-policy.d.ts.map +1 -1
- package/dist/archive-policy.js +2 -1
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +62 -37
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +3 -2
- package/dist/archive-tar-meta.d.ts.map +1 -1
- package/dist/archive-tar-meta.js +15 -1
- package/dist/archive-tar-runtime.d.ts +1 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -1
- package/dist/archive-tar-runtime.js +11 -0
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +8 -4
- package/dist/archive-zip-entry.d.ts +7 -1
- package/dist/archive-zip-entry.d.ts.map +1 -1
- package/dist/archive-zip-entry.js +11 -1
- package/dist/archive-zip-integrity.d.ts +5 -0
- package/dist/archive-zip-integrity.d.ts.map +1 -0
- package/dist/archive-zip-integrity.js +54 -0
- package/dist/archive-zip-preflight.d.ts.map +1 -1
- package/dist/archive-zip-preflight.js +12 -1
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +9 -5
- package/dist/bounded-read-stream.d.ts.map +1 -1
- package/dist/bounded-read-stream.js +13 -2
- package/dist/deny-mutations.d.ts +1 -0
- package/dist/deny-mutations.d.ts.map +1 -1
- package/dist/deny-mutations.js +6 -39
- package/dist/device-path.d.ts +1 -0
- package/dist/device-path.d.ts.map +1 -1
- package/dist/device-path.js +6 -6
- package/dist/directory-guard.d.ts.map +1 -1
- package/dist/directory-guard.js +5 -4
- package/dist/error-detail.d.ts +2 -0
- package/dist/error-detail.d.ts.map +1 -0
- package/dist/error-detail.js +4 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -0
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +4 -1
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +88 -35
- package/dist/file-store-boundary.d.ts +6 -0
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +20 -5
- package/dist/file-store-sync-write.d.ts +9 -0
- package/dist/file-store-sync-write.d.ts.map +1 -0
- package/dist/file-store-sync-write.js +97 -0
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +62 -139
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +27 -0
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +5 -6
- package/dist/install-path.js +1 -1
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +19 -13
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +1 -3
- package/dist/local-file-access.d.ts +3 -2
- package/dist/local-file-access.d.ts.map +1 -1
- package/dist/local-file-access.js +12 -6
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +35 -30
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +51 -12
- package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
- package/dist/native/darwin-x64/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
- package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
- package/dist/native-binding.d.ts +1 -0
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-operations.d.ts.map +1 -1
- package/dist/native-operations.js +14 -1
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +44 -17
- package/dist/output-sibling.d.ts.map +1 -1
- package/dist/output-sibling.js +6 -5
- package/dist/path-policy.d.ts.map +1 -1
- package/dist/path-policy.js +1 -7
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +4 -0
- package/dist/permission-exec.d.ts +6 -0
- package/dist/permission-exec.d.ts.map +1 -0
- package/dist/permission-exec.js +28 -0
- package/dist/permissions-windows.d.ts +56 -0
- package/dist/permissions-windows.d.ts.map +1 -0
- package/dist/permissions-windows.js +285 -0
- package/dist/permissions.d.ts +3 -46
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +17 -328
- package/dist/pinned-open.d.ts.map +1 -1
- package/dist/pinned-open.js +21 -4
- package/dist/pinned-write.js +2 -1
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +45 -4
- package/dist/read-error.d.ts +2 -0
- package/dist/read-error.d.ts.map +1 -0
- package/dist/read-error.js +11 -0
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +21 -2
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +10 -1
- package/dist/replace-file-copy-fallback.d.ts +3 -0
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +18 -11
- package/dist/replace-file-descriptor.d.ts +33 -0
- package/dist/replace-file-descriptor.d.ts.map +1 -0
- package/dist/replace-file-descriptor.js +96 -0
- package/dist/replace-file.d.ts +9 -2
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +64 -72
- package/dist/root-context.d.ts +8 -0
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +43 -2
- package/dist/root-errors.d.ts +7 -0
- package/dist/root-errors.d.ts.map +1 -1
- package/dist/root-errors.js +46 -1
- package/dist/root-file.d.ts +1 -0
- package/dist/root-file.d.ts.map +1 -1
- package/dist/root-file.js +2 -0
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +216 -169
- package/dist/root-path-symlink.d.ts +3 -0
- package/dist/root-path-symlink.d.ts.map +1 -0
- package/dist/root-path-symlink.js +35 -0
- package/dist/root-path.d.ts +1 -0
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +129 -190
- package/dist/root-walk.d.ts.map +1 -1
- package/dist/root-walk.js +15 -0
- package/dist/safe-path-segment.d.ts +2 -0
- package/dist/safe-path-segment.d.ts.map +1 -1
- package/dist/safe-path-segment.js +12 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +29 -6
- package/dist/secret-read-async.d.ts.map +1 -1
- package/dist/secret-read-async.js +5 -1
- package/dist/secure-temp-dir.d.ts.map +1 -1
- package/dist/secure-temp-dir.js +6 -1
- package/dist/short-path.d.ts +2 -0
- package/dist/short-path.d.ts.map +1 -0
- package/dist/short-path.js +7 -0
- package/dist/sidecar-lock-acquire.d.ts +30 -0
- package/dist/sidecar-lock-acquire.d.ts.map +1 -0
- package/dist/sidecar-lock-acquire.js +260 -0
- package/dist/sidecar-lock-policy.d.ts +3 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +20 -3
- package/dist/sidecar-lock-reclaim.d.ts +5 -1
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +59 -9
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +27 -226
- package/dist/symlink-parents.d.ts.map +1 -1
- package/dist/symlink-parents.js +9 -8
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +15 -4
- package/dist/test-hooks.d.ts +1 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/timing.d.ts +2 -0
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +9 -0
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +10 -1
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +15 -0
- package/docs/advanced.md +7 -1
- package/docs/archive.md +27 -20
- package/docs/assets/readme-banner.jpg +0 -0
- package/docs/atomic.md +48 -19
- package/docs/errors.md +34 -8
- package/docs/file-store.md +14 -1
- package/docs/filename.md +17 -16
- package/docs/index.md +1 -0
- package/docs/install-path.md +9 -6
- package/docs/json.md +8 -2
- package/docs/local-roots.md +79 -103
- package/docs/native-helper.md +11 -5
- package/docs/native.md +2 -2
- package/docs/output.md +8 -3
- package/docs/path-scope.md +4 -1
- package/docs/path.md +7 -3
- package/docs/permissions.md +8 -0
- package/docs/public-api.md +109 -0
- package/docs/quickstart.md +6 -2
- package/docs/reading.md +13 -10
- package/docs/root.md +26 -4
- package/docs/secret-file.md +21 -4
- package/docs/security-model.md +21 -4
- package/docs/sidecar-lock.md +15 -5
- package/docs/temp.md +42 -5
- package/docs/test-hooks.md +2 -0
- package/docs/types.md +32 -15
- package/docs/writing.md +25 -12
- package/package.json +15 -4
- package/dist/archive-file-io.d.ts +0 -9
- package/dist/archive-file-io.d.ts.map +0 -1
- package/dist/archive-file-io.js +0 -11
- package/dist/file-store-source.d.ts +0 -5
- package/dist/file-store-source.d.ts.map +0 -1
- package/dist/file-store-source.js +0 -30
- package/dist/file-url.d.ts +0 -2
- package/dist/file-url.d.ts.map +0 -1
- package/dist/file-url.js +0 -1
- package/dist/path-stat.d.ts +0 -4
- package/dist/path-stat.d.ts.map +0 -1
- package/dist/path-stat.js +0 -15
- package/dist/windows-permissions-native.d.ts +0 -9
- package/dist/windows-permissions-native.d.ts.map +0 -1
- package/dist/windows-permissions-native.js +0 -31
package/docs/advanced.md
CHANGED
|
@@ -56,7 +56,7 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
|
|
|
56
56
|
| Export | Page | Notes |
|
|
57
57
|
|---|---|---|
|
|
58
58
|
| `readFileDescriptorBounded`, `readFileDescriptorBoundedSync`, `readFileHandleBounded` | – | Incremental whole-file reads for already-open descriptors/handles. They consume at most `maxBytes + 1`, do not close the input, and throw `FsSafeError("too-large")` on overflow. |
|
|
59
|
-
| `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level
|
|
59
|
+
| `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level root-bounded open; rejects every symlink component by default, with `rejectSymlinks: false` for explicit in-root following. |
|
|
60
60
|
| `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
|
|
61
61
|
| `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
|
|
62
62
|
| `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
|
|
@@ -82,6 +82,12 @@ try {
|
|
|
82
82
|
}
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
For the symlink-parent guards, `allowMissing` defaults to `true` and permits the
|
|
86
|
+
walk to stop only at an actually absent suffix. When an existing non-directory
|
|
87
|
+
component is followed by another segment, both helpers throw
|
|
88
|
+
`FsSafeError("not-file")` before the platform can expose that state as POSIX
|
|
89
|
+
`ENOTDIR` or Windows `ENOENT`.
|
|
90
|
+
|
|
85
91
|
### Local roots and file URLs
|
|
86
92
|
|
|
87
93
|
| Export | Page | Notes |
|
package/docs/archive.md
CHANGED
|
@@ -42,10 +42,10 @@ await extractArchive({
|
|
|
42
42
|
### Parameters
|
|
43
43
|
|
|
44
44
|
```ts
|
|
45
|
-
type
|
|
45
|
+
type ExtractArchiveOptions = {
|
|
46
46
|
archivePath: string; // absolute path to the archive
|
|
47
47
|
destDir: string; // absolute destination directory; must already exist
|
|
48
|
-
timeoutMs: number; // wall-clock cap;
|
|
48
|
+
timeoutMs: number; // positive wall-clock cap; <= 0/non-finite disables it
|
|
49
49
|
kind?: ArchiveKind; // "zip" | "tar" | "tar-zstd" | "tar-bzip2"
|
|
50
50
|
stripComponents?: number; // strip N leading dirs from entry paths
|
|
51
51
|
tarGzip?: boolean; // when archive is .tar.gz/.tgz
|
|
@@ -107,7 +107,7 @@ of leaving a paused parser to drain indefinitely. The native path finishes its
|
|
|
107
107
|
bounded manifest read before TypeScript policy evaluation, so a rejected plan
|
|
108
108
|
never starts the extraction worker.
|
|
109
109
|
|
|
110
|
-
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).
|
|
110
|
+
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). A positive finite `timeoutMs` is a wall-clock budget; zero, negative, `NaN`, and infinity disable the deadline.
|
|
111
111
|
|
|
112
112
|
### Limits
|
|
113
113
|
|
|
@@ -122,18 +122,22 @@ type ArchiveExtractLimits = {
|
|
|
122
122
|
};
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
Defaults exist for each (`DEFAULT_MAX_ARCHIVE_BYTES_ZIP`, `DEFAULT_MAX_ENTRIES`, `DEFAULT_MAX_EXTRACTED_BYTES`, `DEFAULT_MAX_ENTRY_BYTES`, `DEFAULT_MAX_META_ENTRY_BYTES`, `DEFAULT_MAX_ENTRY_PATH_COMPONENTS`). The path-component default is 256. It is evaluated after `stripComponents` and before TypeScript accepts an entry for either JavaScript or native extraction, so rejected entries cannot cause implicit parent-directory creation. The 1 MiB metadata default matches node-tar's `maxMetaEntrySize`; fs-safe passes the same resolved value to node-tar and the native TAR meter.
|
|
125
|
+
Defaults exist for each (`DEFAULT_MAX_ARCHIVE_BYTES_ZIP`, `DEFAULT_MAX_ENTRIES`, `DEFAULT_MAX_EXTRACTED_BYTES`, `DEFAULT_MAX_ENTRY_BYTES`, `DEFAULT_MAX_META_ENTRY_BYTES`, `DEFAULT_MAX_ENTRY_PATH_COMPONENTS`). An explicit zero remains zero rather than selecting the default. `maxEntries` counts every archive entry, including entries removed by `stripComponents` or an explicit filter. The path-component default is 256. It is evaluated after `stripComponents` and before TypeScript accepts an entry for either JavaScript or native extraction, so rejected entries cannot cause implicit parent-directory creation. The 1 MiB metadata default matches node-tar's `maxMetaEntrySize`; fs-safe passes the same resolved value to node-tar and the native TAR meter.
|
|
126
126
|
|
|
127
|
-
A limit violation throws `ArchiveLimitError`.
|
|
127
|
+
A limit violation throws `ArchiveLimitError`. Its constant and string code are:
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
129
|
+
| Constant | Code |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `ARCHIVE_SIZE_EXCEEDS_LIMIT` | `archive-size-exceeds-limit` |
|
|
132
|
+
| `ENTRY_COUNT_EXCEEDS_LIMIT` | `archive-entry-count-exceeds-limit` |
|
|
133
|
+
| `EXTRACTED_SIZE_EXCEEDS_LIMIT` | `archive-extracted-size-exceeds-limit` |
|
|
134
|
+
| `ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT` | `archive-entry-extracted-size-exceeds-limit` |
|
|
135
|
+
| `META_ENTRY_SIZE_EXCEEDS_LIMIT` | `archive-meta-entry-size-exceeds-limit` |
|
|
136
|
+
| `ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT` | `archive-entry-path-components-exceeds-limit` |
|
|
137
|
+
| `MANIFEST_SIZE_EXCEEDS_LIMIT` | `archive-manifest-size-exceeds-limit` |
|
|
138
|
+
|
|
139
|
+
`MANIFEST_SIZE_EXCEEDS_LIMIT` is retained in the public compatibility union;
|
|
140
|
+
no current public extractor emits it.
|
|
137
141
|
|
|
138
142
|
Catch and branch on the code to surface a meaningful response to the caller.
|
|
139
143
|
|
|
@@ -144,10 +148,12 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and
|
|
|
144
148
|
|
|
145
149
|
## What it defends against
|
|
146
150
|
|
|
147
|
-
- **Path traversal:** entries with `..`, absolute paths, or Windows drive
|
|
151
|
+
- **Path traversal:** entries with `..`, absolute paths, NUL bytes, or Windows drive-relative segments such as `C:secret` and `nested/C:secret` are rejected (`ArchiveSecurityError`). On Windows, path segments containing `:` are also rejected as alternate data stream names before either backend writes to the filesystem.
|
|
148
152
|
- **Symlink/hardlink entries:** rejected by default. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
|
|
153
|
+
- **Ambiguous output names:** duplicate names and distinct names that collide after `stripComponents`, case normalization, or Unicode normalization are rejected instead of relying on backend- or volume-specific overwrite order.
|
|
149
154
|
- **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. Destination symlink swaps are checked with the selected platform mechanism; non-Linux routes retain the best-effort race window documented in the [security model](security-model.md#containment-guarantees-by-platform).
|
|
150
155
|
- **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk.
|
|
156
|
+
- **Corrupt ZIP payloads:** streamed output must match both the central-directory CRC and declared uncompressed size before it can leave private staging.
|
|
151
157
|
- **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget. Extraction is aborted on overrun.
|
|
152
158
|
- **Metadata bombs:** a fixed-header pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before either TAR implementation buffers them. It understands octal and base-256 size fields without interpreting metadata content.
|
|
153
159
|
|
|
@@ -168,7 +174,7 @@ import { resolveArchiveKind, type ArchiveKind } from "@openclaw/fs-safe/archive"
|
|
|
168
174
|
const kind = resolveArchiveKind("upload.zip"); // "zip"
|
|
169
175
|
const tar = resolveArchiveKind("upload.tar.gz"); // "tar"
|
|
170
176
|
const zstd = resolveArchiveKind("upload.tar.zst"); // "tar-zstd" when native is available
|
|
171
|
-
const unknown = resolveArchiveKind("upload.bin"); //
|
|
177
|
+
const unknown = resolveArchiveKind("upload.bin"); // null
|
|
172
178
|
```
|
|
173
179
|
|
|
174
180
|
Recognizes:
|
|
@@ -178,7 +184,7 @@ Recognizes:
|
|
|
178
184
|
- `*.tar.zst`, `*.tar.zstd`, `*.tzst` → `"tar-zstd"` (native only)
|
|
179
185
|
- `*.tar.bz2`, `*.tbz2`, `*.tbz` → `"tar-bzip2"` (native only)
|
|
180
186
|
|
|
181
|
-
Returns `
|
|
187
|
+
Returns `null` for unknown extensions; check the result before calling
|
|
182
188
|
`extractArchive` if the filename is caller-controlled. A recognized zstd or
|
|
183
189
|
bzip2 TAR extension with no native binding throws the typed
|
|
184
190
|
`FsSafeError("helper-unavailable")` with installation guidance. This includes
|
|
@@ -204,7 +210,8 @@ await extractArchive({
|
|
|
204
210
|
|
|
205
211
|
`readArchiveEntry(archivePath, entryPath, { maxBytes, kind? })` reads one
|
|
206
212
|
regular-file entry into a bounded `Buffer` without extracting a tree. It pins
|
|
207
|
-
and privately stages the archive input, rejects link and
|
|
213
|
+
and privately stages the archive input, rejects link, directory, and duplicate
|
|
214
|
+
entries, verifies ZIP CRC and declared size,
|
|
208
215
|
and throws `ArchiveLimitError` if decompressed bytes exceed `maxBytes`. ZIP
|
|
209
216
|
inputs retain the archive subpath's 256 MiB compressed-input ceiling.
|
|
210
217
|
With a native binding it uses the same Rust decoders as extraction, including
|
|
@@ -248,11 +255,11 @@ import {
|
|
|
248
255
|
} from "@openclaw/fs-safe/archive";
|
|
249
256
|
```
|
|
250
257
|
|
|
251
|
-
- `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, drive-
|
|
252
|
-
- `normalizeArchiveEntryPath(raw)` —
|
|
258
|
+
- `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths, including alternate data stream names on Windows.
|
|
259
|
+
- `normalizeArchiveEntryPath(raw)` — converts backslashes in the entry path to forward slashes.
|
|
253
260
|
- `stripArchivePath(entryPath, n)` — strip the leading N path components, returning `null` if not enough remain.
|
|
254
261
|
- `resolveArchiveOutputPath({ destDir, entryPath })` — combines the entry path with the destination, after validation.
|
|
255
|
-
- `isWindowsDrivePath(value)` — detects `C
|
|
262
|
+
- `isWindowsDrivePath(value)` — detects drive-relative segments such as `C:secret` or `nested/C:secret` that should be rejected.
|
|
256
263
|
|
|
257
264
|
## Common patterns
|
|
258
265
|
|
|
Binary file
|
package/docs/atomic.md
CHANGED
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
|
|
15
15
|
## `replaceFileAtomic` / `replaceFileAtomicSync`
|
|
16
16
|
|
|
17
|
-
Write `content` to a sibling temp file in the destination directory, optionally `fsync` the
|
|
17
|
+
Write `content` to a sibling temp file in the destination directory, apply the parent-directory and final file modes through verified descriptors, optionally `fsync` the file descriptor, optionally `fsync` the parent directory after rename, then atomically rename over the destination. No permission change follows a caller-supplied pathname.
|
|
18
|
+
|
|
19
|
+
On POSIX, the parent is opened with no-follow and directory-only flags, checked against its pre-open identity, and mode-adjusted through that descriptor. A replacement symlink is rejected rather than followed. If the directory cannot be opened for descriptor access, the operation fails closed instead of retrying by pathname. Windows does not enforce POSIX directory modes and Node cannot consistently open directory descriptors there, so `dirMode` is passed only to `mkdir`; no pathname `chmod` fallback is attempted.
|
|
18
20
|
|
|
19
21
|
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
22
|
|
|
@@ -36,18 +38,19 @@ await replaceFileAtomic({
|
|
|
36
38
|
type ReplaceFileAtomicOptions = {
|
|
37
39
|
filePath: string; // destination
|
|
38
40
|
content: string | Uint8Array;
|
|
39
|
-
dirMode?: number; //
|
|
40
|
-
mode?: number; //
|
|
41
|
-
preserveExistingMode?: boolean; // copy
|
|
42
|
-
tempPrefix?: string;
|
|
43
|
-
renameMaxRetries?: number;
|
|
44
|
-
renameRetryBaseDelayMs?: number;
|
|
45
|
-
copyFallbackOnPermissionError?: boolean;
|
|
41
|
+
dirMode?: number; // parent-directory mode (POSIX; default 0o700)
|
|
42
|
+
mode?: number; // new-file mode (default 0o600)
|
|
43
|
+
preserveExistingMode?: boolean; // copy existing mode; default false
|
|
44
|
+
tempPrefix?: string; // default ".fs-safe-replace"
|
|
45
|
+
renameMaxRetries?: number; // EBUSY retries; default 0
|
|
46
|
+
renameRetryBaseDelayMs?: number; // exponential base; default 50
|
|
47
|
+
copyFallbackOnPermissionError?: boolean; // default false
|
|
46
48
|
copyFallbackRestore?: "restore-original" | "none"; // default: "none"
|
|
47
49
|
maxRestoreBytes?: number; // required with "restore-original"
|
|
48
|
-
destinationHardlinks?: "reject";
|
|
49
|
-
syncTempFile?: boolean; // fsync(temp) before rename
|
|
50
|
-
syncParentDir?: boolean; // fsync(parent) after rename
|
|
50
|
+
destinationHardlinks?: "reject"; // default unset (no destination nlink policy)
|
|
51
|
+
syncTempFile?: boolean; // fsync(temp) before rename, or the final file after copy fallback; default false
|
|
52
|
+
syncParentDir?: boolean; // fsync(parent) after rename, POSIX only; default false
|
|
53
|
+
throwOnCleanupError?: boolean; // report temp cleanup failure; default false
|
|
51
54
|
beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
|
|
52
55
|
fileSystem?: ReplaceFileAtomicFileSystem; // injectable fs for tests
|
|
53
56
|
};
|
|
@@ -87,9 +90,10 @@ The default `copyFallbackRestore: "none"` preserves the existing fallback
|
|
|
87
90
|
contract: a failed copy can leave a partial destination. For state files where
|
|
88
91
|
preserving the old bytes is more important, choose `"restore-original"` and set
|
|
89
92
|
an explicit `maxRestoreBytes` memory budget. If the destination exists, fs-safe
|
|
90
|
-
snapshots it through a pinned descriptor, overwrites
|
|
91
|
-
descriptor, and synchronizes the result. Any write or sync
|
|
92
|
-
restore and another sync through the same
|
|
93
|
+
snapshots it through a pinned descriptor, overwrites and mode-adjusts through
|
|
94
|
+
that same descriptor, and synchronizes the result. Any write, mode, or sync
|
|
95
|
+
failure triggers a byte-and-mode restore and another sync through the same
|
|
96
|
+
descriptor.
|
|
93
97
|
|
|
94
98
|
Restore failures are `FsSafeError("helper-failed")` values with typed
|
|
95
99
|
`details.cleanup` set to `"restored"` or `"restore-failed"`. An original larger
|
|
@@ -99,7 +103,10 @@ fallback.
|
|
|
99
103
|
|
|
100
104
|
### Sync variant
|
|
101
105
|
|
|
102
|
-
`replaceFileAtomicSync` accepts the same options
|
|
106
|
+
`replaceFileAtomicSync` accepts the same base options, a synchronous
|
|
107
|
+
`beforeRename` callback, and `ReplaceFileAtomicSyncFileSystem`. Use it inside
|
|
108
|
+
synchronous boot paths or test setup code. It returns the same
|
|
109
|
+
`{ method: "rename" | "copy-fallback" }` receipt as the async variant.
|
|
103
110
|
|
|
104
111
|
## `replaceDirectoryAtomic`
|
|
105
112
|
|
|
@@ -115,13 +122,17 @@ await replaceDirectoryAtomic({
|
|
|
115
122
|
```
|
|
116
123
|
|
|
117
124
|
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.
|
|
125
|
+
Concurrent replacements of the same resolved target are serialized inside the
|
|
126
|
+
current process so their backup, commit, and cleanup phases cannot interleave.
|
|
127
|
+
`backupPrefix`, when supplied, is sanitized as one path prefix and cannot contain
|
|
128
|
+
path separators or NUL bytes; the generated backup tail is randomized.
|
|
118
129
|
|
|
119
130
|
Use it when callers must see a whole staged tree at the target path. For single-file replacement, `replaceFileAtomic` is the right tool.
|
|
120
131
|
|
|
121
132
|
## `writeTextAtomic`
|
|
122
133
|
|
|
123
134
|
Atomic UTF-8 text write with the same secure defaults as `writeJson`: sibling
|
|
124
|
-
temp file,
|
|
135
|
+
temp file, descriptor-bound mode setting and fsync, rename, and parent fsync.
|
|
125
136
|
It delegates to `replaceFileAtomic()` with a smaller call shape. Use it when
|
|
126
137
|
you do not need replacement hooks such as `beforeRename`, `preserveExistingMode`,
|
|
127
138
|
or custom copy-fallback policy.
|
|
@@ -141,8 +152,8 @@ Options:
|
|
|
141
152
|
```ts
|
|
142
153
|
type WriteTextAtomicOptions = {
|
|
143
154
|
mode?: number; // file mode (default 0o600)
|
|
144
|
-
dirMode?: number; // mode
|
|
145
|
-
trailingNewline?: boolean; // append "\n" if missing
|
|
155
|
+
dirMode?: number; // parent mode (default 0o777 masked by process umask)
|
|
156
|
+
trailingNewline?: boolean; // append "\n" if missing; default false
|
|
146
157
|
durable?: boolean; // default true; false skips temp/parent fsync
|
|
147
158
|
};
|
|
148
159
|
```
|
|
@@ -157,7 +168,14 @@ Rename a path. If the rename fails with `EXDEV` (cross-device), fall back to
|
|
|
157
168
|
copying into a staged sibling path, renaming that staged path into place, and
|
|
158
169
|
then removing only the source entries that were copied. The fallback avoids
|
|
159
170
|
buffering regular files into memory and does not tighten the destination parent
|
|
160
|
-
directory mode.
|
|
171
|
+
directory mode. Staged file modes are applied through their still-open handles.
|
|
172
|
+
If descriptor-bound mode application fails, the staged path is removed and the
|
|
173
|
+
move fails before publication. A transient staged-path cleanup failure retains
|
|
174
|
+
an identity-bound process-exit cleanup retry.
|
|
175
|
+
On POSIX, staged directory modes are applied through no-follow directory
|
|
176
|
+
descriptors; on Windows, Node cannot portably open those descriptors and no
|
|
177
|
+
pathname `chmod` fallback is attempted, so directory modes remain subject to
|
|
178
|
+
Windows' `mkdir(mode)` behavior.
|
|
161
179
|
|
|
162
180
|
```ts
|
|
163
181
|
import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
|
|
@@ -212,6 +230,17 @@ await replaceFileAtomic({
|
|
|
212
230
|
});
|
|
213
231
|
```
|
|
214
232
|
|
|
233
|
+
The synchronous injectable interface has one optional descriptor-mode operation:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
type ReplaceFileAtomicSyncFileSystem = {
|
|
237
|
+
// other required operations omitted
|
|
238
|
+
fchmodSync?: typeof import("node:fs").fchmodSync;
|
|
239
|
+
};
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The async interface already requires `open()`, whose `FileHandle` supplies `chmod()`, so injecting `node:fs` or another conforming adapter needs no new async member. On POSIX, that `open()` must support no-follow directory descriptors as Node does. A custom synchronous filesystem that passes `mode`, `dirMode`, or `preserveExistingMode` must supply `fchmodSync`; omission fails before any file or directory is created and never falls back to a pathname `chmod`. Existing synchronous adapters that request none of those options may omit it; their parent is still opened and identity-checked through a no-follow directory descriptor. Injecting plain `node:fs` supports explicit file and directory modes. Older adapter literals may continue to include `chmod` or `chmodSync` for source compatibility, but those operations are ignored. Copy fallback applies the file mode through its pinned destination descriptor as well, preserving exact modes despite the process umask.
|
|
243
|
+
|
|
215
244
|
## See also
|
|
216
245
|
|
|
217
246
|
- [`root()`](root.md) — when you want method-style writes with the boundary baked in.
|
package/docs/errors.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Every failure that's the library's job to surface lands as an `FsSafeError` with a closed `code` union you can branch on. Catch by code, not by message text — messages may change, codes will not.
|
|
4
4
|
|
|
5
|
+
Path and archive-entry details embedded in diagnostics escape control characters
|
|
6
|
+
as `\\uXXXX` sequences. This keeps attacker-controlled names on one log line;
|
|
7
|
+
the escaped message is for diagnosis, not for reconstructing the original path.
|
|
8
|
+
|
|
5
9
|
```ts
|
|
6
10
|
import { FsSafeError, type FsSafeErrorCode } from "@openclaw/fs-safe";
|
|
7
11
|
```
|
|
@@ -50,8 +54,17 @@ destination.
|
|
|
50
54
|
|
|
51
55
|
`category` separates caller-policy failures from operational failures:
|
|
52
56
|
|
|
53
|
-
- `"policy"` — unsafe input or target state, such as `outside-workspace`, `symlink`, `hardlink`, or `too-large`.
|
|
54
|
-
- `"operational"` — environment/runtime failures, such as helper startup, platform support, timeout, or unverifiable permissions.
|
|
57
|
+
- `"policy"` — unsafe input or target state rejected by a safety policy, such as `outside-workspace`, `symlink`, `hardlink`, or `too-large`.
|
|
58
|
+
- `"operational"` — routine filesystem outcomes or environment/runtime failures, such as `not-found`, `not-empty`, `not-removable`, helper startup, platform support, timeout, or unverifiable permissions.
|
|
59
|
+
|
|
60
|
+
Routine absence or inability to remove a path does not by itself indicate a
|
|
61
|
+
filesystem boundary violation. Branch on the specific code when the distinction
|
|
62
|
+
between those operational outcomes matters.
|
|
63
|
+
|
|
64
|
+
The operational set is exactly `helper-failed`, `helper-unavailable`,
|
|
65
|
+
`not-empty`, `not-found`, `not-removable`, `permission-unverified`, `read-failed`,
|
|
66
|
+
`timeout`, and `unsupported-platform`. Every other current `FsSafeErrorCode`, including
|
|
67
|
+
`store-reentrant-update`, is categorized as `policy`.
|
|
55
68
|
|
|
56
69
|
## Code union
|
|
57
70
|
|
|
@@ -74,7 +87,9 @@ type FsSafeErrorCode =
|
|
|
74
87
|
| "path-alias"
|
|
75
88
|
| "path-mismatch"
|
|
76
89
|
| "permission-unverified"
|
|
90
|
+
| "read-failed"
|
|
77
91
|
| "secret-exists"
|
|
92
|
+
| "store-reentrant-update"
|
|
78
93
|
| "symlink"
|
|
79
94
|
| "timeout"
|
|
80
95
|
| "too-large"
|
|
@@ -92,9 +107,9 @@ type FsSafeErrorCode =
|
|
|
92
107
|
| `helper-failed` | A native mechanism or multi-step operational helper failed. | Inspect `cause` and any operation-specific `details`; retrying may be unsafe if the operation partially completed. |
|
|
93
108
|
| `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, missing/incompatible bundled binary, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
|
|
94
109
|
| `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
|
|
95
|
-
| `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows. |
|
|
110
|
+
| `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows, a drive-relative segment in a portable relative path or store key, or a leading drive-relative spelling such as `C:name` used as a Root destination. Existing-object Root lookups retain legal POSIX drive-like names. |
|
|
96
111
|
| `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
|
|
97
|
-
| `not-file` | Read or copy targeted a non-regular file. | Target was a directory, FIFO, socket, device. |
|
|
112
|
+
| `not-file` | Read or copy targeted a non-regular file, or a path walk found a non-directory ancestor. | Target was a directory, FIFO, socket, device, or an existing file was followed by another segment. |
|
|
98
113
|
| `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
|
|
99
114
|
| `not-owned` | A secure file owner check failed. | File is owned by another UID. |
|
|
100
115
|
| `not-removable` | `remove()` couldn't `unlink`/`rmdir` for a reason other than non-empty. | Permissions, device busy, immutable bit. |
|
|
@@ -102,11 +117,13 @@ type FsSafeErrorCode =
|
|
|
102
117
|
| `path-alias` | A path alias check failed (e.g. canonical-real-path moved out of the root). | Symlink resolution lands outside the root. |
|
|
103
118
|
| `path-mismatch` | Post-open identity check failed: the opened fd does not match the resolved path. | TOCTOU — something else swapped the path between resolve and open. |
|
|
104
119
|
| `permission-unverified` | A secure file check could not verify required permissions. | Windows ACL inspection failed; POSIX ownership/mode was unavailable. |
|
|
120
|
+
| `read-failed` | A validated file could not be read because of an operational filesystem or device failure. | I/O error, media failure, or another runtime read failure; inspect `cause`. |
|
|
105
121
|
| `secret-exists` | `createSecretFileAtomic()` found an existing final path. | First-writer-wins secret creation lost a race or the credential was already initialized. |
|
|
122
|
+
| `store-reentrant-update` | A `JsonStore.update()` callback called `update()` or `updateOr()` for the same canonical store before returning. | Reentrant mutation would deadlock or lose an update; return the complete next value from the outer callback. |
|
|
106
123
|
| `symlink` | Path component is a symlink, policy is `reject`. | Caller followed a symlink they shouldn't have, or `symlinks: "reject"` is set. |
|
|
107
124
|
| `timeout` | An operation with a wall-clock budget overran. | Secure file read or timed operation exceeded `timeoutMs`. |
|
|
108
125
|
| `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
|
|
109
|
-
| `unsupported-platform` |
|
|
126
|
+
| `unsupported-platform` | Reserved compatibility code for a platform-specific operation. | No current public helper emits this `FsSafeError` code. Platform-specific APIs currently return a typed unsupported result or use `helper-unavailable`; keep the union member when exhaustively switching across supported package versions. |
|
|
110
127
|
|
|
111
128
|
## Branching
|
|
112
129
|
|
|
@@ -138,11 +155,13 @@ try {
|
|
|
138
155
|
}
|
|
139
156
|
```
|
|
140
157
|
|
|
141
|
-
The
|
|
158
|
+
The `default` above deliberately rethrows unknown codes, so it remains safe as
|
|
159
|
+
the union grows. If you want the compiler to flag every newly added code, end
|
|
160
|
+
an exhaustive switch with a `never` assertion instead of a general `default`.
|
|
142
161
|
|
|
143
162
|
## Distinguishing from `NodeJS.ErrnoException`
|
|
144
163
|
|
|
145
|
-
Some failures bubble up as native Node errors (e.g. `EACCES`, `EISDIR`, `EBUSY`) when they don't map cleanly to a library code. Inspect both:
|
|
164
|
+
Some failures bubble up as native Node errors (e.g. `EACCES`, `EPERM`, `EISDIR`, `EBUSY`) when they don't map cleanly to a library code. Inspect both:
|
|
146
165
|
|
|
147
166
|
```ts
|
|
148
167
|
import { FsSafeError } from "@openclaw/fs-safe";
|
|
@@ -154,7 +173,8 @@ try {
|
|
|
154
173
|
handleFsSafe(err);
|
|
155
174
|
return;
|
|
156
175
|
}
|
|
157
|
-
|
|
176
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
177
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
158
178
|
handleAccess();
|
|
159
179
|
return;
|
|
160
180
|
}
|
|
@@ -164,6 +184,12 @@ try {
|
|
|
164
184
|
|
|
165
185
|
A common pattern is to wrap your domain code in a single try/catch that maps both shapes to your application's typed error format.
|
|
166
186
|
|
|
187
|
+
On Windows, access-denied failures from native root/open filesystem operations
|
|
188
|
+
use `EPERM` to match Node/libuv and the JavaScript fallback. Security-descriptor
|
|
189
|
+
inspection has its own failure surface. Older fs-safe versions could report
|
|
190
|
+
`EACCES` for the same root/open condition, so consumers spanning versions
|
|
191
|
+
should accept both codes.
|
|
192
|
+
|
|
167
193
|
## Specialty errors
|
|
168
194
|
|
|
169
195
|
A handful of helpers throw their own typed errors instead of `FsSafeError`:
|
package/docs/file-store.md
CHANGED
|
@@ -43,7 +43,7 @@ type FileStore = {
|
|
|
43
43
|
readonly rootDir: string;
|
|
44
44
|
path(relativePath: string): string;
|
|
45
45
|
root(): Promise<Root>;
|
|
46
|
-
write(rel, data: string |
|
|
46
|
+
write(rel, data: string | Uint8Array, options?): Promise<string>;
|
|
47
47
|
writeStream(rel, stream: Readable, options?): Promise<string>;
|
|
48
48
|
copyIn(rel, sourcePath: string, options?): Promise<string>;
|
|
49
49
|
open(rel, options?): Promise<OpenResult>;
|
|
@@ -64,6 +64,15 @@ type FileStore = {
|
|
|
64
64
|
|
|
65
65
|
`path()` returns the absolute path the store would use, after asserting it stays inside `rootDir`. Useful for logging or for handing to other libraries.
|
|
66
66
|
|
|
67
|
+
Every `relativePath` is a portable store key, including keys passed to reads,
|
|
68
|
+
`exists`, and `remove`. A segment with a Windows drive-relative spelling such
|
|
69
|
+
as `C:name` (including an embedded segment such as `a/C:name`) throws
|
|
70
|
+
`invalid-path` on every platform. This prevents a key created on POSIX from
|
|
71
|
+
aliasing a different file when the store is moved to Windows. Colons elsewhere,
|
|
72
|
+
such as the timestamp in `logs/2026-08-02T10:30:00Z.log`, remain valid.
|
|
73
|
+
Keys with surrounding whitespace also throw `invalid-path`; the store never
|
|
74
|
+
silently trims one caller-supplied key onto another key.
|
|
75
|
+
|
|
67
76
|
`root()` returns a [`Root`](root.md) handle for the same directory when you need the full surface (move, list, mkdir). It's a fresh handle per call and is safe to call frequently.
|
|
68
77
|
|
|
69
78
|
## Writes
|
|
@@ -129,6 +138,10 @@ type FileStoreWriteOptions = {
|
|
|
129
138
|
## Reads
|
|
130
139
|
|
|
131
140
|
`open`, `read`, `readBytes`, `readText`, and `readJson` delegate to a fresh `Root` with `hardlinks: "reject"` and the store's `maxBytes`. Same return shapes as `Root`.
|
|
141
|
+
Async and sync reads both report `not-file` when the key names a directory;
|
|
142
|
+
stable hardlinks and symlinks retain `hardlink` and `symlink`. The `IfExists`
|
|
143
|
+
methods return their nullish result only for `not-found`. Operational filesystem
|
|
144
|
+
read failures use `read-failed` with the Node error retained in `cause`.
|
|
132
145
|
|
|
133
146
|
## `remove(rel)` / `exists(rel)`
|
|
134
147
|
|
package/docs/filename.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Filenames
|
|
2
2
|
|
|
3
|
-
`sanitizeUntrustedFileName(name, fallback)` reduces a filename string from an untrusted source to one
|
|
3
|
+
`sanitizeUntrustedFileName(name, fallback)` reduces a filename string from an untrusted source to one traversal-free path segment. Use it as a thin first pass before storing user-supplied names; pair with [`safeDirName`](install-path.md#safedirname) when you need stricter directory-name handling.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
-
import { sanitizeUntrustedFileName } from "@openclaw/fs-safe";
|
|
6
|
+
import { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced";
|
|
7
7
|
|
|
8
8
|
const safe = sanitizeUntrustedFileName(req.body.fileName, "upload");
|
|
9
9
|
await fs.write(`uploads/${safe}`, body);
|
|
@@ -24,9 +24,12 @@ In order:
|
|
|
24
24
|
3. **Strip non-portable characters.** C0/C1 controls (`0x00`–`0x1f`, `0x7f`–`0x9f`) and the Windows-invalid set `< > : " / \\ | ? *` are removed on every platform.
|
|
25
25
|
4. **Trim again.**
|
|
26
26
|
5. If the result is empty, `"."`, or `".."`, return `fallbackName`.
|
|
27
|
-
6. **
|
|
27
|
+
6. **Suffix Windows reserved basenames.** Compare the part before the first `.` case-insensitively with the Windows device-name set, including `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`, `CONOUT$`, `COM1..9`, `LPT1..9`, and their superscript `¹`, `²`, and `³` variants. Windows-ignored spaces and dots at the end of that basename do not disguise a device name. A match gains `_` before its extension, preserving the original case and extension on every platform.
|
|
28
|
+
7. **Truncate.** If the cleaned segment is longer than 200 UTF-16 code units, take up to the first 200 without splitting a valid Unicode surrogate pair.
|
|
28
29
|
|
|
29
|
-
That's it. The function stays intentionally small: it
|
|
30
|
+
That's it. The function stays intentionally small: it removes traversal and
|
|
31
|
+
the most obvious cross-platform device and character hazards, but it is not a
|
|
32
|
+
complete portable-filename or uniqueness policy.
|
|
30
33
|
|
|
31
34
|
## Examples
|
|
32
35
|
|
|
@@ -38,35 +41,33 @@ sanitizeUntrustedFileName("a\u0000b\tc", "upload"); // "abc"
|
|
|
38
41
|
sanitizeUntrustedFileName(" ", "fallback"); // "fallback"
|
|
39
42
|
sanitizeUntrustedFileName(".", "fallback"); // "fallback"
|
|
40
43
|
sanitizeUntrustedFileName("..", "fallback"); // "fallback"
|
|
41
|
-
sanitizeUntrustedFileName("
|
|
44
|
+
sanitizeUntrustedFileName("CON", "fallback"); // "CON_"
|
|
45
|
+
sanitizeUntrustedFileName("nul.txt", "fallback"); // "nul_.txt"
|
|
46
|
+
sanitizeUntrustedFileName("aux.c", "fallback"); // "aux_.c"
|
|
47
|
+
sanitizeUntrustedFileName("conin$", "fallback"); // "conin$_"
|
|
48
|
+
sanitizeUntrustedFileName("a".repeat(300), "x"); // 200-char "aaa..."
|
|
42
49
|
```
|
|
43
50
|
|
|
44
51
|
## What it does **not** do
|
|
45
52
|
|
|
46
53
|
The function is deliberately narrow. It will not:
|
|
47
54
|
|
|
48
|
-
- Reject Windows reserved names (`CON`, `PRN`, `AUX`, `NUL`, `COM1..9`, `LPT1..9`).
|
|
49
55
|
- Replace leading dots (so a name like `.config` stays hidden on POSIX systems).
|
|
50
|
-
- Trim trailing dots
|
|
56
|
+
- Trim trailing dots. Surrounding spaces are removed by the documented trim
|
|
57
|
+
steps, but Windows-normalized dot/space spellings can still alias; reject or
|
|
58
|
+
rewrite them when Windows portability or cross-platform migration matters.
|
|
51
59
|
- Add an extension or change case.
|
|
52
60
|
- Validate file *content*. To enforce an extension allow-list, check after sanitization.
|
|
53
61
|
- Deduplicate against existing files. Append a random suffix if you need uniqueness.
|
|
54
62
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
```ts
|
|
58
|
-
const trimmed = sanitizeUntrustedFileName(input, "upload");
|
|
59
|
-
const noWindowsReserved = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..+)?$/i.test(trimmed)
|
|
60
|
-
? "upload"
|
|
61
|
-
: trimmed;
|
|
62
|
-
```
|
|
63
|
+
Windows reserved basenames are handled by the default portability pass; callers no longer need to layer a separate reserved-name recipe on top.
|
|
63
64
|
|
|
64
65
|
## Common patterns
|
|
65
66
|
|
|
66
67
|
### Make a unique filename
|
|
67
68
|
|
|
68
69
|
```ts
|
|
69
|
-
import { sanitizeUntrustedFileName } from "@openclaw/fs-safe";
|
|
70
|
+
import { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced";
|
|
70
71
|
import { randomUUID } from "node:crypto";
|
|
71
72
|
|
|
72
73
|
const base = sanitizeUntrustedFileName(req.body.fileName, "upload");
|
package/docs/index.md
CHANGED
|
@@ -69,6 +69,7 @@ await fs.remove("notes/archive/today.txt");
|
|
|
69
69
|
| [Permissions](permissions.md) | POSIX mode helpers plus Windows ACL inspection, raw owner/ACE facts, remediation, and private-directory creation. |
|
|
70
70
|
| [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
|
|
71
71
|
| [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
|
|
72
|
+
| [Public API inventory](public-api.md) | Complete runtime/type export cross-check against the generated declarations. |
|
|
72
73
|
| [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
|
|
73
74
|
| [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
|
|
74
75
|
| [`@openclaw/fs-safe/test-hooks`](test-hooks.md) | Test-only injection hooks for reproducing open/lstat races. |
|
package/docs/install-path.md
CHANGED
|
@@ -95,16 +95,19 @@ Returns a directory-safe segment **plus** a short content hash when sanitization
|
|
|
95
95
|
|
|
96
96
|
```ts
|
|
97
97
|
safePathSegmentHashed("plugin-v1"); // "plugin-v1" (unchanged short input)
|
|
98
|
-
safePathSegmentHashed("plugin/v1"); // "plugin-v1-
|
|
99
|
-
safePathSegmentHashed("plugin\\v1"); // "plugin-v1-
|
|
100
|
-
safePathSegmentHashed("Über@"); // "ber-
|
|
101
|
-
safePathSegmentHashed(""); // "skill"
|
|
102
|
-
safePathSegmentHashed("."); // "skill"
|
|
98
|
+
safePathSegmentHashed("plugin/v1"); // "plugin-v1-d9ef8af2eb"
|
|
99
|
+
safePathSegmentHashed("plugin\\v1"); // "plugin-v1-bed33f465b"
|
|
100
|
+
safePathSegmentHashed("Über@"); // "ber-e392bba2b3"
|
|
101
|
+
safePathSegmentHashed(""); // "skill-e3b0c44298"
|
|
102
|
+
safePathSegmentHashed("."); // "skill-cdb4ee2aea"
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
The sanitization is more aggressive than `safeDirName`: any character not in `[A-Za-z0-9._-]` becomes `-`, runs of `-` collapse, leading and trailing `-` are stripped, the empty/`.`/`..` fallback is `"skill"`. Long results are truncated to 50 chars before the hash is appended.
|
|
106
106
|
|
|
107
|
-
The
|
|
107
|
+
The suffix is the first 10 hex characters of `sha256(trimmedInput)`. It makes
|
|
108
|
+
collisions between distinct trimmed inputs unlikely, but it is a 40-bit
|
|
109
|
+
identifier rather than a mathematical uniqueness guarantee. Inputs that differ
|
|
110
|
+
only by surrounding whitespace intentionally map to the same output.
|
|
108
111
|
|
|
109
112
|
## Common patterns
|
|
110
113
|
|
package/docs/json.md
CHANGED
|
@@ -133,7 +133,12 @@ where lower latency matters more than crash-durability.
|
|
|
133
133
|
|
|
134
134
|
### `writeJsonSync(pathname, data)`
|
|
135
135
|
|
|
136
|
-
Synchronous variant.
|
|
136
|
+
Synchronous variant. It pretty-prints with two spaces, appends a newline,
|
|
137
|
+
creates parents at `0o700`, writes at `0o600`, and synchronizes the parent
|
|
138
|
+
directory best-effort. It has no options bag. On `EPERM`/`EEXIST`, its legacy
|
|
139
|
+
compatibility path may replace by copy rather than atomic rename; use the async
|
|
140
|
+
`writeJson()`/`replaceFileAtomic()` surfaces when fallback policy must be
|
|
141
|
+
explicit.
|
|
137
142
|
|
|
138
143
|
```ts
|
|
139
144
|
writeJsonSync("./prefs.json", { theme: "dark" });
|
|
@@ -187,7 +192,8 @@ const state = await readJsonIfExists<State>("./state.json");
|
|
|
187
192
|
|
|
188
193
|
| Throw / return | When |
|
|
189
194
|
|---|---|
|
|
190
|
-
| `null`
|
|
195
|
+
| `null` from `tryReadJson` / `tryReadJsonSync` | Any read or parse failure. |
|
|
196
|
+
| `null` from `readJsonIfExists` | File is missing; invalid or unreadable input still throws. |
|
|
191
197
|
| `JsonFileReadError` | `readJson` or `readJsonIfExists` saw unreadable or invalid input. Inspect `cause`. |
|
|
192
198
|
| Native `NodeJS.ErrnoException` | Lower-level fs errors not wrapped. |
|
|
193
199
|
|