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