@openclaw/fs-safe 0.19.0 → 0.21.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 +50 -0
- package/README.md +24 -6
- package/dist/advanced.d.ts +4 -0
- package/dist/advanced.js +2 -0
- package/dist/archive-plan.d.ts +2 -7
- package/dist/archive-read.js +9 -18
- package/dist/archive-zip-entry.d.ts +11 -11
- package/dist/archive-zip-entry.js +3 -35
- package/dist/archive-zip-integrity.d.ts +2 -2
- package/dist/archive-zip-integrity.js +2 -12
- package/dist/archive-zip-loader.d.ts +7 -3
- package/dist/archive-zip-loader.js +10 -9
- package/dist/archive-zip-preflight.d.ts +2 -1
- package/dist/archive-zip-preflight.js +16 -7
- package/dist/archive.js +17 -16
- package/dist/atomic.d.ts +1 -1
- package/dist/directory-receipt.js +5 -7
- package/dist/effective-uid.js +1 -4
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +3 -2
- package/dist/file-lock-sync-root-held.js +1 -4
- package/dist/file-store.d.ts +4 -7
- package/dist/json-document-store.d.ts +4 -9
- package/dist/local-file-access.js +2 -5
- package/dist/move-path-cleanup.js +4 -4
- package/dist/native-binding.d.ts +28 -1
- package/dist/native-staged-symlink.d.ts +13 -0
- package/dist/native-staged-symlink.js +303 -0
- package/dist/owner-dacl-batch-worker.d.ts +1 -0
- package/dist/owner-dacl-batch-worker.js +54 -0
- package/dist/owner-dacl-batch.d.ts +5 -0
- package/dist/owner-dacl-batch.js +64 -0
- package/dist/owner-dacl.d.ts +2 -0
- package/dist/owner-dacl.js +3 -0
- package/dist/path.js +17 -1
- package/dist/permission-exec.js +3 -6
- package/dist/permissions-public.d.ts +1 -0
- package/dist/permissions-public.js +1 -0
- package/dist/pinned-mutation-admission.d.ts +0 -1
- package/dist/pinned-open.d.ts +0 -1
- package/dist/pinned-open.js +1 -2
- package/dist/publish-copy-stage.js +4 -0
- package/dist/read-opened-file.d.ts +2 -5
- package/dist/regular-file.js +3 -3
- package/dist/replace-file-buffer.d.ts +4 -0
- package/dist/replace-file-buffer.js +36 -0
- package/dist/replace-file-copy-fallback.d.ts +3 -2
- package/dist/replace-file-copy-fallback.js +66 -38
- package/dist/replace-file-descriptor.d.ts +4 -0
- package/dist/replace-file-descriptor.js +9 -1
- package/dist/replace-file-destination.d.ts +17 -0
- package/dist/replace-file-destination.js +61 -0
- package/dist/replace-file-mutation.d.ts +26 -0
- package/dist/replace-file-mutation.js +47 -0
- package/dist/replace-file-temp-owner.d.ts +2 -2
- package/dist/replace-file-temp-owner.js +16 -4
- package/dist/replace-file-types.d.ts +55 -0
- package/dist/replace-file-types.js +1 -0
- package/dist/replace-file.d.ts +3 -55
- package/dist/replace-file.js +29 -10
- package/dist/retained-file-types.d.ts +61 -0
- package/dist/retained-file-types.js +1 -0
- package/dist/retained-file.d.ts +3 -0
- package/dist/retained-file.js +121 -0
- package/dist/root-directory-entry.d.ts +9 -0
- package/dist/root-directory-entry.js +28 -0
- package/dist/root-directory-list.d.ts +7 -1
- package/dist/root-directory-list.js +48 -23
- package/dist/root-handle-context.d.ts +4 -0
- package/dist/root-handle-context.js +12 -0
- package/dist/root-impl.d.ts +3 -3
- package/dist/root-impl.js +8 -5
- package/dist/root-observed-path.d.ts +0 -1
- package/dist/root-observed-path.js +0 -3
- package/dist/root-path-observation.d.ts +4 -11
- package/dist/root-path.js +7 -10
- package/dist/root-walk.d.ts +19 -12
- package/dist/root-walk.js +49 -18
- package/dist/root-write-admission.js +0 -2
- package/dist/safe-path-segment.d.ts +1 -0
- package/dist/safe-path-segment.js +8 -2
- package/dist/secure-file.js +3 -2
- package/dist/sidecar-lock.js +5 -3
- package/dist/staged-symlink-types.d.ts +49 -0
- package/dist/staged-symlink-types.js +1 -0
- package/dist/symlink-parents.js +58 -7
- package/dist/temp-target.js +5 -2
- package/dist/temp-workspace-admission.js +22 -21
- package/dist/temp-workspace-child-admission.d.ts +1 -1
- package/dist/temp-workspace-child-admission.js +14 -9
- package/dist/temp-workspace-owner.js +4 -9
- package/dist/temp-workspace-ownership.d.ts +8 -0
- package/dist/temp-workspace-ownership.js +52 -0
- package/dist/test-hooks.d.ts +3 -0
- package/dist/text-atomic.d.ts +2 -1
- package/dist/text-atomic.js +2 -0
- package/dist/trash.js +27 -1
- package/dist/walk.d.ts +2 -5
- package/dist/watch-alias.d.ts +6 -0
- package/dist/watch-alias.js +80 -0
- package/dist/watch-hints.d.ts +8 -0
- package/dist/watch-hints.js +77 -0
- package/dist/watch-native.d.ts +32 -0
- package/dist/watch-native.js +56 -0
- package/dist/watch-scan.d.ts +24 -0
- package/dist/watch-scan.js +269 -0
- package/dist/watch-types.d.ts +58 -0
- package/dist/watch-types.js +1 -0
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +502 -0
- package/dist/windows-owner.d.ts +0 -1
- package/dist/windows-owner.js +0 -1
- package/dist/windows-security-bridge.cs +6 -4
- package/dist/windows-security-bridge.ps1 +78 -3
- package/dist/windows-security-command.d.ts +8 -0
- package/dist/windows-security-command.js +66 -12
- package/dist/windows-security-facts.d.ts +3 -0
- package/dist/windows-security-facts.js +4 -0
- package/docs/advanced.md +4 -2
- package/docs/archive.md +8 -0
- package/docs/atomic.md +72 -3
- package/docs/contributing.md +35 -0
- package/docs/durability.md +7 -0
- package/docs/index.md +1 -0
- package/docs/install.md +28 -0
- package/docs/native-helper.md +14 -4
- package/docs/native.md +45 -1
- package/docs/permissions.md +66 -0
- package/docs/public-api.md +7 -1
- package/docs/retained-file.md +113 -0
- package/docs/root.md +6 -1
- package/docs/security-model.md +4 -1
- package/docs/sidecar-lock.md +2 -0
- package/docs/staged-symlink.md +123 -0
- package/docs/store.md +3 -1
- package/docs/temp.md +24 -4
- package/docs/testing.md +88 -0
- package/docs/types.md +6 -0
- package/docs/walk.md +22 -1
- package/docs/watch.md +184 -0
- package/docs/writing.md +10 -0
- package/package.json +13 -9
|
@@ -3,10 +3,11 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { FsSafeError } from "./errors.js";
|
|
4
4
|
import { DEFAULT_PERMISSION_EXEC_TIMEOUT_MS, PermissionCommandError } from "./permission-exec.js";
|
|
5
5
|
import { resolveWindowsSystemCommand } from "./windows-command.js";
|
|
6
|
-
import { parseWindowsSecurityCommandFacts, unverified } from "./windows-security-facts.js";
|
|
6
|
+
import { parseWindowsOwnerAndDaclFacts, parseWindowsSecurityCommandFacts, unverified } from "./windows-security-facts.js";
|
|
7
7
|
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
8
8
|
const TERMINATION_GRACE_MS = 1_000;
|
|
9
9
|
const FULL_IDENTITY = /^[0-9a-f]{16}:[0-9a-f]{32}$/;
|
|
10
|
+
export const WINDOWS_SECURITY_BATCH_MAX_BYTES = 16 * 1024 * 1024;
|
|
10
11
|
function isFullIdentity(identity) {
|
|
11
12
|
return typeof identity === "string" && identity.length === 49 && FULL_IDENTITY.test(identity);
|
|
12
13
|
}
|
|
@@ -76,6 +77,10 @@ function parseReplyValue(stdout, operation) {
|
|
|
76
77
|
unverified("Windows security command returned an incomplete response");
|
|
77
78
|
}
|
|
78
79
|
if (!response.ok) {
|
|
80
|
+
if (operation === "paths" &&
|
|
81
|
+
(response.code === "helper-unavailable" || response.code === "too-large") && typeof response.message === "string") {
|
|
82
|
+
throw new FsSafeError(response.code, response.message);
|
|
83
|
+
}
|
|
79
84
|
const codes = new Set(["EACCES", "EPERM", "EEXIST", "ENOENT", "ENOTSUP", "EIO", "EBADF", "ELOOP", "ENOTDIR", "EINVAL", "ENOSPC", "EBUSY"]);
|
|
80
85
|
if (typeof response.code !== "string" || !codes.has(response.code) || typeof response.message !== "string") {
|
|
81
86
|
unverified("Windows security command returned an invalid failure");
|
|
@@ -112,13 +117,13 @@ class WindowsSecurityCommandError extends PermissionCommandError {
|
|
|
112
117
|
timedOut;
|
|
113
118
|
creationOutcome;
|
|
114
119
|
processExitConfirmed;
|
|
115
|
-
constructor(file, durationMs, receipt, timedOut) {
|
|
116
|
-
super(file, durationMs, receipt);
|
|
120
|
+
constructor(file, durationMs, receipt, timedOut, timeoutMs = DEFAULT_PERMISSION_EXEC_TIMEOUT_MS) {
|
|
121
|
+
super(file, durationMs, receipt, timeoutMs);
|
|
117
122
|
this.timedOut = timedOut;
|
|
118
123
|
this.creationOutcome = receipt.creationOutcome;
|
|
119
124
|
this.processExitConfirmed = receipt.processExitConfirmed;
|
|
120
125
|
if (timedOut)
|
|
121
|
-
this.message = `Windows permission inspection timed out after ${
|
|
126
|
+
this.message = `Windows permission inspection timed out after ${timeoutMs}ms`;
|
|
122
127
|
if (!receipt.processExitConfirmed)
|
|
123
128
|
this.message += "; process exit was not confirmed";
|
|
124
129
|
else if (!receipt.outputClosed)
|
|
@@ -156,11 +161,12 @@ export function hasUnsettledWindowsSecurityCommand(error) {
|
|
|
156
161
|
return pending.length > 0;
|
|
157
162
|
}
|
|
158
163
|
function ignoreLateError() { }
|
|
159
|
-
async function execute(operation, params) {
|
|
160
|
-
const
|
|
164
|
+
async function execute(operation, params, request) {
|
|
165
|
+
const selected = request ?? command(operation, params);
|
|
166
|
+
const { file, args, env, input, timeoutMs = DEFAULT_PERMISSION_EXEC_TIMEOUT_MS, maxOutputBytes = MAX_OUTPUT_BYTES } = selected;
|
|
161
167
|
const startedAt = performance.now();
|
|
162
168
|
return await new Promise((resolve, reject) => {
|
|
163
|
-
const child = spawn(file, args, { windowsHide: true, env, stdio: [params.fd ?? "ignore", "pipe", "pipe"] });
|
|
169
|
+
const child = spawn(file, args, { windowsHide: true, env, stdio: [input === undefined ? params.fd ?? "ignore" : "pipe", "pipe", "pipe"] });
|
|
164
170
|
const output = [];
|
|
165
171
|
const errors = [];
|
|
166
172
|
let bytes = 0;
|
|
@@ -180,7 +186,7 @@ async function execute(operation, params) {
|
|
|
180
186
|
const timeout = setTimeout(() => {
|
|
181
187
|
timedOut = true;
|
|
182
188
|
fail(deadlineError());
|
|
183
|
-
},
|
|
189
|
+
}, timeoutMs);
|
|
184
190
|
const finish = (outputClosed) => {
|
|
185
191
|
if (settled)
|
|
186
192
|
return;
|
|
@@ -193,6 +199,18 @@ async function execute(operation, params) {
|
|
|
193
199
|
child.removeListener("error", onChildError);
|
|
194
200
|
child.on("error", ignoreLateError);
|
|
195
201
|
const cleanupErrors = [];
|
|
202
|
+
if (input !== undefined && child.stdin) {
|
|
203
|
+
child.stdin.removeListener("error", fail);
|
|
204
|
+
child.stdin.on("error", ignoreLateError);
|
|
205
|
+
if (!outputClosed) {
|
|
206
|
+
try {
|
|
207
|
+
child.stdin.destroy();
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
cleanupErrors.push(error);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
196
214
|
for (const [stream, collect] of [[child.stdout, collectOutput], [child.stderr, collectErrors]]) {
|
|
197
215
|
if (!stream)
|
|
198
216
|
continue;
|
|
@@ -221,7 +239,7 @@ async function execute(operation, params) {
|
|
|
221
239
|
cause: failure, pid: child.pid ?? null, code: exitCode, signal: exitSignal, stderr: Buffer.concat(errors),
|
|
222
240
|
processExitConfirmed, outputClosed, terminationSignalSent, terminationError, cleanupErrors,
|
|
223
241
|
...(operation === "create" ? { creationOutcome: "unconfirmed" } : {}),
|
|
224
|
-
}, timedOut));
|
|
242
|
+
}, timedOut, timeoutMs));
|
|
225
243
|
}
|
|
226
244
|
else {
|
|
227
245
|
try {
|
|
@@ -260,7 +278,7 @@ async function execute(operation, params) {
|
|
|
260
278
|
if (failed || settled)
|
|
261
279
|
return;
|
|
262
280
|
bytes += chunk.length;
|
|
263
|
-
if (bytes <=
|
|
281
|
+
if (bytes <= maxOutputBytes)
|
|
264
282
|
chunks.push(chunk);
|
|
265
283
|
else
|
|
266
284
|
fail(new Error("Windows security command exceeded its output budget"));
|
|
@@ -282,7 +300,7 @@ async function execute(operation, params) {
|
|
|
282
300
|
};
|
|
283
301
|
const onClose = (code, signal) => {
|
|
284
302
|
onExit(code, signal);
|
|
285
|
-
if (!failed && performance.now() - startedAt >=
|
|
303
|
+
if (!failed && performance.now() - startedAt >= timeoutMs) {
|
|
286
304
|
timedOut = true;
|
|
287
305
|
failed = true;
|
|
288
306
|
failure = deadlineError();
|
|
@@ -298,11 +316,47 @@ async function execute(operation, params) {
|
|
|
298
316
|
child.stderr?.on("error", fail);
|
|
299
317
|
child.stdout?.on("data", collectOutput);
|
|
300
318
|
child.stderr?.on("data", collectErrors);
|
|
301
|
-
if (!child.stdout || !child.stderr) {
|
|
319
|
+
if (!child.stdout || !child.stderr || (input !== undefined && !child.stdin)) {
|
|
302
320
|
// Node reports spawn errors on nextTick, which can follow the current microtask.
|
|
303
321
|
missingPipes = setImmediate(() => { if (!failed && !settled)
|
|
304
322
|
fail(new Error("Windows security command output pipes are unavailable")); });
|
|
305
323
|
}
|
|
324
|
+
if (input !== undefined && child.stdin) {
|
|
325
|
+
child.stdin.on("error", fail);
|
|
326
|
+
try {
|
|
327
|
+
child.stdin.end(input, "utf8");
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
fail(error);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
export async function readWindowsSecurityFactsBatch(paths, options) {
|
|
336
|
+
const invocation = command("paths", {});
|
|
337
|
+
const request = {
|
|
338
|
+
...invocation,
|
|
339
|
+
input: JSON.stringify(paths),
|
|
340
|
+
timeoutMs: options.timeoutMs,
|
|
341
|
+
maxOutputBytes: WINDOWS_SECURITY_BATCH_MAX_BYTES,
|
|
342
|
+
};
|
|
343
|
+
if (options.native) {
|
|
344
|
+
request.file = process.execPath;
|
|
345
|
+
request.args = ["--", fileURLToPath(new URL("./owner-dacl-batch-worker.js", import.meta.url))];
|
|
346
|
+
request.env = {
|
|
347
|
+
...Object.fromEntries(Object.entries(invocation.env).filter(([key]) => !["NODE_OPTIONS", "NODE_PATH", "FS_SAFE_OWNER_DACL_BATCH_MODE"].includes(key.toUpperCase()))),
|
|
348
|
+
FS_SAFE_OWNER_DACL_BATCH_MODE: options.mode,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const response = await execute("paths", {}, request);
|
|
352
|
+
if (!Array.isArray(response) || response.length !== paths.length) {
|
|
353
|
+
unverified("Windows security command returned an incomplete path batch");
|
|
354
|
+
}
|
|
355
|
+
return response.map((row, index) => {
|
|
356
|
+
if (!record(row) || row.path !== paths[index]) {
|
|
357
|
+
unverified("Windows security command returned a mismatched path batch");
|
|
358
|
+
}
|
|
359
|
+
return parseWindowsOwnerAndDaclFacts(row.security);
|
|
306
360
|
});
|
|
307
361
|
}
|
|
308
362
|
export function readWindowsSecurityFactsCommand(targetPath) {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { NativeWindowsSecurityFacts } from "./native-binding.js";
|
|
2
|
+
export type DescriptorFacts = Pick<NativeWindowsSecurityFacts, "ownerSid" | "currentUserSid" | "daclPresent" | "isLocal" | "aceListComplete" | "unsupportedAceTypes" | "aces">;
|
|
2
3
|
export declare function unverified(message: string, cause?: unknown): never;
|
|
4
|
+
/** Validate policy-free observations crossing the isolated batch transport. */
|
|
5
|
+
export declare function parseWindowsOwnerAndDaclFacts(value: unknown): DescriptorFacts;
|
|
3
6
|
/** Raw reporting retains unknown flag bits; secure admission validates them below. */
|
|
4
7
|
export declare function parseWindowsSecurityCommandFacts(value: unknown): NativeWindowsSecurityFacts;
|
|
5
8
|
/** Native and command observations share the same fail-closed admission policy. */
|
|
@@ -46,6 +46,10 @@ function validateDescriptor(value, allowUnknownFlags) {
|
|
|
46
46
|
}
|
|
47
47
|
return value;
|
|
48
48
|
}
|
|
49
|
+
/** Validate policy-free observations crossing the isolated batch transport. */
|
|
50
|
+
export function parseWindowsOwnerAndDaclFacts(value) {
|
|
51
|
+
return validateDescriptor(value, true);
|
|
52
|
+
}
|
|
49
53
|
function summarize(facts) {
|
|
50
54
|
const ownerClass = facts.ownerSid === facts.currentUserSid ? "current-user"
|
|
51
55
|
: facts.ownerSid === SYSTEM_SID ? "system"
|
package/docs/advanced.md
CHANGED
|
@@ -77,10 +77,11 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
|
|
|
77
77
|
| `overwriteFileHandle`, `OverwriteFileHandleOptions` | [in-place-write.md](in-place-write.md) | Overwrite a borrowed read/write handle with prefix-only preparation and best-effort rollback; preserves its inode, cursor, and caller-owned lifetime. |
|
|
78
78
|
| `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level root-bounded open; rejects every symlink component by default, with `symlinks: "follow-parents-within-root"` for contained parent aliases or `"follow-within-root"` for final links too. |
|
|
79
79
|
| `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
|
|
80
|
+
| `retainFileInDirectory`, `RetainedFile`, related receipt/result types | [Retained Windows files](retained-file.md) | Existing-file native handle custody and explicit removal; local NTFS, producer authority required, no persistence guarantee. |
|
|
80
81
|
| `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
|
|
81
82
|
| `readDirectoryIdentity`, `assertDirectoryIdentitySync`, `DirectoryIdentity` | [directory-identity.md](directory-identity.md) | Observe exact bigint directory identity and synchronously check a caller-selected path, optionally retaining its canonical path. |
|
|
82
83
|
| `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
|
|
83
|
-
| `assertNoSymlinkParents`, `assertNoSymlinkParentsSync`, `AssertNoSymlinkParentsOptions` | – | Reject paths whose ancestor chain contains symlinks. |
|
|
84
|
+
| `assertNoSymlinkParents`, `assertNoSymlinkParentsSync`, `AssertNoSymlinkParentsOptions` | – | Reject paths whose ancestor chain contains symlinks, inspecting raw segments before `..` normalization. A `..` may undo an inspected real directory, but cannot leave the root or undo an allowed root-child symlink. Raw paths outside the root that normalize inside are rejected. |
|
|
84
85
|
| `assertNoHardlinkedFinalPath`, `assertNoPathAliasEscape`, `PATH_ALIAS_POLICIES`, `PathAliasPolicy` | – | Hardlink/alias defense building blocks. |
|
|
85
86
|
|
|
86
87
|
`pathExists()` and `pathExistsSync()` intentionally retain ordinary `stat`
|
|
@@ -240,6 +241,7 @@ atomic replacement, use [`Root.write()`](writing.md).
|
|
|
240
241
|
| Export | Page | Notes |
|
|
241
242
|
|---|---|---|
|
|
242
243
|
| `stageFileInDirectory`, `StagedFile`, `StagedFileReceipt`, `PublishedFileReceipt`, `StagedFilePublication`, `StagedFileCleanupReceipt`, `StagedFileFailureDetails` | [staged-file.md](staged-file.md) | Native-required Linux/macOS lifecycle retaining the original directory for abort cleanup. |
|
|
244
|
+
| `retainSymlinkInDirectory`, `StagedSymlink`, `StagedSymlinkExpected`, `StagedSymlinkReceipt`, `PublishedSymlinkReceipt`, `StagedSymlinkPublication`, `StagedSymlinkRemoval`, `StagedSymlinkCleanupReceipt`, `StagedSymlinkFailureDetails` | [staged-symlink.md](staged-symlink.md) | Native-required retained symlink identity, no-replace publication and explicit recovery; never same-target ownership adoption. |
|
|
243
245
|
| `tempFile`, `withTempFile`, `TempFile`, `buildRandomTempFilePath`, `sanitizeTempFileName` | [temp.md](temp.md) | One-file temp primitive; prefer `tempWorkspace` from `@openclaw/fs-safe/temp` for the stable surface. |
|
|
244
246
|
| `writeSiblingTempFile`, `writeViaSiblingTempPath`, `WriteSiblingTempFileOptions`, `WriteSiblingTempFileResult` | – | Callback-produced file staging: verified sibling publication or private-workspace copy through a root. |
|
|
245
247
|
|
|
@@ -256,7 +258,7 @@ atomic replacement, use [`Root.write()`](writing.md).
|
|
|
256
258
|
|---|---|---|
|
|
257
259
|
| `createAsyncLock` | – | In-process async lock (separate from cross-process file locks). |
|
|
258
260
|
| `withTimeout` | [timing.md](timing.md) | Wrap a promise with a timeout that raises `Error` by default, or an error supplied by `createError`. |
|
|
259
|
-
| `movePathToTrash`, `MovePathToTrashOptions` | – | Best-effort move to the platform trash. |
|
|
261
|
+
| `movePathToTrash`, `MovePathToTrashOptions` | – | Best-effort move to the platform trash. Allowed roots constrain the real parent of the moved entry, including symlinks; the referent is not moved. Parent identity is rechecked before mutation. |
|
|
260
262
|
|
|
261
263
|
## Stability
|
|
262
264
|
|
package/docs/archive.md
CHANGED
|
@@ -211,6 +211,14 @@ loading, including failure. Public preflight still returns ordinary JSZip entry
|
|
|
211
211
|
objects, with directory keys ending in `/` and recognizable symlink type bits.
|
|
212
212
|
Compressed data is retained even for declared-zero entries, so an empty-size
|
|
213
213
|
claim cannot bypass payload-size or CRC verification during extraction or reads.
|
|
214
|
+
Extraction and bounded reads retain the admitted CRC and size independently of
|
|
215
|
+
mutable decoder objects, so later decoder changes cannot redefine the expected
|
|
216
|
+
payload integrity. Public preflight archives retain ordinary JSZip mutation
|
|
217
|
+
and entry-reading behavior.
|
|
218
|
+
Portable bounded reads select the admitted canonical name and reject a selected
|
|
219
|
+
decoder entry that no longer belongs to that archive and name before reading its
|
|
220
|
+
payload. Extraction retains the admitted names and physical order independently
|
|
221
|
+
of later changes to the decoder's public `files` object.
|
|
214
222
|
|
|
215
223
|
Within one ZIP entry, identical local and central name bytes reuse the same
|
|
216
224
|
decoded validation. Unicode Path admission is shared only when both the raw names
|
package/docs/atomic.md
CHANGED
|
@@ -60,6 +60,8 @@ type ReplaceFileAtomicOptions = {
|
|
|
60
60
|
syncTempFile?: boolean; // fsync(temp) before rename, or the final file after copy fallback; default false
|
|
61
61
|
syncParentDir?: boolean; // fsync(parent) after rename, POSIX only; default false
|
|
62
62
|
throwOnCleanupError?: boolean; // report temp cleanup failure; default false
|
|
63
|
+
assertBeforeMutation?: () => void;
|
|
64
|
+
onDestinationState?: (state: ReplaceFileAtomicDestinationState) => void;
|
|
63
65
|
beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
|
|
64
66
|
fileSystem?: ReplaceFileAtomicFileSystem; // injectable fs for tests
|
|
65
67
|
};
|
|
@@ -108,6 +110,65 @@ descriptor is still closed, and a close failure remains reportable.
|
|
|
108
110
|
|
|
109
111
|
Identity checks and pathname rename/unlink remain separate syscalls, not atomic conditional mutations. Use an approved writable parent plus cooperative locking or OS isolation when arbitrary concurrent namespace mutation is in scope.
|
|
110
112
|
|
|
113
|
+
### Atomic write authority and destination state
|
|
114
|
+
|
|
115
|
+
Use `assertBeforeMutation` when a write depends on a lease or other revocable
|
|
116
|
+
application authority. Both atomic variants capture the callback at entry and
|
|
117
|
+
run it synchronously before directory creation or mode changes, compatibility
|
|
118
|
+
lock acquisition, staging creation and writes, each rename attempt, and fallback
|
|
119
|
+
removal, destination acquisition, truncation, and each content write. Asynchronous destination identity checks
|
|
120
|
+
finish before the final authority check; no await separates that check from the
|
|
121
|
+
write dispatch. A dispatched operation still owns its completion.
|
|
122
|
+
|
|
123
|
+
`onDestinationState` captures facts that a caller may need if the operation later
|
|
124
|
+
rejects:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
type ReplaceFileAtomicDestinationState =
|
|
128
|
+
| Readonly<{ state: "removed"; path: string }>
|
|
129
|
+
| Readonly<{
|
|
130
|
+
state: "writing" | "published";
|
|
131
|
+
path: string;
|
|
132
|
+
dev: bigint;
|
|
133
|
+
ino: bigint;
|
|
134
|
+
}>;
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- `removed` follows successful removal of an existing fallback destination.
|
|
138
|
+
- `writing` records the fallback's retained descriptor after exclusive creation
|
|
139
|
+
or the first successful truncation of an existing `restore-original` target.
|
|
140
|
+
Opening an existing target leaves it untouched and emits no receipt. This state
|
|
141
|
+
does not claim that all requested bytes were written.
|
|
142
|
+
- `published` follows successful rename and destination identity verification,
|
|
143
|
+
before parent synchronization or descriptor close can fail. Copy fallback
|
|
144
|
+
reports it after its content, mode, and requested file synchronization complete.
|
|
145
|
+
The explicit `verify-content-with-lock` policy first applies its existing
|
|
146
|
+
locked content verification to admit the replacement descriptor.
|
|
147
|
+
|
|
148
|
+
Receipts are frozen and use the destination spelling captured by the operation.
|
|
149
|
+
File identities come from retained descriptors, never from reopening a pathname
|
|
150
|
+
after a failed copy. Failed or indeterminate admission can leave no identity
|
|
151
|
+
receipt; a rename followed by replacement before verification also has no
|
|
152
|
+
publication receipt. Do not infer ownership from a fresh post-failure stat. A later writer
|
|
153
|
+
can replace the pathname, so compare the recorded identity with the current
|
|
154
|
+
entry and recheck application authority before compensation. Receipts do not
|
|
155
|
+
promise durable storage or authorize rollback.
|
|
156
|
+
|
|
157
|
+
Both callbacks must complete synchronously; Promise and thenable results are
|
|
158
|
+
rejected, and ordinary return values are ignored. The first callback refusal
|
|
159
|
+
is terminal, including falsy thrown values; an `EPERM`, `EEXIST`, or `EBUSY` code
|
|
160
|
+
from a callback never starts fallback or retry. Refusal during an in-place
|
|
161
|
+
fallback also stops new restoration writes. Final mode, synchronization, close,
|
|
162
|
+
and private-stage cleanup may still settle against owned identities after
|
|
163
|
+
revocation. Callback refusal does not delete a published destination or a
|
|
164
|
+
competing file at a staging name. An observer that throws must retain its receipt
|
|
165
|
+
first if recovery needs it.
|
|
166
|
+
|
|
167
|
+
`beforeRename` remains the hook for preparing backups. Effects performed inside
|
|
168
|
+
that hook remain the caller's responsibility; the authority option guards the
|
|
169
|
+
atomic writer's own effects. Omitting both new callbacks preserves existing
|
|
170
|
+
write, fallback, restoration, result, and cleanup behavior.
|
|
171
|
+
|
|
111
172
|
### FUSE, Windows exFAT/FAT32, and unstable rename identity
|
|
112
173
|
|
|
113
174
|
Strict source-to-destination identity is the default. Some FUSE mounts assign a different inode to the destination during rename even without concurrency. Set `renameIdentity: "verify-content-with-lock"` to accept that boundary only when the re-opened no-follow destination has the exact requested SHA-256 content under an exclusive hashed sidecar lock in the destination parent. The newly accepted descriptor and identity remain pinned through parent sync and final verification. The synchronous helper provides the same policy with the synchronous lock implementation.
|
|
@@ -295,9 +356,9 @@ semantics. For single-file replacement, `replaceFileAtomic` is the right tool.
|
|
|
295
356
|
|
|
296
357
|
Atomic UTF-8 text write with the same secure defaults as `writeJson`: sibling
|
|
297
358
|
temp file, descriptor-bound mode setting and fsync, rename, and parent fsync.
|
|
298
|
-
It delegates to `replaceFileAtomic()` with a smaller call shape
|
|
299
|
-
|
|
300
|
-
or custom copy-fallback policy.
|
|
359
|
+
It delegates to `replaceFileAtomic()` with a smaller call shape, including its
|
|
360
|
+
pre-publication hook and staging-prefix options. Use `replaceFileAtomic()` when
|
|
361
|
+
you need mode preservation or a custom copy-fallback policy.
|
|
301
362
|
|
|
302
363
|
```ts
|
|
303
364
|
import { writeTextAtomic } from "@openclaw/fs-safe/atomic";
|
|
@@ -317,9 +378,17 @@ type WriteTextAtomicOptions = {
|
|
|
317
378
|
dirMode?: number; // parent mode (default 0o777 masked by process umask)
|
|
318
379
|
trailingNewline?: boolean; // append "\n" if missing; default false
|
|
319
380
|
durable?: boolean; // default true; false skips temp/parent fsync
|
|
381
|
+
beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
|
|
382
|
+
tempPrefix?: string; // default ".fs-safe-replace"
|
|
320
383
|
};
|
|
321
384
|
```
|
|
322
385
|
|
|
386
|
+
`beforeRename` is awaited after the complete text is staged and before
|
|
387
|
+
publication, with the same [stage identity and refusal cleanup](#beforerename)
|
|
388
|
+
checks as `replaceFileAtomic`. Pass `tempPrefix` to identify staged files; it
|
|
389
|
+
uses the same prefix validation, including rejection of empty prefixes and path
|
|
390
|
+
separators.
|
|
391
|
+
|
|
323
392
|
`durable: false` keeps the sibling-temp replace/rename behavior but skips the
|
|
324
393
|
temp-file and parent-directory `fsync` calls. Use it only for reconstructible
|
|
325
394
|
metadata where lower latency matters more than crash-durability.
|
package/docs/contributing.md
CHANGED
|
@@ -70,6 +70,36 @@ Consumers receive the asset in the npm package and need no compiler.
|
|
|
70
70
|
|
|
71
71
|
Output lands in `dist/`. The package's `prepack` hook re-runs the build before publishing — manual `pnpm build` is only required when you want to inspect the output or run a freshly-built copy locally.
|
|
72
72
|
|
|
73
|
+
### Linux GNU release bindings
|
|
74
|
+
|
|
75
|
+
GNU x64 and arm64 artifacts use Zig 0.16.0 and `cargo-zigbuild` 0.23.4 with an
|
|
76
|
+
explicit glibc 2.28 target, independent of the runner's libc. This matches the
|
|
77
|
+
Node Linux runtime baseline and supports RHEL 8-family users without adding a
|
|
78
|
+
second legacy package. Run the same build and ABI gate used in CI and releases:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cargo install cargo-zigbuild --version 0.23.4 --locked
|
|
82
|
+
rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
|
|
83
|
+
node scripts/build-linux-gnu.mjs x86_64-unknown-linux-gnu
|
|
84
|
+
node scripts/build-linux-gnu.mjs aarch64-unknown-linux-gnu
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
These commands require Zig on `PATH` and GNU `objdump`. They build the N-API
|
|
88
|
+
cdylib with `cargo zigbuild --target <triple>.2.28`, copy it to the existing
|
|
89
|
+
`artifacts/fs-safe-native.<platform>.node` name, and reject any GLIBC symbol
|
|
90
|
+
requirement above 2.28 before upload. The gate also rejects missing or unknown
|
|
91
|
+
GLIBC versions. To inspect an existing binding, use
|
|
92
|
+
`node scripts/check-linux-glibc.mjs <binding.node>`.
|
|
93
|
+
|
|
94
|
+
`pnpm native:build` remains a host-toolchain development build; it does not
|
|
95
|
+
establish the GNU release ABI floor.
|
|
96
|
+
|
|
97
|
+
On Linux x64 with Docker, run `pnpm build`, copy the GNU x64 artifact from
|
|
98
|
+
`artifacts/` to `native/`, run `node scripts/stage-host-native.mjs`, then run
|
|
99
|
+
`bash scripts/test-linux-glibc-floor.sh`. CI uses this command to load the actual
|
|
100
|
+
artifact and run native security and no-replace move tests in Rocky Linux 8
|
|
101
|
+
(glibc 2.28). GNU arm64 is cross-built and symbol-checked in the same CI matrix.
|
|
102
|
+
|
|
73
103
|
## Test
|
|
74
104
|
|
|
75
105
|
```bash
|
|
@@ -137,6 +167,11 @@ pnpm check
|
|
|
137
167
|
This runs the filesystem boundary checks, build, tests, and package
|
|
138
168
|
tarball/import validation.
|
|
139
169
|
|
|
170
|
+
The native watch lane requires events on Linux, macOS, and Windows and reports
|
|
171
|
+
actual edit latency. Run `FS_SAFE_TEST_SERIAL=1 pnpm check` to isolate local timing
|
|
172
|
+
checks from the other filesystem stress suites. Watch fixtures use normal OS
|
|
173
|
+
temporary storage; session scratch trees may suppress macOS filesystem events.
|
|
174
|
+
|
|
140
175
|
### Method benchmarks
|
|
141
176
|
|
|
142
177
|
`pnpm benchmark:methods` measures the callable library surface against synthetic
|
package/docs/durability.md
CHANGED
|
@@ -436,3 +436,10 @@ owning product boundary.
|
|
|
436
436
|
- [Migrating to 0.5](migrating-to-0.5.md) — choosing a publication policy during upgrade.
|
|
437
437
|
- [Native architecture](native.md) — clone/copy/hash mechanisms and fallback guarantees.
|
|
438
438
|
- [Errors](errors.md) — typed operational failure handling.
|
|
439
|
+
|
|
440
|
+
## Existing Windows file retirement
|
|
441
|
+
|
|
442
|
+
[`retainFileInDirectory`](retained-file.md) describes identity-bound native
|
|
443
|
+
disposition and resource settlement separately from persistence. Its result is
|
|
444
|
+
always `persistence: "not-proven"`; neither accepted disposition nor observed
|
|
445
|
+
namespace absence is a directory/volume barrier or an application commit.
|
package/docs/index.md
CHANGED
|
@@ -65,6 +65,7 @@ await fs.remove("notes/archive/today.txt");
|
|
|
65
65
|
| [Private file-store mode](private-file-store.md) | `fileStore({ private: true })` for private JSON/text state at 0600 under 0700 dirs. |
|
|
66
66
|
| [`tempWorkspace`](temp.md) | 0700 scratch dir with auto-cleanup. |
|
|
67
67
|
| [`readSecureFile`](secure-file.md) | Absolute file reads with fd pinning, permissions, owner, size, and timeout checks. |
|
|
68
|
+
| [`watch`](watch.md) | Guarded filesystem observation with advisory native hints and polling. |
|
|
68
69
|
| [`walkDirectory` / `Root.walk`](walk.md) | Standalone inventories plus root-bounded pruning, budgets, and partial-error reporting. |
|
|
69
70
|
| [`Root.entries`](entries.md) | Guarded nonrecursive entries, bounded name collection, and caller-owned symlink validation. |
|
|
70
71
|
| [`extractArchive`](archive.md) | Policy-driven ZIP/TAR extraction with clamp/filter, metadata/path-depth, link, count, and byte limits. |
|
package/docs/install.md
CHANGED
|
@@ -134,6 +134,16 @@ when the matching package is absent, incompatible, or disabled.
|
|
|
134
134
|
Upgrading an existing 0.5 consumer? Follow [Migrating to 0.6](migrating-to-0.6.md)
|
|
135
135
|
before deploying with native mode `require` or native-only features.
|
|
136
136
|
|
|
137
|
+
### Older Linux kernels and seccomp
|
|
138
|
+
|
|
139
|
+
The native addon supports kernels without `openat2` (before Linux 5.6) and
|
|
140
|
+
containers returning `ENOSYS` or probe-time `EPERM` for that syscall. Beneath
|
|
141
|
+
opens use a guarded no-follow component walk and report `best-effort`.
|
|
142
|
+
Nested no-clobber moves retain atomic `renameat2(RENAME_NOREPLACE)` and exact
|
|
143
|
+
identity checks; keep native mode enabled. Strict bounded cleanup still
|
|
144
|
+
requires `openat2` with `RESOLVE_NO_XDEV` and reports `helper-unavailable`
|
|
145
|
+
without it. See [Linux capability behavior and limits](native.md#linux-without-openat2).
|
|
146
|
+
|
|
137
147
|
### Windows security fallback
|
|
138
148
|
|
|
139
149
|
Windows raw owner/DACL inspection, private-directory creation, and secure-file
|
|
@@ -161,6 +171,24 @@ available native operation's error never triggers a command retry. See
|
|
|
161
171
|
|
|
162
172
|
## Native helper policy
|
|
163
173
|
|
|
174
|
+
### Supported native platforms
|
|
175
|
+
|
|
176
|
+
Prebuilt bindings cover Linux x64/arm64 (GNU glibc **2.28 or newer**, or musl),
|
|
177
|
+
macOS x64/arm64, and Windows x64. The GNU baseline includes RHEL 8, Rocky Linux 8,
|
|
178
|
+
and AlmaLinux 8. A compatible Node 22+ runtime and the kernel/filesystem features
|
|
179
|
+
required by each operation are still necessary; a loadable addon alone does not
|
|
180
|
+
guarantee every native capability. Systems older than glibc 2.28 are outside the
|
|
181
|
+
GNU binary support floor.
|
|
182
|
+
|
|
183
|
+
A missing or incompatible optional binding (including `ERR_DLOPEN_FAILED` from
|
|
184
|
+
glibc) does not prevent importing fs-safe. In `auto`, supported JavaScript/WASM
|
|
185
|
+
fallbacks remain available. Native-only operations such as the default
|
|
186
|
+
no-clobber `Root.move()` still fail closed with `helper-unavailable`; `require`
|
|
187
|
+
also rejects fallback-capable operations and retains the binding load error as
|
|
188
|
+
the cause.
|
|
189
|
+
|
|
190
|
+
### Loading modes
|
|
191
|
+
|
|
164
192
|
The platform native binaries provide fd-relative open/link/mkdir primitives,
|
|
165
193
|
atomic no-replace rename, and file identity checks. The default is `auto`: use
|
|
166
194
|
the matching binary when it loads, otherwise use the guarded JavaScript path
|
package/docs/native-helper.md
CHANGED
|
@@ -103,7 +103,7 @@ clone/copy/hash workers, POSIX canonicalization, and Windows security descriptor
|
|
|
103
103
|
layer owns policy, retries, filters, budgets, modes, cleanup, error
|
|
104
104
|
normalization, and the decision to fall back.
|
|
105
105
|
|
|
106
|
-
- Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`. Direct-child no-replace renames borrow already-retained parent descriptors; deeper relative paths retain the guarded reopen. Owned-tree cleanup
|
|
106
|
+
- Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`. Without `openat2`, beneath opens use the [no-follow component walk](native.md#linux-without-openat2) with exact identity checks and report `best-effort`. Direct-child no-replace renames borrow already-retained parent descriptors; deeper relative paths retain the guarded reopen. Owned-tree cleanup still requires `openat2` with `RESOLVE_NO_XDEV` and fails closed when unavailable.
|
|
107
107
|
- macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. Direct-child no-replace renames borrow already-retained parents without another `F_GETPATH`; deeper paths retain the guarded reopen. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace; owned-tree cleanup uses descriptor-relative `openat`/`unlinkat`.
|
|
108
108
|
- Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, and uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer. The dedicated retained-directory `renameNoReplaceWithIdentity` primitive compares its required exact source receipt with the handle opened for rename before mutation. Its internal mismatch status is normalized to public `path-mismatch`; the legacy four-argument `renameNoReplace` export and its other callers are unchanged. Owned trees are deleted through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed. Descriptors crossing N-API are converted only by the host executable's paired `uv_get_osfhandle` and `uv_open_osfhandle` exports. A runtime without both exports is unsupported for these native operations; the binding never guesses a raw HANDLE or uses a foreign CRT descriptor table. Descriptor-producing operations also require that same host's synchronous libuv close and request-management APIs before exporting an owned descriptor.
|
|
109
109
|
|
|
@@ -139,11 +139,12 @@ for the exact difference.
|
|
|
139
139
|
The guarded JavaScript mutation path is detection-based, not containment-atomic.
|
|
140
140
|
If a same-privilege peer can replace a writable parent after its identity guard
|
|
141
141
|
but before Node resolves a pathname mutation, the mutation can land outside the
|
|
142
|
-
intended root before the post-operation guard throws.
|
|
143
|
-
|
|
142
|
+
intended root before the post-operation guard throws. Native `require` ensures the addon is present, but does not require a
|
|
143
|
+
`kernel-atomic` resolver; inspect containment and use OS isolation when that
|
|
144
|
+
concurrent attacker is part of the threat model.
|
|
144
145
|
|
|
145
146
|
`openBeneath()` returns `{ fd, containment }`. `containment` is
|
|
146
|
-
`"kernel-atomic"` for Linux `openat2` and `"best-effort"` for macOS and
|
|
147
|
+
`"kernel-atomic"` for Linux `openat2` and `"best-effort"` for the Linux fallback, macOS and
|
|
147
148
|
Windows. Public JavaScript root open/read/writable results also expose the
|
|
148
149
|
field and report `"best-effort"`; the label reports mechanism, not policy.
|
|
149
150
|
|
|
@@ -181,3 +182,12 @@ consumer performs its 0.5 upgrade.
|
|
|
181
182
|
- [Durability](durability.md)
|
|
182
183
|
- [Migrating to 0.5](migrating-to-0.5.md)
|
|
183
184
|
- [Migrating to 0.6](migrating-to-0.6.md)
|
|
185
|
+
|
|
186
|
+
### Retained existing Windows file
|
|
187
|
+
|
|
188
|
+
The maintained Windows helper supports the public
|
|
189
|
+
[`retainFileInDirectory`](retained-file.md) lifecycle on fixed local NTFS.
|
|
190
|
+
Private native handles, exact identity/generation checks, writable-section
|
|
191
|
+
admission and explicit close results stay behind that public API. Older helpers
|
|
192
|
+
without this capability are unsupported; there is no pathname deletion fallback.
|
|
193
|
+
No Windows namespace persistence barrier is provided.
|
package/docs/native.md
CHANGED
|
@@ -43,6 +43,7 @@ whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
|
|
|
43
43
|
|
|
44
44
|
- Linux uses `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`, fd-relative
|
|
45
45
|
`mkdirat`/`linkat`/`renameat`/`renameat2`, `FICLONE`, and `copy_file_range`.
|
|
46
|
+
Without `openat2`, beneath opens use the [guarded fallback](#linux-without-openat2).
|
|
46
47
|
- macOS 15.4 and newer first use `openat(O_RESOLVE_BENEATH)`; older kernels walk
|
|
47
48
|
components with `openat(O_NOFOLLOW)` and restart in-root symlinks from the
|
|
48
49
|
pinned root. Both routes apply an `F_GETPATH` post-open containment detector,
|
|
@@ -85,6 +86,49 @@ privately owned empty ACL. Unsupported, malformed, and failed inspection is not
|
|
|
85
86
|
reported as absence. These facts do not classify individual ACE permissions or
|
|
86
87
|
prove volume ownership enforcement; each caller applies its own security policy.
|
|
87
88
|
|
|
89
|
+
## Linux without openat2
|
|
90
|
+
|
|
91
|
+
Linux kernels before 5.6 and containers whose seccomp policy denies `openat2`
|
|
92
|
+
can keep native mode `auto` or `require`. The addon caches one harmless
|
|
93
|
+
`openat2(".")` capability probe per process. `ENOSYS`, or `EPERM` on that probe,
|
|
94
|
+
selects the native `openat` fallback. An `EPERM`/`EACCES` from an application
|
|
95
|
+
operation is still a permission error and never triggers a retry. Install
|
|
96
|
+
syscall filters before the first native operation; a later `ENOSYS` fails with
|
|
97
|
+
`ENOTSUP`, rather than changing the cached mechanism during a call.
|
|
98
|
+
|
|
99
|
+
The fallback opens each directory relative to a retained descriptor with
|
|
100
|
+
`O_PATH | O_DIRECTORY | O_NOFOLLOW`, compares exact device/inode/type identities,
|
|
101
|
+
and rechecks the retained parent chain before and after the final no-follow
|
|
102
|
+
open. It rejects all symlink components, including procfs magic links, even
|
|
103
|
+
when the low-level caller requests symlink following. Public Root policy and
|
|
104
|
+
canonical-path admission, hardlink rejection, pinned-file checks, and mutation
|
|
105
|
+
identity fences remain in place. `openBeneath()` reports `best-effort`: these
|
|
106
|
+
identity samples detect replacements but cannot make a multi-component walk
|
|
107
|
+
atomic against a hostile process renaming directories between samples. This
|
|
108
|
+
is the same documented containment class as the macOS and JavaScript paths;
|
|
109
|
+
applications requiring atomic beneath resolution must check the result or use
|
|
110
|
+
OS isolation. Rejection after a mutating open does not promise rollback.
|
|
111
|
+
|
|
112
|
+
Nested no-clobber `Root.move()` still admits both parents and uses
|
|
113
|
+
`renameat2(RENAME_NOREPLACE)`. Existing destinations are never overwritten.
|
|
114
|
+
That separate syscall/filesystem capability remains required; if unavailable,
|
|
115
|
+
the operation fails with `helper-unavailable`. Turning native mode `off` still
|
|
116
|
+
disables no-clobber moves because Node has no equivalent atomic rename API.
|
|
117
|
+
|
|
118
|
+
Bounded owned-tree cleanup deliberately has no `openat` fallback:
|
|
119
|
+
`RESOLVE_NO_XDEV` rejects bind mounts even when device numbers match, which
|
|
120
|
+
ordinary identity checks cannot reproduce. `cleanupSafety: "require-bounded"`
|
|
121
|
+
fails before workspace creation with `helper-unavailable`; low-level cleanup
|
|
122
|
+
opens report `ENOTSUP`. Compatible cleanup retains its documented behavior.
|
|
123
|
+
Low-level `O_TMPFILE` anonymous opens also fail before creation with `ENOTSUP`
|
|
124
|
+
in the fallback, because named-entry identity checks cannot verify an unnamed
|
|
125
|
+
file. Public staged-write APIs use exclusive named files and remain available.
|
|
126
|
+
|
|
127
|
+
For tests, set `FS_SAFE_TEST_NO_OPENAT2=1` before starting Node to force the
|
|
128
|
+
fallback (including bounded-cleanup refusal). It is read only at the first
|
|
129
|
+
capability probe. It has no effect on macOS or Windows.
|
|
130
|
+
See [Linux fallback testing](testing.md#linux-openat2-fallback).
|
|
131
|
+
|
|
88
132
|
## Archives
|
|
89
133
|
|
|
90
134
|
Native ZIP and TAR entry reads retain a private, unpooled input Buffer in
|
|
@@ -252,7 +296,7 @@ See [Root containment guarantees](security-model.md#containment-guarantees-by-pl
|
|
|
252
296
|
|
|
253
297
|
| Capability | Native path | Guarded JavaScript path |
|
|
254
298
|
|---|---|---|
|
|
255
|
-
| Native beneath opens and Root mutations | Descriptor-relative beneath operations. Pinned writes create parents and publish both replacement and no-replace targets relative to open directory descriptors. No-clobber `Root.move()` admits both parents and uses the native no-replace rename. Native `openBeneath()` reports `kernel-atomic`
|
|
299
|
+
| Native beneath opens and Root mutations | Descriptor-relative beneath operations. Pinned writes create parents and publish both replacement and no-replace targets relative to open directory descriptors. No-clobber `Root.move()` admits both parents and uses the native no-replace rename. Native `openBeneath()` reports `kernel-atomic` with Linux `openat2` and `best-effort` with the guarded Linux fallback, macOS, and Windows. macOS uses `O_RESOLVE_BENEATH` when available plus an `F_GETPATH` detector, while Windows rejects reparse traversal in the object-manager call. | Reports `best-effort`: component-wise alias checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. No-clobber `Root.move()` is unsupported because a check followed by a replacing rename is unsafe. A same-privilege peer can replace a writable parent after a guard assertion but before Node resolves another pathname mutation; the mutation may land outside the intended root before the post-check detects it. |
|
|
256
300
|
| ZIP/TAR/gzip | Rust streaming decode and fd-relative output creation. | Optional JSZip or bundled WASM TAR into guarded private staging, then the same guarded merge policy. |
|
|
257
301
|
| Zstd/bzip2 TAR | Rust streaming decode and fd-relative output creation. | Bundled WASM codecs feed the shared Rust TAR parser, then guarded private staging and the same merge policy; no optional codec dependency. |
|
|
258
302
|
| Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
|
package/docs/permissions.md
CHANGED
|
@@ -185,6 +185,72 @@ the same raw ACE projection. Native `require` rejects either absence with
|
|
|
185
185
|
query's failure is terminal. The existing coarse `inspectPathPermissions()` API
|
|
186
186
|
still owns its compatibility fallback and trust classification.
|
|
187
187
|
|
|
188
|
+
### Asynchronous batches
|
|
189
|
+
|
|
190
|
+
Use `readOwnerAndDaclBatch()` when several paths, such as a directory and its
|
|
191
|
+
ancestors, need inspection without blocking the caller's event loop:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { readOwnerAndDaclBatch } from "@openclaw/fs-safe/permissions";
|
|
195
|
+
|
|
196
|
+
const facts = await readOwnerAndDaclBatch(stagingDirectories, { timeoutMs: 60_000 });
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
function readOwnerAndDaclBatch(
|
|
201
|
+
paths: readonly string[],
|
|
202
|
+
options?: { timeoutMs?: number },
|
|
203
|
+
): Promise<OwnerAndDaclResult[]>;
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Results use the same raw fact shape and SID spelling as `readOwnerAndDacl()`.
|
|
207
|
+
They correspond one-for-one to input order, including duplicate paths. An empty
|
|
208
|
+
array returns `[]` without dispatch. Other platforms return an
|
|
209
|
+
`unsupported-platform` result for each path. Query failures, malformed facts,
|
|
210
|
+
or missing/reordered response rows reject the entire batch; no partial facts
|
|
211
|
+
are returned. Null DACLs, incomplete ACE lists and nonlocal observations remain
|
|
212
|
+
raw facts for the caller's policy to evaluate.
|
|
213
|
+
|
|
214
|
+
The call captures paths, options and native mode before awaiting. Windows
|
|
215
|
+
relative paths are anchored to the current directory or selected drive at
|
|
216
|
+
entry, without normalizing their remaining `.` or `..` components. Empty,
|
|
217
|
+
nonstring, sparse or NUL-containing path entries and Windows namespace aliases
|
|
218
|
+
reject before dispatch. The UTF-8 JSON input is limited to 16 MiB.
|
|
219
|
+
|
|
220
|
+
An available native capability runs all queries in one isolated process using
|
|
221
|
+
the current runtime executable. Its resolved native mode is forwarded explicitly;
|
|
222
|
+
Node preload and module-search environment overrides are not inherited. An
|
|
223
|
+
available native query's failure is terminal. In `auto` when the binding or
|
|
224
|
+
capability is absent, or in `off`, one packaged PowerShell process reads the
|
|
225
|
+
path array from stdin and compiles the existing C# bridge once. Native `require`
|
|
226
|
+
rejects missing support before launching a process. No route launches one
|
|
227
|
+
process per path or wraps synchronous parent-process queries in promises.
|
|
228
|
+
|
|
229
|
+
`timeoutMs` defaults to 60,000 and applies to the whole process, including
|
|
230
|
+
startup and fallback compilation. It must be an integer from 1 through
|
|
231
|
+
2,147,483,647. Combined stdout and stderr are limited to 16 MiB. Success waits
|
|
232
|
+
for process exit and both output pipes to close. Timeout and transport failure
|
|
233
|
+
request termination, then retain the existing one-second settlement grace.
|
|
234
|
+
The error's `processExitConfirmed` field, also retained in its cause receipt,
|
|
235
|
+
distinguishes observed exit from an unconfirmed termination attempt. An
|
|
236
|
+
unconfirmed child can still be reading after rejection; the operation returns
|
|
237
|
+
no facts and owns no output files or artifact cleanup. Neither timing out nor
|
|
238
|
+
receiving a successful kill request is reported as confirmed exit.
|
|
239
|
+
|
|
240
|
+
The PowerShell fallback encodes and budgets each result while collecting it,
|
|
241
|
+
instead of retaining every descriptor graph until the entire batch finishes.
|
|
242
|
+
An encoded response that would exceed 16 MiB rejects with `too-large` before
|
|
243
|
+
inspecting later paths. A query failure observed earlier retains its original
|
|
244
|
+
error, and no partial facts are returned. Duplicate paths remain independent
|
|
245
|
+
observations. This bounds accumulated encoded results, not total process memory:
|
|
246
|
+
the current descriptor and row, decoded input, and runtime overhead still exist.
|
|
247
|
+
|
|
248
|
+
Batch observations are point-in-time pathname facts, not a snapshot or a
|
|
249
|
+
retained filesystem capability. The caller still owns ancestry trust, principal
|
|
250
|
+
policy and authorization for subsequent operations. Existing singular queries
|
|
251
|
+
and other command operations retain their 30-second deadline, 1 MiB output
|
|
252
|
+
budget and documented failure behavior.
|
|
253
|
+
|
|
188
254
|
## Private directories
|
|
189
255
|
|
|
190
256
|
```ts
|