@openclaw/fs-safe 0.8.6 → 0.9.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 +15 -0
- package/dist/archive-durability.d.ts +24 -0
- package/dist/archive-durability.d.ts.map +1 -0
- package/dist/archive-durability.js +180 -0
- package/dist/archive-gzip-tail.d.ts.map +1 -1
- package/dist/archive-gzip-tail.js +2 -1
- package/dist/archive-input.js +4 -4
- package/dist/archive-kind.d.ts.map +1 -1
- package/dist/archive-kind.js +3 -2
- package/dist/archive-merge.d.ts +1 -0
- package/dist/archive-merge.d.ts.map +1 -1
- package/dist/archive-merge.js +41 -8
- package/dist/archive-native.d.ts +1 -0
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +1 -0
- package/dist/archive-options.d.ts +2 -0
- package/dist/archive-options.d.ts.map +1 -1
- package/dist/archive-parser.wasm +0 -0
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +5 -4
- package/dist/archive-staging.d.ts +1 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +24 -19
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +9 -4
- package/dist/copy-publication.d.ts +6 -0
- package/dist/copy-publication.d.ts.map +1 -0
- package/dist/copy-publication.js +3 -0
- package/dist/directory-mode-node.d.ts.map +1 -1
- package/dist/directory-mode-node.js +4 -4
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +5 -4
- package/dist/file-store-sync-write.d.ts +1 -0
- package/dist/file-store-sync-write.d.ts.map +1 -1
- package/dist/file-store-sync-write.js +9 -6
- package/dist/file-store.d.ts +4 -0
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +10 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +1 -2
- package/dist/guarded-mutation.js +1 -1
- package/dist/install-path.js +7 -7
- package/dist/json-document-store.d.ts +3 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +1 -0
- package/dist/json-durable-queue-directory.js +3 -3
- package/dist/json-durable-queue-ownership.js +3 -2
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +18 -13
- package/dist/move-path-cleanup.d.ts.map +1 -1
- package/dist/move-path-cleanup.js +4 -3
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +31 -18
- package/dist/opened-file-failure.d.ts +1 -1
- package/dist/opened-file-failure.d.ts.map +1 -1
- package/dist/opened-file-failure.js +3 -2
- package/dist/permissions.js +3 -3
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +9 -3
- package/dist/publish-file.js +21 -21
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +30 -17
- package/dist/replace-file-copy-source.d.ts.map +1 -1
- package/dist/replace-file-copy-source.js +10 -5
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +8 -3
- package/dist/root-impl.d.ts +1 -1
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +16 -2
- package/dist/secret-file.d.ts +1 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +1 -0
- package/dist/secret-read-async.js +5 -5
- package/dist/secure-file.d.ts +1 -1
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +19 -9
- package/dist/sibling-staged-file.js +7 -7
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +16 -7
- package/dist/sidecar-lock-acquire.d.ts +1 -1
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +4 -3
- package/dist/sidecar-lock-policy.js +2 -2
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +16 -4
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +2 -1
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +9 -4
- package/dist/walk.js +2 -2
- package/dist/write-open-flags.d.ts.map +1 -1
- package/dist/write-open-flags.js +1 -2
- package/docs/archive.md +35 -2
- package/docs/file-store.md +48 -3
- package/docs/json-store.md +10 -0
- package/docs/reading.md +6 -0
- package/docs/root.md +2 -2
- package/docs/secret-file.md +6 -0
- package/docs/sidecar-lock.md +2 -0
- package/docs/writing.md +5 -3
- package/package.json +11 -11
|
@@ -90,7 +90,13 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
90
90
|
await opened.handle.close().catch(() => undefined);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
-
|
|
93
|
+
let before;
|
|
94
|
+
try {
|
|
95
|
+
before = fsSync.lstatSync(lockPath);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
before = missingSnapshotPath(error);
|
|
99
|
+
}
|
|
94
100
|
if (!before)
|
|
95
101
|
return null;
|
|
96
102
|
if (!before.isFile() || before.isSymbolicLink()) {
|
|
@@ -119,7 +125,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
119
125
|
options.onOpenFailure?.(error);
|
|
120
126
|
throw error;
|
|
121
127
|
}
|
|
122
|
-
const opened =
|
|
128
|
+
const opened = fsSync.fstatSync(handle.fd);
|
|
123
129
|
if (!opened.isFile()) {
|
|
124
130
|
if (options.rejectNonFile) {
|
|
125
131
|
throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
|
|
@@ -129,7 +135,13 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
129
135
|
if (!options.allowDescriptorIdentityDrift && !sameFileIdentity(before, opened))
|
|
130
136
|
return null;
|
|
131
137
|
const raw = (await readFileHandleBounded(handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
|
|
132
|
-
|
|
138
|
+
let after;
|
|
139
|
+
try {
|
|
140
|
+
after = fsSync.lstatSync(lockPath);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
after = missingSnapshotPath(error);
|
|
144
|
+
}
|
|
133
145
|
if (!after || !after.isFile() || !sameFileIdentity(before, after))
|
|
134
146
|
return null;
|
|
135
147
|
return { raw, stat: after };
|
|
@@ -241,7 +253,7 @@ export async function sidecarLockSnapshotStillPresent(lockPath, observed, option
|
|
|
241
253
|
}
|
|
242
254
|
export async function sidecarReclaimGuardExists(pathname) {
|
|
243
255
|
try {
|
|
244
|
-
|
|
256
|
+
fsSync.lstatSync(pathname);
|
|
245
257
|
return true;
|
|
246
258
|
}
|
|
247
259
|
catch (err) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,yBAAyB,EACzB,iBAAiB,EACjB,oBAAoB,EACpB,sBAAsB,EACvB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC1E,YAAY,EACV,yBAAyB,EACzB,0BAA0B,EAC1B,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,yBAAyB,EACzB,iBAAiB,EACjB,oBAAoB,EACpB,sBAAsB,EACvB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC1E,YAAY,EACV,yBAAyB,EACzB,0BAA0B,EAC1B,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,yBAAyB,CAAC;AAsIjC,0FAA0F;AAC1F,wBAAgB,uBAAuB,IAAI,OAAO,CAMjD;AAuFD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM;cAU3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACpD,yBAAyB,CAAC,QAAQ,CAAC,KAC3C,OAAO,CAAC,iBAAiB,CAAC;eAkBL,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACxD,yBAAyB,CAAC,QAAQ,CAAC,MACxC,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAC;iBAqBW,OAAO,CAAC,IAAI,CAAC;iBAQnB,IAAI;uBAIE,oBAAoB,EAAE;EAW/C;AAED,wBAAsB,eAAe,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAMZ"}
|
package/dist/sidecar-lock.js
CHANGED
|
@@ -85,7 +85,8 @@ function snapshotMatchesSync(lockPath, observed) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
function releaseAllReclaimGuardsSync(state) {
|
|
88
|
-
|
|
88
|
+
// Exit cleanup also visits managers created by older copies and never reopened here.
|
|
89
|
+
for (const reclaimGuardPath of state.reclaimGuards ?? []) {
|
|
89
90
|
try {
|
|
90
91
|
fsSync.rmdirSync(reclaimGuardPath);
|
|
91
92
|
state.reclaimGuards.delete(reclaimGuardPath);
|
|
@@ -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":"AASA,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;AA8DF,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;AAyCD,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,CAwBpB;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,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import fsSync from "node:fs";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { sameFileIdentityForCleanup } from "./file-identity.js";
|
|
@@ -81,12 +82,16 @@ function isNodeErrorWithCode(err, code) {
|
|
|
81
82
|
}
|
|
82
83
|
async function cleanupTempDir(dir, identity, onCleanupError) {
|
|
83
84
|
try {
|
|
84
|
-
|
|
85
|
+
let current;
|
|
86
|
+
try {
|
|
87
|
+
current = fsSync.lstatSync(dir, { bigint: true });
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
85
90
|
if (isNodeErrorWithCode(error, "ENOENT")) {
|
|
86
|
-
return
|
|
91
|
+
return;
|
|
87
92
|
}
|
|
88
93
|
throw error;
|
|
89
|
-
}
|
|
94
|
+
}
|
|
90
95
|
if (!current || !sameFileIdentityForCleanup(current, identity)) {
|
|
91
96
|
return;
|
|
92
97
|
}
|
|
@@ -107,7 +112,7 @@ export async function tempFile(params) {
|
|
|
107
112
|
const dir = await fs.mkdtemp(path.join(rootDir, prefix));
|
|
108
113
|
// Windows file indexes can exceed Number.MAX_SAFE_INTEGER. Cleanup receipts
|
|
109
114
|
// must retain the exact identity or adjacent directories can compare equal.
|
|
110
|
-
const identity =
|
|
115
|
+
const identity = fsSync.lstatSync(dir, { bigint: true });
|
|
111
116
|
const unregisterTempDir = registerTempPathForExit(dir, { recursive: true, identity });
|
|
112
117
|
const file = (fileName) => path.join(dir, sanitizeTempFileName(fileName ?? params.fileName ?? "download.bin"));
|
|
113
118
|
const cleanup = async () => {
|
package/dist/walk.js
CHANGED
|
@@ -76,7 +76,7 @@ async function resolveAsyncKind(fullPath, dirent, symlinks) {
|
|
|
76
76
|
if (symlinks === "include")
|
|
77
77
|
return "symlink";
|
|
78
78
|
try {
|
|
79
|
-
const stat =
|
|
79
|
+
const stat = fsSync.statSync(fullPath);
|
|
80
80
|
if (stat.isDirectory())
|
|
81
81
|
return "directory";
|
|
82
82
|
if (stat.isFile())
|
|
@@ -162,7 +162,7 @@ export async function walkDirectory(rootDir, options = {}) {
|
|
|
162
162
|
return;
|
|
163
163
|
let realDir;
|
|
164
164
|
try {
|
|
165
|
-
realDir =
|
|
165
|
+
realDir = fsSync.realpathSync.native(dir);
|
|
166
166
|
}
|
|
167
167
|
catch (error) {
|
|
168
168
|
recordFailedDir(result, root, dir, depth, error);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"write-open-flags.d.ts","sourceRoot":"","sources":["../src/write-open-flags.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"write-open-flags.d.ts","sourceRoot":"","sources":["../src/write-open-flags.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,SAAS,CAAC;AAG7B,wBAAgB,2BAA2B,CACzC,SAAS,GAAE,OAAO,CAAC,IAAI,CAAC,OAAO,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,CAAoB,GACjF,MAAM,CAIR;AAUD,wBAAsB,0BAA0B,CAC9C,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,wBAAgB,8BAA8B,CAC5C,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAOT"}
|
package/dist/write-open-flags.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import fsSync from "node:fs";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
2
|
import { hasNodeErrorCode } from "./path.js";
|
|
4
3
|
export function resolveNonblockingWriteFlag(constants = fsSync.constants) {
|
|
5
4
|
return process.platform !== "win32" && typeof constants.O_NONBLOCK === "number"
|
|
@@ -17,7 +16,7 @@ export async function isNonRegularWriteOpenError(error, filePath, flags) {
|
|
|
17
16
|
if (!isNonblockingWriteEnxio(error, flags))
|
|
18
17
|
return false;
|
|
19
18
|
try {
|
|
20
|
-
return !
|
|
19
|
+
return !fsSync.lstatSync(filePath).isFile();
|
|
21
20
|
}
|
|
22
21
|
catch {
|
|
23
22
|
return false;
|
package/docs/archive.md
CHANGED
|
@@ -43,6 +43,7 @@ type ExtractArchiveOptions = {
|
|
|
43
43
|
archivePath: string; // absolute path to the archive
|
|
44
44
|
destDir: string; // absolute destination directory; must already exist
|
|
45
45
|
timeoutMs: number; // positive wall-clock budget; <= 0/non-finite disables it
|
|
46
|
+
durable?: boolean; // false; opt into syncing published files and directories before completion
|
|
46
47
|
kind?: ArchiveKind; // "zip" | "tar" | "tar-zstd" | "tar-bzip2"
|
|
47
48
|
stripComponents?: number; // strip N leading dirs from entry paths
|
|
48
49
|
tarGzip?: boolean; // when archive is .tar.gz/.tgz
|
|
@@ -55,6 +56,36 @@ type ExtractArchiveOptions = {
|
|
|
55
56
|
};
|
|
56
57
|
```
|
|
57
58
|
|
|
59
|
+
`durable` defaults to `false`. Private staging never fsyncs, and publication copies
|
|
60
|
+
defer opted-in durability until the complete merge succeeds. With `durable: true`, the final pass syncs each
|
|
61
|
+
published file once (at most eight concurrently), then each published directory
|
|
62
|
+
once, deepest first, and finally the destination directory. All work stays inside
|
|
63
|
+
the extraction deadline; active syncs are joined before rejection. File sync
|
|
64
|
+
failures use the same error surface as `Root.copyIn()`; directory I/O failures
|
|
65
|
+
also reject, with the existing platform limitations on directory flushing.
|
|
66
|
+
Files whose final mode prevents reading, including `0o000` and write-only files,
|
|
67
|
+
sync once through the copy's retained descriptor during publication. Permissions
|
|
68
|
+
are never widened to reopen them. Directory modes are finalized after the file
|
|
69
|
+
pass, with a descriptor pinned before chmod for syncing restrictive directories.
|
|
70
|
+
Existing inaccessible directories are never widened; an existing search-only
|
|
71
|
+
directory must become readable in its final mode if no readable sync descriptor
|
|
72
|
+
can be acquired before chmod.
|
|
73
|
+
|
|
74
|
+
The default suits extractions into temporary or reconstructible locations.
|
|
75
|
+
It skips all file and directory syncs while preserving atomic file publication,
|
|
76
|
+
mode enforcement, identity checks, and containment checks. Successful `durable: true`
|
|
77
|
+
extraction syncs file contents and directory entries before returning; failures
|
|
78
|
+
can leave a partially published tree as described below.
|
|
79
|
+
|
|
80
|
+
For a crash-safe install workflow, extract with the default into a scratch
|
|
81
|
+
directory, then apply the caller's durability policy: sync the staged files and
|
|
82
|
+
directories before publishing with [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic),
|
|
83
|
+
and sync the affected parent directories afterward. The directory swap alone
|
|
84
|
+
does not sync the staged tree. Alternatively, pass `durable: true` when extracted
|
|
85
|
+
files must be on stable storage before the extraction call returns, subject to
|
|
86
|
+
the platform's flushing guarantees. A plain fsync on macOS does not flush the
|
|
87
|
+
drive cache.
|
|
88
|
+
|
|
58
89
|
`entryModes` defaults to `"clamp"`: directories become `0o755`; files become
|
|
59
90
|
`0o644`, or `0o755` when the archived owner-execute bit is set. `"preserve"`
|
|
60
91
|
keeps archived read/write/execute bits. Both policies strip setuid, setgid, and
|
|
@@ -201,11 +232,13 @@ before publication preserves a pre-existing file, and rejection does not grant
|
|
|
201
232
|
authority to delete a substituted file or alias. Failed extraction does not
|
|
202
233
|
restore overwritten contents. Active destination mutations and their guarded
|
|
203
234
|
cleanup still finish before rejection; no later destination mutation begins.
|
|
204
|
-
New directories whose
|
|
235
|
+
New directories whose finalization was never reached can retain their
|
|
205
236
|
private working mode after failure. Failure cleanup closes retained descriptors;
|
|
206
|
-
it does not run a
|
|
237
|
+
it does not run a cleanup chmod sweep or roll back the archive. The public merge
|
|
207
238
|
helper still derives modes from its external source tree and must be able to
|
|
208
239
|
read that source; it never chmods an unreadable external source to admit it.
|
|
240
|
+
That helper retains per-copy durability and immediate postorder directory-mode
|
|
241
|
+
finalization; the deferred pass described above belongs to `extractArchive()`.
|
|
209
242
|
|
|
210
243
|
### Limits
|
|
211
244
|
|
package/docs/file-store.md
CHANGED
|
@@ -28,9 +28,18 @@ const cache = fileStore({
|
|
|
28
28
|
dirMode: 0o700, // mode for parent directories created on demand (default 0o700)
|
|
29
29
|
maxBytes: 64 * 1024 * 1024, // optional: refuse writes/reads larger than this
|
|
30
30
|
private: true, // use secret-file atomic writes for private state
|
|
31
|
+
durable: true, // sync file and parent directory (default true)
|
|
31
32
|
});
|
|
32
33
|
```
|
|
33
34
|
|
|
35
|
+
| `FileStoreOptions` option | Default | Purpose |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `rootDir` | Required | Store directory. |
|
|
38
|
+
| `private` | `false` | Use the secret-file atomic path. |
|
|
39
|
+
| `mode` / `dirMode` | `0o600` / `0o700` | File and parent-directory modes. |
|
|
40
|
+
| `maxBytes` | Unset | Store read/write byte limit. |
|
|
41
|
+
| `durable` | `true` | Sync the written file and its parent directory; see method support below. |
|
|
42
|
+
|
|
34
43
|
Store and per-call `maxBytes` values must be non-negative safe integers or positive `Infinity`. Zero is an active zero-byte cap; `Infinity` disables the cap. An omitted or explicitly `undefined` per-call value preserves the store-level limit. The same rule applies to buffer writes, streams, copies, async reads, and synchronous reads/writes.
|
|
35
44
|
|
|
36
45
|
Use `private: true` for credentials, auth profiles, tokens, and other private
|
|
@@ -101,7 +110,22 @@ therefore does not imply that no filesystem access or serialization occurred.
|
|
|
101
110
|
|
|
102
111
|
## Writes
|
|
103
112
|
|
|
104
|
-
|
|
113
|
+
Writes use guarded sibling-temp publication: apply file and directory modes,
|
|
114
|
+
then rename into place. By default, the file and parent directory are synced
|
|
115
|
+
where supported by the platform and writer.
|
|
116
|
+
|
|
117
|
+
`durable: false` keeps the sibling-temp replace/rename behavior but skips the
|
|
118
|
+
temp-file and parent-directory `fsync` calls. Use it only for reconstructible
|
|
119
|
+
metadata where lower latency matters more than crash-durability. Per-call
|
|
120
|
+
`durable` overrides the store option, which defaults to `true`; an omitted or
|
|
121
|
+
`undefined` override preserves the store default. Modes, path confinement,
|
|
122
|
+
and publication identity checks are unchanged.
|
|
123
|
+
|
|
124
|
+
| Method | Durability support |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `write`, `writeText`, `writeJson` (async and sync) | Per-call option overrides store default. |
|
|
127
|
+
| `writeStream`, `copyIn` (either private mode) | Per-call option overrides store default. |
|
|
128
|
+
| JSON `write`, `update`, `updateOr` | JSON handle option overrides file-store default. |
|
|
105
129
|
|
|
106
130
|
### `write(rel, data, options?)`
|
|
107
131
|
|
|
@@ -118,7 +142,7 @@ Convenience wrappers over `write`. `writeJson` pretty-prints with a trailing new
|
|
|
118
142
|
### `json<T>(rel, options?)`
|
|
119
143
|
|
|
120
144
|
Returns a typed single-file JSON state helper for a file under this store. It
|
|
121
|
-
inherits the store's root, mode, max-size, and private-write policy, then adds
|
|
145
|
+
inherits the store's root, mode, max-size, durability, and private-write policy, then adds
|
|
122
146
|
`readOr`, `readRequired`, `update`, `updateOr`, and optional sidecar locking:
|
|
123
147
|
|
|
124
148
|
```ts
|
|
@@ -129,6 +153,9 @@ await state.updateOr(defaultState, (current) => ({ ...current, enabled: true }))
|
|
|
129
153
|
Use this when one JSON file owns one piece of state. `jsonStore({ filePath })`
|
|
130
154
|
is the absolute-path convenience wrapper for the same primitive.
|
|
131
155
|
|
|
156
|
+
Pass `{ durable: false }` or `{ durable: true }` to `json()` to override the
|
|
157
|
+
parent store's durability for all mutations of that JSON handle.
|
|
158
|
+
|
|
132
159
|
### `writeStream(rel, stream, options?)`
|
|
133
160
|
|
|
134
161
|
```ts
|
|
@@ -138,6 +165,9 @@ const path = await cache.writeStream("downloads/blob.bin", Readable.from(remoteF
|
|
|
138
165
|
|
|
139
166
|
Streams into a sibling temp with a running byte budget. Aborts the source stream with `too-large` if `maxBytes` is exceeded mid-stream — partial writes are cleaned up.
|
|
140
167
|
|
|
168
|
+
Streams honor `durable` in both private modes. Non-private streams stage their
|
|
169
|
+
input and forward the resolved durability option to Root `copyIn` for publication.
|
|
170
|
+
|
|
141
171
|
### `copyIn(rel, sourcePath, options?)`
|
|
142
172
|
|
|
143
173
|
```ts
|
|
@@ -146,12 +176,16 @@ const path = await cache.copyIn("ingest/upload.bin", "/tmp/upload.bin");
|
|
|
146
176
|
|
|
147
177
|
One-shot ingest from an absolute source path. Source is checked for symlink/non-regular before copy. Same mode rules as `write`.
|
|
148
178
|
|
|
179
|
+
`copyIn` honors per-call and store-level `durable` values in both private modes,
|
|
180
|
+
with the same precedence as `write`.
|
|
181
|
+
|
|
149
182
|
### `FileStoreWriteOptions`
|
|
150
183
|
|
|
151
184
|
Per-call overrides for the store-level defaults:
|
|
152
185
|
|
|
153
186
|
```ts
|
|
154
187
|
type FileStoreWriteOptions = {
|
|
188
|
+
durable?: boolean; // store default, otherwise true
|
|
155
189
|
dirMode?: number;
|
|
156
190
|
mode?: number;
|
|
157
191
|
maxBytes?: number;
|
|
@@ -159,6 +193,17 @@ type FileStoreWriteOptions = {
|
|
|
159
193
|
};
|
|
160
194
|
```
|
|
161
195
|
|
|
196
|
+
| `FileStoreWriteOptions` option | Default |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `durable` | Store option, otherwise `true`. |
|
|
199
|
+
| `dirMode` / `mode` | Store directory/file modes. |
|
|
200
|
+
| `maxBytes` | Store byte limit. |
|
|
201
|
+
| `tempPrefix` | Writer-specific temporary prefix. |
|
|
202
|
+
|
|
203
|
+
The same durability precedence applies to `fileStoreSync().write`,
|
|
204
|
+
`writeText`, and `writeJson`. The synchronous store has no `writeStream` or
|
|
205
|
+
`copyIn` methods.
|
|
206
|
+
|
|
162
207
|
## Reads
|
|
163
208
|
|
|
164
209
|
`open`, `read`, `readBytes`, `readText`, and `readJson` delegate to a fresh `Root` with `hardlinks: "reject"` and the store's `maxBytes`. Same return shapes as `Root`.
|
|
@@ -254,5 +299,5 @@ await root.move(`pending/${id}`, `done/${id}`);
|
|
|
254
299
|
|
|
255
300
|
- [`root()`](root.md) — the boundary `FileStore` is built on; reach for it when you need move/list/append.
|
|
256
301
|
- [JSON store](json-store.md) — the JSON-state-file equivalent of this surface.
|
|
257
|
-
- [Atomic writes](atomic.md) —
|
|
302
|
+
- [Atomic writes](atomic.md) — lower-level sibling-temp publication helpers.
|
|
258
303
|
- [Temp workspaces](temp.md) — private scratch directories backed by `FileStore`.
|
package/docs/json-store.md
CHANGED
|
@@ -45,6 +45,7 @@ type JsonStoreOptions<T> = {
|
|
|
45
45
|
filePath: string;
|
|
46
46
|
dirMode?: number; // default 0o700
|
|
47
47
|
mode?: number; // default 0o600
|
|
48
|
+
durable?: boolean; // default true
|
|
48
49
|
trailingNewline?: boolean; // default true
|
|
49
50
|
lock?: boolean | JsonStoreLockOptions; // false / undefined = no lock
|
|
50
51
|
};
|
|
@@ -71,6 +72,15 @@ type JsonStore<T> = {
|
|
|
71
72
|
`jsonStore({ filePath })` resolves `rootDir = dirname(filePath)` and calls
|
|
72
73
|
`fileStore({ rootDir, private: true }).json(basename(filePath), options)`.
|
|
73
74
|
|
|
75
|
+
`durable: false` keeps sibling-temp replace/rename behavior but skips the
|
|
76
|
+
temp-file and parent-directory `fsync` calls. Use it only for reconstructible
|
|
77
|
+
metadata where lower latency matters more than crash-durability. The default
|
|
78
|
+
is `true`, subject to platform sync support. This store-level policy applies
|
|
79
|
+
to `write`, `update`, and `updateOr`; these methods have no per-call options.
|
|
80
|
+
For `fileStore(...).json(rel, options)`, `options.durable` overrides the parent
|
|
81
|
+
file store's durability, while omission or `undefined` inherits it. Modes,
|
|
82
|
+
identity checks, mutation serialization, and sidecar locking are unchanged.
|
|
83
|
+
|
|
74
84
|
The store does **not** validate the parsed value against `T` at runtime — the cast is unchecked. Wrap with a schema (zod/valibot) if the file might be hand-edited or written by another process you don't control.
|
|
75
85
|
|
|
76
86
|
## `read()`
|
package/docs/reading.md
CHANGED
|
@@ -14,6 +14,12 @@ const opened = await fs.open("large.log"); // FileHandle for strea
|
|
|
14
14
|
|
|
15
15
|
Identity and containment checks use brief synchronous metadata calls, like Node's module resolution, while file data is read asynchronously. Canonical paths retain Node's native realpath spelling, including expansion of Windows short paths.
|
|
16
16
|
|
|
17
|
+
The same observation rule applies to archive extraction, copy, publication, move,
|
|
18
|
+
directory modes, and supporting lock, queue, and secret-file operations. Opens,
|
|
19
|
+
data transfers, durability syncs, filesystem mutations, and closes retain their
|
|
20
|
+
existing asynchronous behavior. Custom filesystem adapters retain their async
|
|
21
|
+
metadata interface.
|
|
22
|
+
|
|
17
23
|
Regardless of shape, every read goes through the same boundary checks:
|
|
18
24
|
|
|
19
25
|
1. Resolve the input lexically against the canonical real root.
|
package/docs/root.md
CHANGED
|
@@ -18,7 +18,7 @@ const fs = await root("/srv/workspace", {
|
|
|
18
18
|
function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
|
|
19
19
|
|
|
20
20
|
type RootDefaults = {
|
|
21
|
-
durable?: boolean; // fsync write/create/writeJson/createJson/append; default true
|
|
21
|
+
durable?: boolean; // fsync write/create/writeJson/createJson/append/copyIn; default true
|
|
22
22
|
hardlinks?: "reject" | "allow"; // refuse files with nlink > 1 on read; defaults to "reject"
|
|
23
23
|
denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
|
|
24
24
|
maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
|
|
@@ -111,7 +111,7 @@ fs.ensureRoot(options?) // accepts "" / "." as the root itself
|
|
|
111
111
|
|
|
112
112
|
`write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
|
|
113
113
|
|
|
114
|
-
These five methods also accept `durable?: boolean`: the per-call value overrides
|
|
114
|
+
These five methods and `copyIn` also accept `durable?: boolean`: the per-call value overrides
|
|
115
115
|
`Root.defaults.durable`, which defaults to `true` when omitted. An explicitly
|
|
116
116
|
`undefined` per-call value preserves the root default. `durable: false` keeps
|
|
117
117
|
the existing publication behavior, modes, and identity checks but skips file
|
package/docs/secret-file.md
CHANGED
|
@@ -172,9 +172,15 @@ type WriteSecretFileParams = {
|
|
|
172
172
|
content: string | Uint8Array;
|
|
173
173
|
mode?: number; // file mode for the new file (default PRIVATE_SECRET_FILE_MODE = 0o600)
|
|
174
174
|
dirMode?: number; // mode for the root and intermediate dirs (default PRIVATE_SECRET_DIR_MODE = 0o700)
|
|
175
|
+
durable?: boolean; // default true; false skips file and parent fsync
|
|
175
176
|
};
|
|
176
177
|
```
|
|
177
178
|
|
|
179
|
+
`durable: false` preserves atomic publication, modes, and identity checks while
|
|
180
|
+
skipping file and parent-directory `fsync` calls. Use it only for reconstructible
|
|
181
|
+
data where lower latency matters more than crash-durability; private and JSON
|
|
182
|
+
stores forward their durability policy here.
|
|
183
|
+
|
|
178
184
|
The full POSIX directory mode is asserted on each component along the path: `rootDir`, then any intermediate dirs, then the parent. Existing directories, including another creator's `EEXIST` winner, must already match `dirMode` exactly or the write fails with `insecure-permissions`; they are never chmod-repaired. An explicitly requested directory mode such as `0o2750` preserves its setgid bit. Audit and adjust existing secret directories yourself. The admitted directory guards are retained through traversal and the final writer/lock handoff; a fresh pathname lookup cannot silently authorize a replacement. The caller must still trust the selected root and its owners; matching permission bits alone do not establish that trust.
|
|
179
185
|
|
|
180
186
|
Directory admission and its retained guards use lossless bigint identities, including through private locks and native writes. On Windows, an unknown zero device or inode gets one reinspection that retains known components; a definite mismatch or persistent ambiguity fails with `path-mismatch` rather than authorizing a replacement.
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -25,6 +25,8 @@ On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit"
|
|
|
25
25
|
|
|
26
26
|
Always release locks in a `finally` block. Application-managed graceful shutdown can await `release()` or `manager.drain()` before terminating. Explicit `process.exit()`, uncaught failures, crashes, default signal handling, and fatal termination (including `SIGKILL`) do not reliably run asynchronous Root cleanup and may leave sidecars. Recover only after an application-owned liveness policy proves the holder cannot still be writing.
|
|
27
27
|
|
|
28
|
+
Exit cleanup tolerates shared managers created by older package copies that lack reclaim-guard state, and continues through later manager domains. Handler ownership remains first-registration-wins: loading an updated copy does not replace an older copy's registered handler. Restart with the updated copy registering first to obtain the fix. After building, `node scripts/legacy-lock-exit-proof.mjs` checks clean exit, removal of legacy and modern raw locks, and preservation of a retained lock using synthetic temporary files.
|
|
29
|
+
|
|
28
30
|
Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check.
|
|
29
31
|
|
|
30
32
|
The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
|
package/docs/writing.md
CHANGED
|
@@ -91,14 +91,14 @@ await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });
|
|
|
91
91
|
| `overwrite` | `boolean` | `true`; `false` is create-only. |
|
|
92
92
|
| `renameIdentity` | `RenameIdentityPolicy` | `"strict"`. |
|
|
93
93
|
|
|
94
|
-
`write`, `create`, `writeJson`, `createJson`, and `
|
|
94
|
+
`write`, `create`, `writeJson`, `createJson`, `append`, and `copyIn` accept `durable`.
|
|
95
95
|
Precedence is per-call option, then `Root.defaults.durable`, then `true`;
|
|
96
96
|
an explicitly `undefined` call option preserves the root default.
|
|
97
97
|
`durable: false` keeps the sibling-temp replace/rename behavior of replacement
|
|
98
98
|
writes but skips file and parent-directory fsync calls. Create-only and append
|
|
99
99
|
publication behavior, permissions, identity checks, and error codes are unchanged.
|
|
100
100
|
Use it only for reconstructible data: a crash may lose the write or leave the
|
|
101
|
-
previous file. `
|
|
101
|
+
previous file. `move` and streaming `openWritable` do not use this option.
|
|
102
102
|
The existing pure-JavaScript Windows writer performs no fsync calls in either
|
|
103
103
|
setting; native Windows writes honor the option. Directory sync remains best-effort.
|
|
104
104
|
|
|
@@ -185,7 +185,9 @@ await fs.copyIn("inbox/upload.bin", "/tmp/incoming.bin", {
|
|
|
185
185
|
});
|
|
186
186
|
```
|
|
187
187
|
|
|
188
|
-
Options are `{ denyMutations?, maxBytes?, mkdir?, mode?, sourceHardlinks? }`.
|
|
188
|
+
Options are `{ denyMutations?, durable?, maxBytes?, mkdir?, mode?, sourceHardlinks? }`.
|
|
189
|
+
`durable` follows the root default and is `true` when omitted at both levels;
|
|
190
|
+
set it to `false` to skip file and parent-directory syncs for reconstructible data.
|
|
189
191
|
Use `sourceHardlinks: "reject"` to refuse if the source itself is a hardlinked
|
|
190
192
|
alias. There is no encoding option: copying preserves source bytes.
|
|
191
193
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"filesystem",
|
|
@@ -156,19 +156,19 @@
|
|
|
156
156
|
"archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
|
|
157
157
|
},
|
|
158
158
|
"optionalDependencies": {
|
|
159
|
-
"@openclaw/fs-safe-darwin-arm64": "0.
|
|
160
|
-
"@openclaw/fs-safe-darwin-x64": "0.
|
|
161
|
-
"@openclaw/fs-safe-linux-arm64-gnu": "0.
|
|
162
|
-
"@openclaw/fs-safe-linux-arm64-musl": "0.
|
|
163
|
-
"@openclaw/fs-safe-linux-x64-gnu": "0.
|
|
164
|
-
"@openclaw/fs-safe-linux-x64-musl": "0.
|
|
165
|
-
"@openclaw/fs-safe-win32-x64-msvc": "0.
|
|
166
|
-
"jszip": "^3.10.
|
|
159
|
+
"@openclaw/fs-safe-darwin-arm64": "0.9.0",
|
|
160
|
+
"@openclaw/fs-safe-darwin-x64": "0.9.0",
|
|
161
|
+
"@openclaw/fs-safe-linux-arm64-gnu": "0.9.0",
|
|
162
|
+
"@openclaw/fs-safe-linux-arm64-musl": "0.9.0",
|
|
163
|
+
"@openclaw/fs-safe-linux-x64-gnu": "0.9.0",
|
|
164
|
+
"@openclaw/fs-safe-linux-x64-musl": "0.9.0",
|
|
165
|
+
"@openclaw/fs-safe-win32-x64-msvc": "0.9.0",
|
|
166
|
+
"jszip": "^3.10.2"
|
|
167
167
|
},
|
|
168
168
|
"devDependencies": {
|
|
169
|
-
"@emnapi/runtime": "2.0.0-alpha.
|
|
169
|
+
"@emnapi/runtime": "2.0.0-alpha.5",
|
|
170
170
|
"@napi-rs/cli": "3.9.0",
|
|
171
|
-
"@types/node": "^26.
|
|
171
|
+
"@types/node": "^26.5.1",
|
|
172
172
|
"@vitest/coverage-v8": "5.0.0",
|
|
173
173
|
"fast-check": "^4.9.0",
|
|
174
174
|
"istanbul-lib-coverage": "3.2.2",
|