@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.
- package/CHANGELOG.md +58 -0
- package/README.md +58 -2
- package/dist/absolute-path.d.ts +14 -0
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +203 -2
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +2 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +81 -7
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +25 -42
- package/dist/bounded-read-stream.d.ts +8 -0
- package/dist/bounded-read-stream.d.ts.map +1 -0
- package/dist/bounded-read-stream.js +20 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/directory-guard.d.ts +18 -0
- package/dist/directory-guard.d.ts.map +1 -0
- package/dist/directory-guard.js +70 -0
- package/dist/file-lock.d.ts +4 -2
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +13 -2
- package/dist/file-store-boundary.d.ts +25 -0
- package/dist/file-store-boundary.d.ts.map +1 -0
- package/dist/file-store-boundary.js +168 -0
- package/dist/file-store-prune.d.ts +12 -0
- package/dist/file-store-prune.d.ts.map +1 -0
- package/dist/file-store-prune.js +86 -0
- package/dist/file-store-source.d.ts +5 -0
- package/dist/file-store-source.d.ts.map +1 -0
- package/dist/file-store-source.js +30 -0
- package/dist/file-store.d.ts +2 -6
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +122 -125
- package/dist/guarded-mkdir.d.ts +6 -0
- package/dist/guarded-mkdir.d.ts.map +1 -0
- package/dist/guarded-mkdir.js +45 -0
- package/dist/guarded-mutation.d.ts +33 -0
- package/dist/guarded-mutation.d.ts.map +1 -0
- package/dist/guarded-mutation.js +76 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/json-document-store.d.ts +2 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +7 -3
- package/dist/json-durable-queue.d.ts +45 -0
- package/dist/json-durable-queue.d.ts.map +1 -0
- package/dist/json-durable-queue.js +200 -0
- package/dist/json.d.ts +35 -5
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +69 -2
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +17 -3
- package/dist/lock-config.d.ts +10 -0
- package/dist/lock-config.d.ts.map +1 -0
- package/dist/lock-config.js +12 -0
- package/dist/move-path.d.ts +1 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +195 -7
- package/dist/output.d.ts +13 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +69 -0
- package/dist/path-stat.d.ts +4 -0
- package/dist/path-stat.d.ts.map +1 -0
- package/dist/path-stat.js +15 -0
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +6 -3
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +44 -19
- package/dist/private-file-store.d.ts +5 -7
- package/dist/private-file-store.d.ts.map +1 -1
- package/dist/private-file-store.js +21 -34
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +28 -15
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +52 -8
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +5 -4
- package/dist/replace-file.d.ts +1 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +93 -31
- package/dist/root-context.d.ts +23 -0
- package/dist/root-context.d.ts.map +1 -0
- package/dist/root-context.js +62 -0
- package/dist/root-errors.d.ts +4 -0
- package/dist/root-errors.d.ts.map +1 -0
- package/dist/root-errors.js +21 -0
- package/dist/root-impl.d.ts +108 -0
- package/dist/root-impl.d.ts.map +1 -0
- package/dist/{safe-root.js → root-impl.js} +484 -265
- package/dist/root.d.ts +1 -107
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +1 -1332
- package/dist/safe-path-segment.d.ts +9 -0
- package/dist/safe-path-segment.d.ts.map +1 -0
- package/dist/safe-path-segment.js +51 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +6 -1
- package/dist/sibling-temp.d.ts +1 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +32 -7
- package/dist/sidecar-lock.d.ts +2 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +93 -13
- package/dist/store.d.ts +1 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1 -0
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +7 -4
- package/dist/test-hooks.d.ts +6 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/text-atomic.d.ts +7 -0
- package/dist/text-atomic.d.ts.map +1 -1
- package/dist/text-atomic.js +3 -2
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +48 -13
- package/docs/advanced.md +110 -0
- package/docs/archive.md +199 -0
- package/docs/atomic.md +191 -0
- package/docs/config.md +94 -0
- package/docs/contributing.md +80 -0
- package/docs/errors.md +154 -0
- package/docs/file-store.md +213 -0
- package/docs/filename.md +0 -0
- package/docs/index.md +77 -0
- package/docs/install-path.md +167 -0
- package/docs/install.md +150 -0
- package/docs/json-store.md +204 -0
- package/docs/json.md +192 -0
- package/docs/local-roots.md +164 -0
- package/docs/output.md +92 -0
- package/docs/path-scope.md +130 -0
- package/docs/path.md +191 -0
- package/docs/permissions.md +95 -0
- package/docs/private-file-store.md +45 -0
- package/docs/python-helper.md +103 -0
- package/docs/quickstart.md +147 -0
- package/docs/reading.md +174 -0
- package/docs/regular-file.md +175 -0
- package/docs/root.md +205 -0
- package/docs/secret-file.md +154 -0
- package/docs/secure-file.md +82 -0
- package/docs/security-model.md +99 -0
- package/docs/sidecar-lock.md +230 -0
- package/docs/store.md +77 -0
- package/docs/temp.md +284 -0
- package/docs/test-hooks.md +74 -0
- package/docs/testing.md +178 -0
- package/docs/timing.md +121 -0
- package/docs/types.md +155 -0
- package/docs/walk.md +63 -0
- package/docs/writing.md +222 -0
- package/package.json +10 -2
- package/dist/archive-utils.d.ts +0 -3
- package/dist/archive-utils.d.ts.map +0 -1
- package/dist/archive-utils.js +0 -26
- package/dist/boundary-file-read.d.ts +0 -44
- package/dist/boundary-file-read.d.ts.map +0 -1
- package/dist/boundary-file-read.js +0 -129
- package/dist/boundary-path.d.ts +0 -39
- package/dist/boundary-path.d.ts.map +0 -1
- package/dist/boundary-path.js +0 -598
- package/dist/fs-pinned-path-helper.d.ts +0 -7
- package/dist/fs-pinned-path-helper.d.ts.map +0 -1
- package/dist/fs-pinned-path-helper.js +0 -182
- package/dist/fs-pinned-write-helper.d.ts +0 -21
- package/dist/fs-pinned-write-helper.d.ts.map +0 -1
- package/dist/fs-pinned-write-helper.js +0 -263
- package/dist/hardlink-guards.d.ts +0 -7
- package/dist/hardlink-guards.d.ts.map +0 -1
- package/dist/hardlink-guards.js +0 -30
- package/dist/install-safe-path.d.ts +0 -20
- package/dist/install-safe-path.d.ts.map +0 -1
- package/dist/install-safe-path.js +0 -94
- package/dist/json-file.d.ts +0 -3
- package/dist/json-file.d.ts.map +0 -1
- package/dist/json-file.js +0 -123
- package/dist/json-files.d.ts +0 -20
- package/dist/json-files.d.ts.map +0 -1
- package/dist/json-files.js +0 -153
- package/dist/path-alias-guards.d.ts +0 -19
- package/dist/path-alias-guards.d.ts.map +0 -1
- package/dist/path-alias-guards.js +0 -21
- package/dist/path-guards.d.ts +0 -7
- package/dist/path-guards.d.ts.map +0 -1
- package/dist/path-guards.js +0 -49
- package/dist/path-safety.d.ts +0 -12
- package/dist/path-safety.d.ts.map +0 -1
- package/dist/path-safety.js +0 -50
- package/dist/safe-open-sync.d.ts +0 -24
- package/dist/safe-open-sync.d.ts.map +0 -1
- package/dist/safe-open-sync.js +0 -71
- package/dist/safe-root.d.ts +0 -123
- package/dist/safe-root.d.ts.map +0 -1
- package/dist/secure-temp-workspace.d.ts +0 -25
- package/dist/secure-temp-workspace.d.ts.map +0 -1
- package/dist/secure-temp-workspace.js +0 -136
- package/dist/sibling-temp-file.d.ts +0 -16
- package/dist/sibling-temp-file.d.ts.map +0 -1
- package/dist/sibling-temp-file.js +0 -73
- package/dist/sibling-temp-write.d.ts +0 -8
- package/dist/sibling-temp-write.d.ts.map +0 -1
- 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()])].
|
|
24
|
+
const roots = [...(allowedRoots ?? [os.homedir(), os.tmpdir()])].flatMap((root) => {
|
|
25
|
+
const lexicalRoot = path.resolve(root);
|
|
22
26
|
try {
|
|
23
|
-
|
|
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
|
|
32
|
+
return [lexicalRoot];
|
|
27
33
|
}
|
|
28
34
|
});
|
|
29
35
|
return [...new Set(roots)];
|
|
30
36
|
}
|
|
31
|
-
function
|
|
32
|
-
let resolvedTargetPath = path.resolve(targetPath);
|
|
37
|
+
function resolveTrashTargetPath(targetPath) {
|
|
33
38
|
try {
|
|
34
|
-
|
|
39
|
+
return { path: path.resolve(fs.realpathSync.native(targetPath)), resolved: true };
|
|
35
40
|
}
|
|
36
41
|
catch {
|
|
37
|
-
//
|
|
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(
|
|
120
|
+
function movePathToDestination(target, dest) {
|
|
121
|
+
getFsSafeTestHooks()?.beforeTrashMove?.(target.path, dest);
|
|
122
|
+
assertTrashTargetGuard(target);
|
|
90
123
|
try {
|
|
91
|
-
|
|
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
|
-
|
|
104
|
-
fs.
|
|
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(
|
|
157
|
+
if (movePathToDestination(target, dest)) {
|
|
123
158
|
return dest;
|
|
124
159
|
}
|
|
125
160
|
}
|
package/docs/advanced.md
ADDED
|
@@ -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.
|
package/docs/archive.md
ADDED
|
@@ -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.
|