@openclaw/fs-safe 0.18.2 → 0.20.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 +47 -0
- package/README.md +15 -5
- package/dist/advanced.d.ts +2 -0
- package/dist/advanced.js +1 -0
- package/dist/archive-durability.js +1 -1
- package/dist/archive-merge.js +1 -1
- package/dist/archive-plan.d.ts +2 -7
- package/dist/archive-read.js +9 -18
- package/dist/archive-staging.js +3 -1
- 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/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.d.ts +4 -11
- package/dist/file-lock-sync-root-held.js +1 -4
- package/dist/file-lock-sync-root-io.d.ts +1 -4
- package/dist/file-lock-sync-root.d.ts +2 -4
- package/dist/file-store-boundary.d.ts +3 -7
- package/dist/file-store-boundary.js +7 -10
- package/dist/file-store-prune.js +3 -2
- package/dist/file-store.d.ts +4 -7
- package/dist/file-store.js +19 -21
- package/dist/guest-native-python.js +23 -31
- package/dist/guest.js +21 -12
- package/dist/json-document-store.d.ts +4 -9
- package/dist/json-durable-queue.js +2 -6
- package/dist/local-file-access.js +2 -5
- package/dist/local-file-descriptor.d.ts +2 -5
- package/dist/local-roots.d.ts +2 -7
- package/dist/move-path-cleanup.js +4 -4
- package/dist/native-binding.d.ts +18 -14
- 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-mutation-shared-route.d.ts +2 -8
- 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-copy-fallback.d.ts +1 -2
- package/dist/root-context.js +3 -2
- package/dist/root-impl.js +0 -3
- package/dist/root-move-noreplace.d.ts +2 -7
- 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-paths.d.ts +2 -6
- package/dist/root-remove-identity.d.ts +1 -3
- package/dist/root-walk.js +8 -3
- package/dist/root-write-admission.js +1 -6
- package/dist/root-write-complete-parent.d.ts +2 -0
- package/dist/root-write-complete-parent.js +1 -1
- package/dist/safe-path-segment.d.ts +1 -0
- package/dist/safe-path-segment.js +8 -2
- package/dist/secret-file.d.ts +6 -2
- package/dist/secret-file.js +1 -0
- package/dist/secure-file-windows.js +1 -5
- package/dist/secure-file.js +3 -2
- package/dist/sidecar-lock-admission-parser.d.ts +1 -2
- package/dist/sidecar-lock-handle.d.ts +2 -8
- package/dist/sidecar-lock-policy.d.ts +2 -7
- package/dist/sidecar-lock-stale-admission.d.ts +1 -5
- 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 +4 -2
- package/dist/temp-workspace-owner.js +4 -9
- package/dist/test-hooks.d.ts +1 -1
- 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/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 -15
- package/dist/windows-security-facts.d.ts +4 -0
- package/dist/windows-security-facts.js +6 -2
- package/docs/advanced.md +3 -2
- package/docs/archive.md +10 -0
- package/docs/atomic.md +11 -3
- package/docs/contributing.md +30 -0
- package/docs/copy.md +2 -0
- package/docs/file-store.md +5 -0
- package/docs/guest.md +7 -1
- package/docs/install.md +28 -0
- package/docs/native-helper.md +11 -4
- package/docs/native.md +45 -1
- package/docs/permissions.md +66 -0
- package/docs/public-api.md +7 -1
- package/docs/root.md +10 -1
- package/docs/secret-file.md +10 -0
- 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/testing.md +28 -0
- package/docs/walk.md +12 -0
- package/docs/writing.md +21 -0
- package/package.json +9 -9
package/dist/symlink-parents.js
CHANGED
|
@@ -3,24 +3,46 @@ import path from "node:path";
|
|
|
3
3
|
import { FsSafeError } from "./errors.js";
|
|
4
4
|
import { hasNodeErrorCode, isPathRelativeEscape } from "./path.js";
|
|
5
5
|
import { assertNoWindowsPathAlias, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
|
|
6
|
+
function outsideRootError(params, root) {
|
|
7
|
+
return new Error(`${params.messagePrefix ?? "Path"} must stay under ${root}.`);
|
|
8
|
+
}
|
|
9
|
+
function pathSegments(value) {
|
|
10
|
+
return value.split(process.platform === "win32" ? /[/\\]+/ : /\/+/)
|
|
11
|
+
.filter((segment) => segment.length > 0 && segment !== ".");
|
|
12
|
+
}
|
|
13
|
+
function rawTargetSegments(root, targetPath) {
|
|
14
|
+
// Resolve only the drive/root or cwd, never the caller's dotdot segments.
|
|
15
|
+
const targetRoot = path.parse(targetPath).root;
|
|
16
|
+
const base = resolvePathPreservingWindowsRoot(targetRoot || ".");
|
|
17
|
+
const absoluteTarget = `${base}${path.sep}${targetPath.slice(targetRoot.length)}`;
|
|
18
|
+
const rootSegments = pathSegments(root);
|
|
19
|
+
const targetSegments = pathSegments(absoluteTarget);
|
|
20
|
+
const fold = (segment) => process.platform === "win32" ? segment.toLowerCase() : segment;
|
|
21
|
+
if (rootSegments.some((segment, index) => fold(segment) !== fold(targetSegments[index] ?? ""))) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return targetSegments.slice(rootSegments.length);
|
|
25
|
+
}
|
|
6
26
|
function resolvePathWalk(params) {
|
|
7
27
|
const rawRootDir = params.rootDir;
|
|
8
28
|
assertNoWindowsPathAlias(rawRootDir, "filesystem", "root dir uses a Windows filesystem namespace alias");
|
|
9
29
|
const rawTargetPath = params.targetPath;
|
|
10
30
|
assertNoWindowsPathAlias(rawTargetPath, "filesystem", "target path uses a Windows filesystem namespace alias");
|
|
11
31
|
const root = resolvePathPreservingWindowsRoot(rawRootDir);
|
|
12
|
-
const
|
|
13
|
-
const relative = path.relative(root,
|
|
32
|
+
const lexicalTarget = resolvePathPreservingWindowsRoot(rawTargetPath);
|
|
33
|
+
const relative = path.relative(root, lexicalTarget);
|
|
14
34
|
if (isPathRelativeEscape(relative)) {
|
|
15
35
|
if (params.allowOutsideRoot) {
|
|
16
36
|
return null;
|
|
17
37
|
}
|
|
18
|
-
throw
|
|
38
|
+
throw outsideRootError(params, root);
|
|
19
39
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
40
|
+
const segments = rawTargetSegments(root, rawTargetPath);
|
|
41
|
+
// A spelling outside the root must not fall back to a normalized walk that
|
|
42
|
+
// could erase a symlink before the filesystem receives the original path.
|
|
43
|
+
if (!segments)
|
|
44
|
+
throw outsideRootError(params, root);
|
|
45
|
+
return { root, segments };
|
|
24
46
|
}
|
|
25
47
|
function formatUnsafePath(params, current) {
|
|
26
48
|
return `${params.messagePrefix ?? "Path"} must not traverse symlinked directory: ${current}`;
|
|
@@ -28,18 +50,39 @@ function formatUnsafePath(params, current) {
|
|
|
28
50
|
export async function assertNoSymlinkParents(params) {
|
|
29
51
|
assertNoSymlinkParentsSync(params);
|
|
30
52
|
}
|
|
53
|
+
function isFilesystemRoot(root) {
|
|
54
|
+
return root === path.parse(root).root;
|
|
55
|
+
}
|
|
31
56
|
export function assertNoSymlinkParentsSync(params) {
|
|
32
57
|
const walk = resolvePathWalk(params);
|
|
33
58
|
if (!walk) {
|
|
34
59
|
return;
|
|
35
60
|
}
|
|
36
61
|
let current = walk.root;
|
|
62
|
+
// `..` may only undo a real directory this walk already lstat'd.
|
|
63
|
+
const walked = [];
|
|
37
64
|
for (const [index, segment] of walk.segments.entries()) {
|
|
65
|
+
if (segment === "..") {
|
|
66
|
+
const top = walked[walked.length - 1];
|
|
67
|
+
if (top?.kind === "dir") {
|
|
68
|
+
walked.pop();
|
|
69
|
+
current = walked[walked.length - 1]?.path ?? walk.root;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (top?.kind === "symlink") {
|
|
73
|
+
throw new Error(formatUnsafePath(params, top.path));
|
|
74
|
+
}
|
|
75
|
+
if (!isFilesystemRoot(walk.root)) {
|
|
76
|
+
throw outsideRootError(params, walk.root);
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
38
80
|
current = path.join(current, segment);
|
|
39
81
|
try {
|
|
40
82
|
const stat = fsSync.lstatSync(current);
|
|
41
83
|
if (stat.isSymbolicLink()) {
|
|
42
84
|
if (params.allowRootChildSymlink && path.dirname(current) === walk.root) {
|
|
85
|
+
walked.push({ path: current, kind: "symlink" });
|
|
43
86
|
continue;
|
|
44
87
|
}
|
|
45
88
|
throw new Error(formatUnsafePath(params, current));
|
|
@@ -47,9 +90,17 @@ export function assertNoSymlinkParentsSync(params) {
|
|
|
47
90
|
if ((params.requireDirectories || index < walk.segments.length - 1) && !stat.isDirectory()) {
|
|
48
91
|
throw new FsSafeError("not-file", `${params.messagePrefix ?? "Path"} must traverse directories: ${current}`);
|
|
49
92
|
}
|
|
93
|
+
if (stat.isDirectory()) {
|
|
94
|
+
walked.push({ path: current, kind: "dir" });
|
|
95
|
+
}
|
|
50
96
|
}
|
|
51
97
|
catch (err) {
|
|
52
98
|
if (hasNodeErrorCode(err, "ENOENT") && params.allowMissing !== false) {
|
|
99
|
+
// Win32 can cancel a nonexistent component before filesystem lookup.
|
|
100
|
+
// Returning early would leave later, reachable symlinks unchecked.
|
|
101
|
+
if (process.platform === "win32" && walk.segments.slice(index + 1).includes("..")) {
|
|
102
|
+
throw new FsSafeError("invalid-path", `${params.messagePrefix ?? "Path"} must not cancel a missing directory: ${current}`);
|
|
103
|
+
}
|
|
53
104
|
return;
|
|
54
105
|
}
|
|
55
106
|
throw err;
|
package/dist/temp-target.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { suffixWindowsReservedDeviceName } from "./filename.js";
|
|
6
6
|
import { sameFileIdentityForCleanup } from "./file-identity.js";
|
|
7
|
-
import { assertSafePathSegment, sanitizeSafePathSegment, trimHyphenEdges } from "./safe-path-segment.js";
|
|
7
|
+
import { assertSafePathSegment, normalizeSafePathSegment, sanitizeSafePathSegment, trimHyphenEdges, } from "./safe-path-segment.js";
|
|
8
8
|
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
9
9
|
import { hasNodeErrorCode } from "./path.js";
|
|
10
10
|
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
@@ -60,7 +60,9 @@ function sanitizeExtension(extension) {
|
|
|
60
60
|
return token ? `.${token}` : "";
|
|
61
61
|
}
|
|
62
62
|
export function sanitizeTempFileName(fileName) {
|
|
63
|
-
|
|
63
|
+
// Suffix reserved stems before admission so CON.txt stays CON_.txt.
|
|
64
|
+
const suffixed = suffixWindowsReservedDeviceName(normalizeSafePathSegment(path.basename(fileName)));
|
|
65
|
+
return sanitizeSafePathSegment(suffixed) ?? "download.bin";
|
|
64
66
|
}
|
|
65
67
|
export function buildRandomTempFilePath(params) {
|
|
66
68
|
const rootDir = resolveTempRoot(params.rootDir);
|
|
@@ -14,13 +14,6 @@ function isNativeCleanupBinding(binding) {
|
|
|
14
14
|
typeof binding.removeOwnedTreeSync === "function" &&
|
|
15
15
|
typeof binding.ownedTreeRemovalAvailable === "function";
|
|
16
16
|
}
|
|
17
|
-
function nativeRemovalError(result) {
|
|
18
|
-
if (!result.errorCode)
|
|
19
|
-
return undefined;
|
|
20
|
-
return Object.assign(new Error(result.errorMessage ?? "native owned-tree cleanup failed"), {
|
|
21
|
-
code: result.errorCode,
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
17
|
export class TempWorkspaceCleanupCapability {
|
|
25
18
|
binding;
|
|
26
19
|
parent;
|
|
@@ -309,8 +302,10 @@ export class TempWorkspaceCleanupOwner {
|
|
|
309
302
|
this.#capability.assertCurrent();
|
|
310
303
|
}
|
|
311
304
|
#mapRemoval(result) {
|
|
312
|
-
|
|
313
|
-
|
|
305
|
+
if (result.errorCode) {
|
|
306
|
+
const error = Object.assign(new Error(result.errorMessage ?? "native owned-tree cleanup failed"), {
|
|
307
|
+
code: result.errorCode,
|
|
308
|
+
});
|
|
314
309
|
if (error.code === "path-mismatch")
|
|
315
310
|
return "indeterminate";
|
|
316
311
|
throw error;
|
package/dist/test-hooks.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type FsSafeTestHooks = {
|
|
|
25
25
|
beforeTempWorkspaceNativeRemovalSync?: (quarantinePath: string) => void;
|
|
26
26
|
beforeTrashMove?: (targetPath: string, destPath: string) => void;
|
|
27
27
|
afterPublishTargetCreated?: (method: "hardlink" | "exclusive-copy" | "rename-noreplace", targetPath: string, identity: FileIdentityStat) => Promise<void> | void;
|
|
28
|
-
beforePublishDirectorySync?:
|
|
28
|
+
beforePublishDirectorySync?: NonNullable<FsSafeTestHooks["afterPublishTargetCreated"]>;
|
|
29
29
|
};
|
|
30
30
|
export declare function getFsSafeTestHooks(): FsSafeTestHooks | undefined;
|
|
31
31
|
export declare function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
|
package/dist/text-atomic.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { type ReplaceFileAtomicOptions } from "./replace-file.js";
|
|
2
|
+
export type WriteTextAtomicOptions = Pick<ReplaceFileAtomicOptions, "beforeRename" | "tempPrefix"> & {
|
|
2
3
|
mode?: number;
|
|
3
4
|
dirMode?: number;
|
|
4
5
|
trailingNewline?: boolean;
|
package/dist/text-atomic.js
CHANGED
package/dist/trash.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { assertSyncDirectoryGuard, createSyncDirectoryGuard } from "./directory-guard.js";
|
|
4
5
|
import { sameFileIdentity } from "./file-identity.js";
|
|
5
6
|
import { guardedRenameSync, guardedRmSync } from "./guarded-mutation.js";
|
|
6
7
|
import { realpathSync } from "./realpath.js";
|
|
@@ -72,6 +73,22 @@ function resolveTrashTargetPath(targetPath) {
|
|
|
72
73
|
assertNoTrashPathAlias(resolvedPath, "target path");
|
|
73
74
|
return { path: resolvedPath, resolved: true };
|
|
74
75
|
}
|
|
76
|
+
function resolveTrashEntryParent(lexicalTarget, targetPath) {
|
|
77
|
+
const lexicalParent = path.dirname(lexicalTarget);
|
|
78
|
+
assertNoTrashPathAlias(lexicalParent, "target path");
|
|
79
|
+
let realParent;
|
|
80
|
+
try {
|
|
81
|
+
// The renamed name lives in this parent. rename follows intermediate
|
|
82
|
+
// symlinks, so a lexical parent inside an allowed root is not enough.
|
|
83
|
+
realParent = realpathSync.native(lexicalParent);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
|
|
87
|
+
}
|
|
88
|
+
const resolvedParent = path.resolve(realParent);
|
|
89
|
+
assertNoTrashPathAlias(resolvedParent, "target path");
|
|
90
|
+
return resolvedParent;
|
|
91
|
+
}
|
|
75
92
|
function assertAllowedTrashTarget(targetPath, allowedRoots) {
|
|
76
93
|
assertNoTrashPathAlias(targetPath, "target path");
|
|
77
94
|
const lexicalTarget = path.resolve(targetPath);
|
|
@@ -79,11 +96,19 @@ function assertAllowedTrashTarget(targetPath, allowedRoots) {
|
|
|
79
96
|
const stat = fs.lstatSync(lexicalTarget);
|
|
80
97
|
const resolvedTarget = resolveTrashTargetPath(targetPath);
|
|
81
98
|
const resolvedTargetPath = resolvedTarget.path;
|
|
82
|
-
const
|
|
99
|
+
const parent = createSyncDirectoryGuard(path.dirname(lexicalTarget));
|
|
100
|
+
// Admit the directory entry only when its parent really stays inside an
|
|
101
|
+
// allowed root. Do not admit it because the symlink target is inside.
|
|
102
|
+
const resolvedParent = resolveTrashEntryParent(lexicalTarget, targetPath);
|
|
103
|
+
const isAllowed = resolveAllowedTrashRoots(allowedRoots).some((root) => isSameOrChildPath(resolvedParent, root));
|
|
83
104
|
if (!isAllowed) {
|
|
84
105
|
throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
|
|
85
106
|
}
|
|
107
|
+
// Sync and native realpath can use different Windows short-name spellings.
|
|
108
|
+
// Recheck the retained guard around native containment instead of comparing them.
|
|
109
|
+
assertSyncDirectoryGuard(parent);
|
|
86
110
|
return {
|
|
111
|
+
parent,
|
|
87
112
|
path: lexicalTarget,
|
|
88
113
|
realPath: resolvedTargetPath,
|
|
89
114
|
realPathResolved: resolvedTarget.resolved,
|
|
@@ -91,6 +116,7 @@ function assertAllowedTrashTarget(targetPath, allowedRoots) {
|
|
|
91
116
|
};
|
|
92
117
|
}
|
|
93
118
|
function assertTrashTargetGuard(guard) {
|
|
119
|
+
assertSyncDirectoryGuard(guard.parent);
|
|
94
120
|
const stat = fs.lstatSync(guard.path);
|
|
95
121
|
if (!sameFileIdentity(stat, guard.stat)) {
|
|
96
122
|
throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
|
package/dist/walk.d.ts
CHANGED
|
@@ -20,12 +20,9 @@ export type AsyncWalkDirectoryOptions = Omit<WalkDirectoryOptions, "include" | "
|
|
|
20
20
|
include?: (entry: WalkDirectoryEntry) => boolean | Promise<boolean>;
|
|
21
21
|
descend?: (entry: WalkDirectoryEntry) => boolean | Promise<boolean>;
|
|
22
22
|
};
|
|
23
|
-
export type WalkDirectoryFailure = {
|
|
24
|
-
path: string;
|
|
25
|
-
relativePath: string;
|
|
26
|
-
depth: number;
|
|
23
|
+
export type WalkDirectoryFailure = Pick<WalkDirectoryEntry & {
|
|
27
24
|
error: unknown;
|
|
28
|
-
}
|
|
25
|
+
}, "path" | "relativePath" | "depth" | "error">;
|
|
29
26
|
export type WalkDirectoryResult = {
|
|
30
27
|
entries: WalkDirectoryEntry[];
|
|
31
28
|
scannedEntryCount: number;
|
package/dist/windows-owner.d.ts
CHANGED
package/dist/windows-owner.js
CHANGED
|
@@ -312,6 +312,10 @@ public static partial class FsSafeWindowsBridge {
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
|
+
static object InspectPath(string path) {
|
|
316
|
+
// Raw reporting retains ACL facts when locality is unknown; admission remains strict.
|
|
317
|
+
using(var handle=Open(path,0x00020080,false)) return Security(handle,false);
|
|
318
|
+
}
|
|
315
319
|
public static object Execute(string operation,string path) {
|
|
316
320
|
try {
|
|
317
321
|
object result;
|
|
@@ -323,10 +327,8 @@ public static partial class FsSafeWindowsBridge {
|
|
|
323
327
|
Require(!handle.IsInvalid,"EBADF","inherited file handle is unavailable");
|
|
324
328
|
result=Row("identity",Identity(handle),"security",Security(handle));
|
|
325
329
|
}
|
|
326
|
-
} else if(operation=="path")
|
|
327
|
-
|
|
328
|
-
using(var handle=Open(path,0x00020080,false)) result=Security(handle,false);
|
|
329
|
-
} else throw new Failure("EINVAL","unknown Windows security operation");
|
|
330
|
+
} else if(operation=="path") result=InspectPath(path);
|
|
331
|
+
else throw new Failure("EINVAL","unknown Windows security operation");
|
|
330
332
|
return Row("ok",true,"result",result);
|
|
331
333
|
} catch(Failure error) { return Row("ok",false,"code",error.Code,"message",error.Message); }
|
|
332
334
|
catch(Exception) { return Row("ok",false,"code","EIO","message","Windows security descriptor processing failed"); }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
param(
|
|
2
2
|
[Parameter(Mandatory = $true)]
|
|
3
|
-
[ValidateSet('path', 'descriptor', 'create', 'directory', 'protect-file', 'verify-file')]
|
|
3
|
+
[ValidateSet('path', 'paths', 'descriptor', 'create', 'directory', 'protect-file', 'verify-file')]
|
|
4
4
|
[string] $Operation
|
|
5
5
|
)
|
|
6
6
|
|
|
@@ -11,5 +11,80 @@ $env:PSModulePath = [IO.Path]::Combine($PSHOME, 'Modules')
|
|
|
11
11
|
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
|
12
12
|
|
|
13
13
|
Microsoft.PowerShell.Utility\Add-Type -LiteralPath ([IO.Path]::Combine($PSScriptRoot, 'windows-security-bridge.cs'))
|
|
14
|
-
$
|
|
15
|
-
|
|
14
|
+
if ($Operation -eq 'paths') {
|
|
15
|
+
$reply = $null
|
|
16
|
+
$limit = 16 * 1024 * 1024
|
|
17
|
+
try {
|
|
18
|
+
$inputStream = [Console]::OpenStandardInput()
|
|
19
|
+
$inputBytes = [IO.MemoryStream]::new()
|
|
20
|
+
try {
|
|
21
|
+
$buffer = [byte[]]::new(8192)
|
|
22
|
+
while (($read = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
|
|
23
|
+
if ($inputBytes.Length + $read -gt $limit) {
|
|
24
|
+
throw 'Windows security path batch exceeds the input budget'
|
|
25
|
+
}
|
|
26
|
+
$inputBytes.Write($buffer, 0, $read)
|
|
27
|
+
}
|
|
28
|
+
$inputJson = [Text.UTF8Encoding]::new($false, $true).GetString($inputBytes.ToArray())
|
|
29
|
+
} finally {
|
|
30
|
+
$inputBytes.Dispose()
|
|
31
|
+
$inputStream.Dispose()
|
|
32
|
+
}
|
|
33
|
+
if (-not $inputJson.TrimStart().StartsWith('[')) {
|
|
34
|
+
throw 'Windows security paths must be a JSON array'
|
|
35
|
+
}
|
|
36
|
+
# Validate the whole document before wrapping it; the wrapper keeps empty,
|
|
37
|
+
# singleton, and nested arrays intact on Windows PowerShell 5.1.
|
|
38
|
+
$null = Microsoft.PowerShell.Utility\ConvertFrom-Json -InputObject $inputJson
|
|
39
|
+
$request = Microsoft.PowerShell.Utility\ConvertFrom-Json -InputObject ('{"paths":' + $inputJson + '}')
|
|
40
|
+
if ($request.paths -isnot [array]) {
|
|
41
|
+
throw 'Windows security paths must be a JSON array'
|
|
42
|
+
}
|
|
43
|
+
for ($index = 0; $index -lt $request.paths.Length; $index++) {
|
|
44
|
+
$value = $request.paths[$index]
|
|
45
|
+
if ($value -isnot [string] -or $value.Length -eq 0 -or $value.IndexOf([char]0) -ge 0) {
|
|
46
|
+
throw 'Windows security paths must be nonempty strings without NUL bytes'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
$reply = @{ ok = $false; code = 'EINVAL'; message = 'Invalid Windows security path batch' }
|
|
51
|
+
}
|
|
52
|
+
if ($null -eq $reply) {
|
|
53
|
+
$utf8 = [Console]::OutputEncoding
|
|
54
|
+
$prefix = '{"ok":true,"result":['
|
|
55
|
+
$suffix = ']}'
|
|
56
|
+
$bytes = $utf8.GetByteCount($prefix) + $utf8.GetByteCount($suffix)
|
|
57
|
+
$encoded = [Text.StringBuilder]::new($prefix)
|
|
58
|
+
$separator = ''
|
|
59
|
+
foreach ($pathname in $request.paths) {
|
|
60
|
+
try {
|
|
61
|
+
$reply = [FsSafeWindowsBridge]::Execute('path', $pathname)
|
|
62
|
+
} catch {
|
|
63
|
+
$reply = @{ ok = $false; code = 'EINVAL'; message = 'Invalid Windows security path batch' }
|
|
64
|
+
}
|
|
65
|
+
if (-not $reply.ok) { break }
|
|
66
|
+
$rowJson = Microsoft.PowerShell.Utility\ConvertTo-Json -InputObject ([ordered]@{ path = $pathname; security = $reply.result }) -Depth 8 -Compress
|
|
67
|
+
$reply = $null
|
|
68
|
+
$nextBytes = $bytes + $separator.Length + $utf8.GetByteCount($rowJson)
|
|
69
|
+
if ($nextBytes -gt $limit) {
|
|
70
|
+
$rowJson = $null
|
|
71
|
+
$reply = @{ ok = $false; code = 'too-large'; message = 'Windows security batch exceeded its output budget' }
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
[void]$encoded.Append($separator).Append($rowJson)
|
|
75
|
+
$bytes = $nextBytes
|
|
76
|
+
$separator = ','
|
|
77
|
+
$rowJson = $null
|
|
78
|
+
}
|
|
79
|
+
if ($null -eq $reply) {
|
|
80
|
+
[void]$encoded.Append($suffix)
|
|
81
|
+
[Console]::Write($encoded.ToString())
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
$encoded = $null
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
$targetPath = [Environment]::GetEnvironmentVariable('FS_SAFE_WINDOWS_SECURITY_PATH')
|
|
88
|
+
$reply = [FsSafeWindowsBridge]::Execute($Operation, $targetPath)
|
|
89
|
+
}
|
|
90
|
+
$reply | Microsoft.PowerShell.Utility\ConvertTo-Json -Depth 8 -Compress
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { NativeWindowsDescriptorSecurityFacts, NativeWindowsSecurityFacts } from "./native-binding.js";
|
|
2
|
+
import type { FsSafeNativeMode } from "./native-config.js";
|
|
3
|
+
import { type DescriptorFacts } from "./windows-security-facts.js";
|
|
4
|
+
export declare const WINDOWS_SECURITY_BATCH_MAX_BYTES: number;
|
|
2
5
|
/** Cleanup must preserve stages still reachable by an unsettled command. */
|
|
3
6
|
export declare function hasUnsettledWindowsSecurityCommand(error: unknown): boolean;
|
|
7
|
+
export declare function readWindowsSecurityFactsBatch(paths: readonly string[], options: {
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
native: boolean;
|
|
10
|
+
mode: FsSafeNativeMode;
|
|
11
|
+
}): Promise<DescriptorFacts[]>;
|
|
4
12
|
export declare function readWindowsSecurityFactsCommand(targetPath: string): NativeWindowsSecurityFacts;
|
|
5
13
|
export declare function inspectWindowsDescriptorCommand(fd: number): Promise<NativeWindowsDescriptorSecurityFacts>;
|
|
6
14
|
export declare function createPrivateWindowsDirectoryCommand(targetPath: string, expectedParentIdentity?: string): Promise<{
|
|
@@ -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 } 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
|
}
|
|
@@ -50,9 +51,6 @@ function command(operation, params) {
|
|
|
50
51
|
},
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
|
-
function unverified(message, cause) {
|
|
54
|
-
throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
|
|
55
|
-
}
|
|
56
54
|
function record(value) {
|
|
57
55
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
58
56
|
}
|
|
@@ -79,6 +77,10 @@ function parseReplyValue(stdout, operation) {
|
|
|
79
77
|
unverified("Windows security command returned an incomplete response");
|
|
80
78
|
}
|
|
81
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
|
+
}
|
|
82
84
|
const codes = new Set(["EACCES", "EPERM", "EEXIST", "ENOENT", "ENOTSUP", "EIO", "EBADF", "ELOOP", "ENOTDIR", "EINVAL", "ENOSPC", "EBUSY"]);
|
|
83
85
|
if (typeof response.code !== "string" || !codes.has(response.code) || typeof response.message !== "string") {
|
|
84
86
|
unverified("Windows security command returned an invalid failure");
|
|
@@ -115,13 +117,13 @@ class WindowsSecurityCommandError extends PermissionCommandError {
|
|
|
115
117
|
timedOut;
|
|
116
118
|
creationOutcome;
|
|
117
119
|
processExitConfirmed;
|
|
118
|
-
constructor(file, durationMs, receipt, timedOut) {
|
|
119
|
-
super(file, durationMs, receipt);
|
|
120
|
+
constructor(file, durationMs, receipt, timedOut, timeoutMs = DEFAULT_PERMISSION_EXEC_TIMEOUT_MS) {
|
|
121
|
+
super(file, durationMs, receipt, timeoutMs);
|
|
120
122
|
this.timedOut = timedOut;
|
|
121
123
|
this.creationOutcome = receipt.creationOutcome;
|
|
122
124
|
this.processExitConfirmed = receipt.processExitConfirmed;
|
|
123
125
|
if (timedOut)
|
|
124
|
-
this.message = `Windows permission inspection timed out after ${
|
|
126
|
+
this.message = `Windows permission inspection timed out after ${timeoutMs}ms`;
|
|
125
127
|
if (!receipt.processExitConfirmed)
|
|
126
128
|
this.message += "; process exit was not confirmed";
|
|
127
129
|
else if (!receipt.outputClosed)
|
|
@@ -159,11 +161,12 @@ export function hasUnsettledWindowsSecurityCommand(error) {
|
|
|
159
161
|
return pending.length > 0;
|
|
160
162
|
}
|
|
161
163
|
function ignoreLateError() { }
|
|
162
|
-
async function execute(operation, params) {
|
|
163
|
-
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;
|
|
164
167
|
const startedAt = performance.now();
|
|
165
168
|
return await new Promise((resolve, reject) => {
|
|
166
|
-
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"] });
|
|
167
170
|
const output = [];
|
|
168
171
|
const errors = [];
|
|
169
172
|
let bytes = 0;
|
|
@@ -183,7 +186,7 @@ async function execute(operation, params) {
|
|
|
183
186
|
const timeout = setTimeout(() => {
|
|
184
187
|
timedOut = true;
|
|
185
188
|
fail(deadlineError());
|
|
186
|
-
},
|
|
189
|
+
}, timeoutMs);
|
|
187
190
|
const finish = (outputClosed) => {
|
|
188
191
|
if (settled)
|
|
189
192
|
return;
|
|
@@ -196,6 +199,18 @@ async function execute(operation, params) {
|
|
|
196
199
|
child.removeListener("error", onChildError);
|
|
197
200
|
child.on("error", ignoreLateError);
|
|
198
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
|
+
}
|
|
199
214
|
for (const [stream, collect] of [[child.stdout, collectOutput], [child.stderr, collectErrors]]) {
|
|
200
215
|
if (!stream)
|
|
201
216
|
continue;
|
|
@@ -224,7 +239,7 @@ async function execute(operation, params) {
|
|
|
224
239
|
cause: failure, pid: child.pid ?? null, code: exitCode, signal: exitSignal, stderr: Buffer.concat(errors),
|
|
225
240
|
processExitConfirmed, outputClosed, terminationSignalSent, terminationError, cleanupErrors,
|
|
226
241
|
...(operation === "create" ? { creationOutcome: "unconfirmed" } : {}),
|
|
227
|
-
}, timedOut));
|
|
242
|
+
}, timedOut, timeoutMs));
|
|
228
243
|
}
|
|
229
244
|
else {
|
|
230
245
|
try {
|
|
@@ -263,7 +278,7 @@ async function execute(operation, params) {
|
|
|
263
278
|
if (failed || settled)
|
|
264
279
|
return;
|
|
265
280
|
bytes += chunk.length;
|
|
266
|
-
if (bytes <=
|
|
281
|
+
if (bytes <= maxOutputBytes)
|
|
267
282
|
chunks.push(chunk);
|
|
268
283
|
else
|
|
269
284
|
fail(new Error("Windows security command exceeded its output budget"));
|
|
@@ -285,7 +300,7 @@ async function execute(operation, params) {
|
|
|
285
300
|
};
|
|
286
301
|
const onClose = (code, signal) => {
|
|
287
302
|
onExit(code, signal);
|
|
288
|
-
if (!failed && performance.now() - startedAt >=
|
|
303
|
+
if (!failed && performance.now() - startedAt >= timeoutMs) {
|
|
289
304
|
timedOut = true;
|
|
290
305
|
failed = true;
|
|
291
306
|
failure = deadlineError();
|
|
@@ -301,11 +316,47 @@ async function execute(operation, params) {
|
|
|
301
316
|
child.stderr?.on("error", fail);
|
|
302
317
|
child.stdout?.on("data", collectOutput);
|
|
303
318
|
child.stderr?.on("data", collectErrors);
|
|
304
|
-
if (!child.stdout || !child.stderr) {
|
|
319
|
+
if (!child.stdout || !child.stderr || (input !== undefined && !child.stdin)) {
|
|
305
320
|
// Node reports spawn errors on nextTick, which can follow the current microtask.
|
|
306
321
|
missingPipes = setImmediate(() => { if (!failed && !settled)
|
|
307
322
|
fail(new Error("Windows security command output pipes are unavailable")); });
|
|
308
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);
|
|
309
360
|
});
|
|
310
361
|
}
|
|
311
362
|
export function readWindowsSecurityFactsCommand(targetPath) {
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { NativeWindowsSecurityFacts } from "./native-binding.js";
|
|
2
|
+
export type DescriptorFacts = Pick<NativeWindowsSecurityFacts, "ownerSid" | "currentUserSid" | "daclPresent" | "isLocal" | "aceListComplete" | "unsupportedAceTypes" | "aces">;
|
|
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;
|
|
2
6
|
/** Raw reporting retains unknown flag bits; secure admission validates them below. */
|
|
3
7
|
export declare function parseWindowsSecurityCommandFacts(value: unknown): NativeWindowsSecurityFacts;
|
|
4
8
|
/** Native and command observations share the same fail-closed admission policy. */
|
|
@@ -6,8 +6,8 @@ const WORLD_SIDS = new Set([
|
|
|
6
6
|
"s-1-1-0", "s-1-5-11", "s-1-5-32-545", "s-1-5-7",
|
|
7
7
|
"s-1-5-32-546", "s-1-5-4", "s-1-5-2",
|
|
8
8
|
]);
|
|
9
|
-
function unverified(message) {
|
|
10
|
-
throw new FsSafeError("permission-unverified", message);
|
|
9
|
+
export function unverified(message, cause) {
|
|
10
|
+
throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
|
|
11
11
|
}
|
|
12
12
|
function record(value) {
|
|
13
13
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -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
|
@@ -80,7 +80,7 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
|
|
|
80
80
|
| `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
|
|
81
81
|
| `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
82
|
| `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
|
|
83
|
-
| `assertNoSymlinkParents`, `assertNoSymlinkParentsSync`, `AssertNoSymlinkParentsOptions` | – | Reject paths whose ancestor chain contains symlinks. |
|
|
83
|
+
| `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
84
|
| `assertNoHardlinkedFinalPath`, `assertNoPathAliasEscape`, `PATH_ALIAS_POLICIES`, `PathAliasPolicy` | – | Hardlink/alias defense building blocks. |
|
|
85
85
|
|
|
86
86
|
`pathExists()` and `pathExistsSync()` intentionally retain ordinary `stat`
|
|
@@ -240,6 +240,7 @@ atomic replacement, use [`Root.write()`](writing.md).
|
|
|
240
240
|
| Export | Page | Notes |
|
|
241
241
|
|---|---|---|
|
|
242
242
|
| `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. |
|
|
243
|
+
| `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
244
|
| `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
245
|
| `writeSiblingTempFile`, `writeViaSiblingTempPath`, `WriteSiblingTempFileOptions`, `WriteSiblingTempFileResult` | – | Callback-produced file staging: verified sibling publication or private-workspace copy through a root. |
|
|
245
246
|
|
|
@@ -256,7 +257,7 @@ atomic replacement, use [`Root.write()`](writing.md).
|
|
|
256
257
|
|---|---|---|
|
|
257
258
|
| `createAsyncLock` | – | In-process async lock (separate from cross-process file locks). |
|
|
258
259
|
| `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. |
|
|
260
|
+
| `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
261
|
|
|
261
262
|
## Stability
|
|
262
263
|
|