@openclaw/fs-safe 0.18.1 → 0.19.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 +28 -0
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.js +1 -1
- package/dist/archive-durability.js +1 -1
- package/dist/archive-merge.js +1 -2
- package/dist/archive-staging.d.ts +1 -2
- package/dist/archive-staging.js +3 -3
- package/dist/archive-zip-preflight.d.ts +0 -2
- package/dist/archive-zip-preflight.js +0 -1
- package/dist/archive.d.ts +6 -3
- package/dist/archive.js +5 -3
- package/dist/directory-guard.d.ts +0 -1
- package/dist/directory-guard.js +0 -3
- package/dist/directory-mode-node.d.ts +20 -2
- package/dist/directory-mode-node.js +77 -1
- package/dist/file-lock-sync-acquisition.js +1 -1
- package/dist/file-lock-sync-root-held.d.ts +4 -11
- package/dist/file-lock-sync-root-io.d.ts +1 -4
- package/dist/file-lock-sync-root-options.d.ts +2 -10
- 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.js +19 -21
- package/dist/guest-native-python.js +23 -31
- package/dist/guest.js +21 -12
- package/dist/json-durable-queue-ownership.d.ts +1 -5
- package/dist/json-durable-queue.d.ts +0 -1
- package/dist/json-durable-queue.js +2 -7
- package/dist/local-file-descriptor.d.ts +2 -5
- package/dist/local-roots.d.ts +2 -7
- package/dist/native-binding.d.ts +12 -13
- package/dist/permission-exec.d.ts +6 -0
- package/dist/permissions-public.d.ts +2 -1
- package/dist/permissions-windows.d.ts +3 -6
- package/dist/permissions.d.ts +3 -10
- package/dist/permissions.js +0 -1
- package/dist/pinned-mutation-shared-route.d.ts +2 -8
- package/dist/pinned-open.d.ts +1 -0
- package/dist/pinned-open.js +1 -1
- package/dist/replace-directory.js +5 -17
- package/dist/replace-file-copy-fallback.d.ts +1 -8
- package/dist/replace-file-descriptor.d.ts +3 -11
- package/dist/replace-file-descriptor.js +1 -1
- package/dist/retained-directory-replacement.d.ts +1 -0
- package/dist/retained-directory-replacement.js +1 -1
- package/dist/root-context.js +3 -2
- package/dist/root-file-final-admission.d.ts +1 -1
- package/dist/root-file-final-admission.js +1 -6
- package/dist/root-file.js +2 -5
- package/dist/root-move-noreplace.d.ts +2 -7
- 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 -4
- package/dist/root-write-complete-parent.d.ts +2 -0
- package/dist/root-write-complete-parent.js +1 -1
- package/dist/secret-file.d.ts +6 -2
- package/dist/secret-file.js +12 -16
- package/dist/secret-read-policy.js +1 -1
- package/dist/secure-file-windows.js +5 -14
- package/dist/secure-temp-dir.js +2 -7
- package/dist/sidecar-lock-acquire.js +1 -1
- 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-reclaim.js +5 -6
- package/dist/sidecar-lock-stale-admission.d.ts +1 -5
- package/dist/stat-observation.js +3 -10
- package/dist/strict-file-identity.d.ts +2 -0
- package/dist/strict-file-identity.js +6 -6
- package/dist/temp-target.js +3 -8
- package/dist/temp-workspace-admission.js +6 -12
- package/dist/temp-workspace-child-admission.js +2 -8
- package/dist/temp-workspace-descriptor.js +1 -2
- package/dist/test-hooks.d.ts +1 -1
- package/dist/windows-owner.d.ts +3 -6
- package/dist/windows-security-bridge.cs +0 -2
- package/dist/windows-security-command.js +1 -4
- package/dist/windows-security-facts.d.ts +1 -0
- package/dist/windows-security-facts.js +2 -2
- package/docs/archive.md +2 -0
- package/docs/copy.md +2 -0
- package/docs/file-store.md +5 -0
- package/docs/guest.md +7 -1
- package/docs/native-helper.md +6 -0
- package/docs/root.md +10 -1
- package/docs/secret-file.md +19 -0
- package/docs/sidecar-lock.md +2 -0
- package/docs/walk.md +12 -0
- package/docs/writing.md +11 -0
- package/package.json +8 -8
- package/dist/directory-mode-owner.d.ts +0 -20
- package/dist/directory-mode-owner.js +0 -78
package/dist/temp-target.js
CHANGED
|
@@ -6,6 +6,7 @@ import { suffixWindowsReservedDeviceName } from "./filename.js";
|
|
|
6
6
|
import { sameFileIdentityForCleanup } from "./file-identity.js";
|
|
7
7
|
import { assertSafePathSegment, sanitizeSafePathSegment, trimHyphenEdges } from "./safe-path-segment.js";
|
|
8
8
|
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
9
|
+
import { hasNodeErrorCode } from "./path.js";
|
|
9
10
|
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
10
11
|
import { assertNoWindowsPathAlias, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
|
|
11
12
|
const HYPHEN_CHAR_CODE = 0x2d;
|
|
@@ -76,12 +77,6 @@ export function buildRandomTempFilePath(params) {
|
|
|
76
77
|
assertNoWindowsPathAlias(filePath, "filesystem", "temp file path uses a Windows filesystem namespace alias");
|
|
77
78
|
return filePath;
|
|
78
79
|
}
|
|
79
|
-
function isNodeErrorWithCode(err, code) {
|
|
80
|
-
return (typeof err === "object" &&
|
|
81
|
-
err !== null &&
|
|
82
|
-
"code" in err &&
|
|
83
|
-
err.code === code);
|
|
84
|
-
}
|
|
85
80
|
async function cleanupTempDir(dir, identity, onCleanupError) {
|
|
86
81
|
try {
|
|
87
82
|
const current = fsSync.lstatSync(dir, { bigint: true });
|
|
@@ -91,7 +86,7 @@ async function cleanupTempDir(dir, identity, onCleanupError) {
|
|
|
91
86
|
await fs.rm(dir, { recursive: true, force: true });
|
|
92
87
|
}
|
|
93
88
|
catch (err) {
|
|
94
|
-
if (!
|
|
89
|
+
if (!hasNodeErrorCode(err, "ENOENT")) {
|
|
95
90
|
onCleanupError?.(err);
|
|
96
91
|
}
|
|
97
92
|
}
|
|
@@ -174,7 +169,7 @@ export async function createOwnedTempFile(params) {
|
|
|
174
169
|
await owner.cleanup();
|
|
175
170
|
}
|
|
176
171
|
catch (err) {
|
|
177
|
-
if (!
|
|
172
|
+
if (!hasNodeErrorCode(err, "ENOENT")) {
|
|
178
173
|
params.onCleanupError?.(err);
|
|
179
174
|
}
|
|
180
175
|
}
|
|
@@ -4,10 +4,9 @@ import path from "node:path";
|
|
|
4
4
|
import { inspectDirectoryIdentitySync, observeDirectoryIdentitySync, } from "./directory-guard.js";
|
|
5
5
|
import { admitTempWorkspaceChild, admitTempWorkspaceChildSync, assertTrustedTempWorkspaceDirectory, inspectTempWorkspaceDescriptorIdentitySync, projectTempWorkspaceNumericIdentity, TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY, } from "./temp-workspace-child-admission.js";
|
|
6
6
|
import { FsSafeError } from "./errors.js";
|
|
7
|
-
import { recordFileObservationFailure } from "./file-observation.js";
|
|
8
7
|
import { realpathSync } from "./realpath.js";
|
|
9
8
|
import { assertNoWindowsPathAlias, pathForWindowsFilesystem, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
|
|
10
|
-
import { inspectFileIdentitySync } from "./strict-file-identity.js";
|
|
9
|
+
import { fileIdentityMismatchError, inspectFileIdentitySync } from "./strict-file-identity.js";
|
|
11
10
|
const WINDOWS = process.platform === "win32";
|
|
12
11
|
function effectiveOwner() {
|
|
13
12
|
// Windows mode/uid fields do not describe ACL authority. The supplied root's
|
|
@@ -72,11 +71,6 @@ function assertCanonicalRoot(entry) {
|
|
|
72
71
|
throw new FsSafeError("path-mismatch", "temp workspace root ancestry changed");
|
|
73
72
|
}
|
|
74
73
|
}
|
|
75
|
-
function identityMismatch() {
|
|
76
|
-
const error = new FsSafeError("path-mismatch", "file identity changed or could not be verified");
|
|
77
|
-
recordFileObservationFailure(error, "identity");
|
|
78
|
-
throw error;
|
|
79
|
-
}
|
|
80
74
|
function inspectSnapshotIdentity(entry) {
|
|
81
75
|
const expected = entry.numericIdentity;
|
|
82
76
|
if (!expected) {
|
|
@@ -87,21 +81,21 @@ function inspectSnapshotIdentity(entry) {
|
|
|
87
81
|
const devKnown = Number.isSafeInteger(stat.dev) && stat.dev >= 0 && (!WINDOWS || stat.dev !== 0);
|
|
88
82
|
if (devKnown) {
|
|
89
83
|
if (stat.dev !== expected.dev)
|
|
90
|
-
|
|
84
|
+
throw fileIdentityMismatchError();
|
|
91
85
|
}
|
|
92
86
|
else if (WINDOWS)
|
|
93
87
|
requiresExactRetry = true;
|
|
94
88
|
else
|
|
95
|
-
|
|
89
|
+
throw fileIdentityMismatchError();
|
|
96
90
|
const inoKnown = Number.isSafeInteger(stat.ino) && stat.ino >= 0 && (!WINDOWS || stat.ino !== 0);
|
|
97
91
|
if (inoKnown) {
|
|
98
92
|
if (stat.ino !== expected.ino)
|
|
99
|
-
|
|
93
|
+
throw fileIdentityMismatchError();
|
|
100
94
|
}
|
|
101
95
|
else if (WINDOWS)
|
|
102
96
|
requiresExactRetry = true;
|
|
103
97
|
else
|
|
104
|
-
|
|
98
|
+
throw fileIdentityMismatchError();
|
|
105
99
|
if (!requiresExactRetry)
|
|
106
100
|
return stat;
|
|
107
101
|
// Read exact identity only once. The strict helper may re-check this constant
|
|
@@ -157,7 +151,7 @@ function canonicalAncestry(root) {
|
|
|
157
151
|
}
|
|
158
152
|
function exactIdentityMatches(current, expected) {
|
|
159
153
|
if (current.dev !== expected.dev || current.ino !== expected.ino)
|
|
160
|
-
|
|
154
|
+
throw fileIdentityMismatchError();
|
|
161
155
|
}
|
|
162
156
|
function associateTempWorkspaceRoot(entry, ownerUid, descriptorFd) {
|
|
163
157
|
const stat = inspectTempWorkspaceDescriptorIdentitySync(descriptorFd, entry.identity, entry.numericIdentity);
|
|
@@ -2,8 +2,7 @@ import fsSync, {} from "node:fs";
|
|
|
2
2
|
import { inspectDirectoryIdentitySync, observeDirectoryIdentitySync } from "./directory-guard.js";
|
|
3
3
|
import { pinNodeDirectoryForMode, pinNodeDirectoryForModeSync } from "./directory-mode-node.js";
|
|
4
4
|
import { FsSafeError } from "./errors.js";
|
|
5
|
-
import {
|
|
6
|
-
import { inspectFileIdentitySync } from "./strict-file-identity.js";
|
|
5
|
+
import { fileIdentityMismatchError, inspectFileIdentitySync } from "./strict-file-identity.js";
|
|
7
6
|
export const TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY = process.platform === "linux" || process.platform === "darwin";
|
|
8
7
|
export function projectTempWorkspaceNumericIdentity(identity) {
|
|
9
8
|
const dev = Number(identity.dev);
|
|
@@ -14,15 +13,10 @@ export function projectTempWorkspaceNumericIdentity(identity) {
|
|
|
14
13
|
}
|
|
15
14
|
return Object.freeze({ dev, ino });
|
|
16
15
|
}
|
|
17
|
-
function identityMismatch() {
|
|
18
|
-
const error = new FsSafeError("path-mismatch", "file identity changed or could not be verified");
|
|
19
|
-
recordFileObservationFailure(error, "identity");
|
|
20
|
-
throw error;
|
|
21
|
-
}
|
|
22
16
|
function inspectNumericIdentity(current, expected) {
|
|
23
17
|
if (!Number.isSafeInteger(current.dev) || current.dev < 0 || current.dev !== expected.dev ||
|
|
24
18
|
!Number.isSafeInteger(current.ino) || current.ino < 0 || current.ino !== expected.ino) {
|
|
25
|
-
|
|
19
|
+
throw fileIdentityMismatchError();
|
|
26
20
|
}
|
|
27
21
|
return current;
|
|
28
22
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fsSync from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
-
import { nodeDirectorySearchOnlyFlags } from "./directory-mode-node.js";
|
|
4
|
-
import { assertOwnedDirectory } from "./directory-mode-owner.js";
|
|
3
|
+
import { nodeDirectorySearchOnlyFlags, assertOwnedDirectory } from "./directory-mode-node.js";
|
|
5
4
|
import { FsSafeError } from "./errors.js";
|
|
6
5
|
import { assertNoWindowsPathAlias, pathForWindowsFilesystem, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
|
|
7
6
|
import { inspectFileIdentitySync } from "./strict-file-identity.js";
|
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/windows-owner.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type PermissionFailureFields } from "./permission-exec.js";
|
|
2
2
|
export type WindowsOwnerExec = (command: string, args: string[]) => Promise<{
|
|
3
3
|
stdout: string;
|
|
4
4
|
stderr: string;
|
|
5
5
|
}>;
|
|
6
|
-
export type WindowsOwnerSummary = {
|
|
6
|
+
export type WindowsOwnerSummary = Omit<PermissionFailureFields & {
|
|
7
7
|
sid?: string;
|
|
8
8
|
currentUserSid?: string;
|
|
9
9
|
daclPresent?: boolean;
|
|
@@ -11,10 +11,7 @@ export type WindowsOwnerSummary = {
|
|
|
11
11
|
aclError?: string;
|
|
12
12
|
remote?: boolean;
|
|
13
13
|
trusted?: boolean;
|
|
14
|
-
|
|
15
|
-
errorDetail?: PermissionCommandFailure;
|
|
16
|
-
errorCause?: unknown;
|
|
17
|
-
};
|
|
14
|
+
}, never>;
|
|
18
15
|
export type WindowsOwnerAce = {
|
|
19
16
|
sid: string;
|
|
20
17
|
mask: number;
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
// descriptor never passes through CommonSecurityDescriptor's ACE normalization.
|
|
3
3
|
using System;
|
|
4
4
|
using System.Collections.Generic;
|
|
5
|
-
using System.ComponentModel;
|
|
6
|
-
using System.IO;
|
|
7
5
|
using System.Runtime.InteropServices;
|
|
8
6
|
using System.Security.AccessControl;
|
|
9
7
|
using System.Security.Principal;
|
|
@@ -3,7 +3,7 @@ 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 { 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}$/;
|
|
@@ -50,9 +50,6 @@ function command(operation, params) {
|
|
|
50
50
|
},
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
|
-
function unverified(message, cause) {
|
|
54
|
-
throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
|
|
55
|
-
}
|
|
56
53
|
function record(value) {
|
|
57
54
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
58
55
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NativeWindowsSecurityFacts } from "./native-binding.js";
|
|
2
|
+
export declare function unverified(message: string, cause?: unknown): never;
|
|
2
3
|
/** Raw reporting retains unknown flag bits; secure admission validates them below. */
|
|
3
4
|
export declare function parseWindowsSecurityCommandFacts(value: unknown): NativeWindowsSecurityFacts;
|
|
4
5
|
/** 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);
|
package/docs/archive.md
CHANGED
|
@@ -238,6 +238,8 @@ directory paths. For example, `./pkg//state\cache/value` is presented as
|
|
|
238
238
|
`pkg/state/cache/value`, even with `stripComponents: 1`. Case and Unicode
|
|
239
239
|
spelling are preserved. Local PAX `path`, GNU long-name, and supported ZIP
|
|
240
240
|
Unicode Path names use the same canonicalization.
|
|
241
|
+
A leading `~` is a literal archive entry name and is never expanded to the
|
|
242
|
+
user's home directory during extraction, publication, or durability checks.
|
|
241
243
|
Callbacks follow physical archive order, including ZIP names that look like
|
|
242
244
|
integer object keys. The public ZIP loader's `files` object retains ordinary
|
|
243
245
|
JavaScript object enumeration and mutation behavior.
|
package/docs/copy.md
CHANGED
|
@@ -74,6 +74,8 @@ Native Windows byte copies can store large zero-filled chunks as sparse ranges w
|
|
|
74
74
|
|
|
75
75
|
The ReFS backend rejects files with alternate data streams and unsupported reparse-point types instead of silently losing their contents. Symbolic links and junctions are preserved.
|
|
76
76
|
|
|
77
|
+
Failed ReFS clones attempt to remove their partial output through the retained directory handles. On Windows versions that reject the ignore-readonly deletion flag, clone rollback retries without that flag so ordinary output can be removed. It never clears readonly attributes: readonly output can remain, and the original clone error includes the cleanup failure. Other processes retaining output handles can delay deletion beyond settlement; a failed call does not guarantee an absent destination.
|
|
78
|
+
|
|
77
79
|
XFS and ZFS preserve regular-file and directory modes, timestamps, extended attributes, and ACLs. They reject special files, symlink extended attributes, non-UTF-8 names, and directory nesting deeper than 128 levels. Hardlinked source files become independent reflinked files. Portable byte copying preserves file contents, empty directories, modes where supported, file and directory timestamps, and literal symbolic links; it does not promise ownership, ACL, extended-attribute, alternate-stream, or sparse-layout preservation. On Windows, byte copying rejects unresolved symbolic links because Node does not expose their file/directory link type; resolved links keep their literal target and source type. POSIX dangling links are preserved. Choose a copying policy that meets the caller's metadata requirements; automatic copying can select either path.
|
|
78
80
|
|
|
79
81
|
`readCloneFileMetadata(files)` asynchronously reads APFS data-stream identities and file metadata in one native batch. Results correspond to input order; missing or unsupported entries return `undefined`. The returned `CloneFileMetadata` includes clone ID, device/inode, size, mode, ownership, and timestamps. These are point-in-time observations, not authorization or proof that later reads remain unchanged. Consumers such as Git index adapters must validate their own content and timestamp invariants. The reader does not follow leaf symbolic links.
|
package/docs/file-store.md
CHANGED
|
@@ -101,6 +101,11 @@ such as `internal space/a b.txt` are accepted. On POSIX, colons elsewhere, such
|
|
|
101
101
|
as the timestamp in `logs/2026-08-02T10:30:00Z.log`, remain lexically valid;
|
|
102
102
|
Windows rejects that spelling as stream syntax.
|
|
103
103
|
|
|
104
|
+
Keys such as `~` and `~/state.json` name literal entries inside the store; they
|
|
105
|
+
do not expand the user's home directory. Reads, writes, removal, and pruning
|
|
106
|
+
all use that literal identity. The `Root` returned by `root()` retains its own
|
|
107
|
+
home-expansion behavior.
|
|
108
|
+
|
|
104
109
|
Validation retains each method's operation order. Async reads, `exists`, and
|
|
105
110
|
`remove` open the root first: if the root is missing, strict methods report
|
|
106
111
|
`not-found` and `readTextIfExists` / `readJsonIfExists` return `null`, even for an
|
package/docs/guest.md
CHANGED
|
@@ -119,7 +119,13 @@ publication failure preserves the existing destination and source link; ordinary
|
|
|
119
119
|
failure cleanup removes the staging directory.
|
|
120
120
|
|
|
121
121
|
Cross-device directory moves build a copy manifest and check it during source
|
|
122
|
-
cleanup.
|
|
122
|
+
cleanup. Directory creation keeps the source mode subject to the guest's umask;
|
|
123
|
+
mode `000` is not replaced with a default. A top-level mode-000 directory uses
|
|
124
|
+
owner-only staging until publication, then restores zero through its retained
|
|
125
|
+
descriptor. Reading a mode-000 source still requires sufficient OS privileges;
|
|
126
|
+
the guest does not change source permissions to gain access. A permission error
|
|
127
|
+
after publication preserves the source and published copy for reconciliation.
|
|
128
|
+
Source changes can leave the published destination and some or all
|
|
123
129
|
of the source. Regular-file and symlink move fallbacks unlink the source
|
|
124
130
|
pathname after publication; they do not perform the directory manifest's
|
|
125
131
|
identity checks. Directory cleanup also has check-to-unlink race windows.
|
package/docs/native-helper.md
CHANGED
|
@@ -107,6 +107,12 @@ normalization, and the decision to fall back.
|
|
|
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
|
|
|
110
|
+
Linux root lookups reject negative descriptor sentinels before borrowing a handle or resolving a relative path; they never substitute the process working directory for an admitted root. Public Root operations already supply retained, admitted handles.
|
|
111
|
+
|
|
112
|
+
On Linux and macOS, native asynchronous file-copy admission rejects negative source and parent descriptors before creating a stage, preserving source-before-parent error ordering. Callers must keep nonnegative source and parent descriptors open until the operation settles.
|
|
113
|
+
|
|
114
|
+
Low-level Unix query, hash, copy, clone, staging, and owned-tree cleanup calls also reject negative descriptors before using them, preserving each operation's path-validation, cancellation, and cleanup order. Descriptor-relative mutations never accept a working-directory sentinel as a retained capability. On modern macOS, beneath opens reject negative roots with `EBADF` before calling `openat`, rather than returning `EIO` after an OS failure or working-directory operation. This check does not establish the validity of arbitrary nonnegative integers: callers must supply live descriptors and retain them until synchronous calls return or asynchronous operations settle.
|
|
115
|
+
|
|
110
116
|
`replaceDirectoryAtomic()` requires `renameNoReplaceWithIdentity` before it
|
|
111
117
|
creates a missing target parent. On POSIX the dedicated entry point keeps the
|
|
112
118
|
existing pre-dispatch exact receipt fence but dispatches direct-child names
|
package/docs/root.md
CHANGED
|
@@ -24,7 +24,7 @@ type RootDefaults = {
|
|
|
24
24
|
denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
|
|
25
25
|
maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
|
|
26
26
|
mkdir?: boolean; // create missing parent dirs on write/openWritable/append; default true
|
|
27
|
-
mode?: number; // file mode
|
|
27
|
+
mode?: number; // requested file mode; per-call override available
|
|
28
28
|
nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
|
|
29
29
|
renameIdentity?: "strict" | "verify-content-with-lock"; // default "strict"
|
|
30
30
|
symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root"; // read policy
|
|
@@ -117,6 +117,10 @@ created through a directory symlink or Windows junction. An absolute path
|
|
|
117
117
|
outside the root is still rejected. On Windows, alternate casing is accepted
|
|
118
118
|
only when the differently cased Root prefix has the Root's exact directory
|
|
119
119
|
identity; the operation then continues under the trusted Root spelling.
|
|
120
|
+
Absolute paths keep literal `~` components: `readAbsolute("/srv/root/~/file")`
|
|
121
|
+
reads that entry under the root, without expanding the user's home directory.
|
|
122
|
+
Relative `~/file` inputs still expand the home directory and must remain inside
|
|
123
|
+
the Root; use `./~/file` for a literal relative `~` directory.
|
|
120
124
|
|
|
121
125
|
### Writes
|
|
122
126
|
|
|
@@ -153,6 +157,11 @@ await fs.create("private-data/credential", "synthetic credential", { private: tr
|
|
|
153
157
|
|
|
154
158
|
`write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
|
|
155
159
|
|
|
160
|
+
For `append` and `openWritable`, `mode` only affects new-file creation: POSIX
|
|
161
|
+
permissions remain subject to the process umask. These methods do not chmod
|
|
162
|
+
existing files. Replacement and create-only writes apply their final mode
|
|
163
|
+
through the retained descriptor; see [Writing](writing.md#write-options).
|
|
164
|
+
|
|
156
165
|
Buffered `create` and `createJson` also accept `atomic?: boolean`. With `true`,
|
|
157
166
|
complete content is staged before exclusive publication even in native-off mode;
|
|
158
167
|
the fallback requires hardlinks. Omitted or `false` keeps the existing buffered
|
package/docs/secret-file.md
CHANGED
|
@@ -113,6 +113,10 @@ If an already validated descriptor fails while reading, both readers throw an
|
|
|
113
113
|
operational `FsSafeError` with `code: "read-failed"`; inspect `cause` for the
|
|
114
114
|
underlying Node filesystem code such as `EIO`.
|
|
115
115
|
|
|
116
|
+
Caught `null` or `undefined` inspection and read failures are reported as
|
|
117
|
+
structured errors with an `Error` cause carrying `"null"` or `"undefined"`,
|
|
118
|
+
instead of an internal `TypeError` while inspecting the thrown value.
|
|
119
|
+
|
|
116
120
|
A synchronous reader closes its descriptor once. A close failure preserves an
|
|
117
121
|
earlier read or identity-validation error; after a successful read, the close
|
|
118
122
|
failure is reported before trimming or rejecting empty content.
|
|
@@ -137,6 +141,11 @@ startWebhookVerifier(signingKey);
|
|
|
137
141
|
|
|
138
142
|
Async. Creates the parent directory at `dirMode` (default `0o700`) if missing, writes content to a sibling temp file, finalizes `mode` (default `0o600`) through an owned descriptor after content writes, and atomically renames over the destination. Publication verification checks the final file identity and mode.
|
|
139
143
|
|
|
144
|
+
Both secret writers capture top-level parameter values when called, before
|
|
145
|
+
asynchronous filesystem preparation; a supplied byte buffer is captured by
|
|
146
|
+
reference. A parameter getter throwing `null` or `undefined` rejects with that
|
|
147
|
+
same value before filesystem inspection.
|
|
148
|
+
|
|
140
149
|
On POSIX, both native and JavaScript writers verify actual `0o600` permission
|
|
141
150
|
bits through the retained descriptor before writing content. A filesystem that
|
|
142
151
|
reports successful chmod without enforcing those bits fails with
|
|
@@ -219,6 +228,15 @@ if anything already occupies the target path it throws
|
|
|
219
228
|
`FsSafeError("secret-exists")` without modifying that entry. Use the distinct
|
|
220
229
|
name when first-writer-wins is part of the credential protocol.
|
|
221
230
|
|
|
231
|
+
Its `durable` option also accepts `"file"`, matching `Root.create()`. This
|
|
232
|
+
requires every file `fsync` to succeed, including on `EPERM`, while parent-directory
|
|
233
|
+
synchronization remains best effort. The default and boolean options retain their
|
|
234
|
+
existing behavior. `writeSecretFileAtomic()` continues to accept boolean durability.
|
|
235
|
+
|
|
236
|
+
Strict file synchronization preserves the existing publication strategy and
|
|
237
|
+
identity-checked cleanup. A failed file flush before staged publication prevents
|
|
238
|
+
publication; a failure after publication can leave the complete file present.
|
|
239
|
+
|
|
222
240
|
Distinct leaves can share missing-parent creation without a `secret-exists`
|
|
223
241
|
error. Concurrent creates at the same leaf still have exactly one winner;
|
|
224
242
|
the loser receives `secret-exists` and leaves the winner's bytes intact.
|
|
@@ -235,6 +253,7 @@ try {
|
|
|
235
253
|
rootDir: "/var/lib/app/credentials",
|
|
236
254
|
filePath: "/var/lib/app/credentials/provider.refresh-token",
|
|
237
255
|
content: refreshToken,
|
|
256
|
+
durable: "file",
|
|
238
257
|
});
|
|
239
258
|
} catch (error) {
|
|
240
259
|
if (!(error instanceof FsSafeError) || error.code !== "secret-exists") throw error;
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -267,6 +267,8 @@ Pass `lockRoot` to place sidecar create, read, verification, and removal behind
|
|
|
267
267
|
an existing `Root` capability. `lockPath` must resolve inside that root.
|
|
268
268
|
Identity-conditioned removal remains the only release and reclaim deletion
|
|
269
269
|
path.
|
|
270
|
+
Root mutation refusals during stale removal propagate unchanged, including
|
|
271
|
+
`null`, `undefined`, and errors carrying `ENOENT`; they do not become missing-sidecar retries.
|
|
270
272
|
|
|
271
273
|
Async Root-backed acquisition normalizes the target's parent without creating
|
|
272
274
|
it, checking the retained Root before and after normalization. A deleted or
|
package/docs/walk.md
CHANGED
|
@@ -113,6 +113,18 @@ typed `FsSafeError("too-large")` instead.
|
|
|
113
113
|
|
|
114
114
|
For followed symlinks, both `kind` and `size` describe the resolved target.
|
|
115
115
|
|
|
116
|
+
The caller's starting path retains Root home shorthand: `~` and `~/dir` expand
|
|
117
|
+
the home directory when iteration starts and must resolve inside the Root.
|
|
118
|
+
Home-started walks report actual Root-relative paths, such as `home/dir/file`,
|
|
119
|
+
rather than `~/dir/file`. Use `./~/dir` to start at a literal `~` directory.
|
|
120
|
+
An alias within a home-started path is reported under its admitted canonical
|
|
121
|
+
target; ordinary non-home starting aliases retain their caller-supplied spelling.
|
|
122
|
+
|
|
123
|
+
Entry names remain literal filesystem data, including a directory named `~`
|
|
124
|
+
and its descendants. To reuse an entry path in another Root method without
|
|
125
|
+
home-directory expansion, prefix it with `./`, as in
|
|
126
|
+
`capability.open("./" + entry.relativePath)`.
|
|
127
|
+
|
|
116
128
|
The default `order: "sorted"` visits each directory's names in lexicographic
|
|
117
129
|
order before descending depth first. It reads and sorts all names in each
|
|
118
130
|
visited directory. With `maxEntries`, it prepares small metadata batches capped
|
package/docs/writing.md
CHANGED
|
@@ -301,6 +301,12 @@ type RootWriteJsonOptions = RootWriteOptions & {
|
|
|
301
301
|
|
|
302
302
|
Open in append mode, write, sync the file handle, and close. Honors `mkdir` for the parent directory and syncs the parent directory when the append creates the file. `durable: false` skips both syncs. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
|
|
303
303
|
|
|
304
|
+
`mode` selects the creation mode, defaulting to `0o600` when neither the call nor
|
|
305
|
+
the Root supplies it. On POSIX, the process umask can further restrict that mode;
|
|
306
|
+
for example, `mode: 0o640` with umask `0o077` creates a `0o600` file. Existing
|
|
307
|
+
files are not chmodded, even when an explicit `mode` is supplied. Empty appends
|
|
308
|
+
use the same creation rules.
|
|
309
|
+
|
|
304
310
|
```ts
|
|
305
311
|
await fs.append("logs/today.log", `[${ts}] ${line}\n`);
|
|
306
312
|
await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });
|
|
@@ -522,6 +528,11 @@ destination — there is no atomic-rename step. For exclusive publication of a
|
|
|
522
528
|
complete stream, use [`create()`](#streamed-creation). For streamed replacement,
|
|
523
529
|
the [`atomic`](atomic.md) helpers provide a staged writer.
|
|
524
530
|
|
|
531
|
+
For all three write modes, `mode` only selects new-file creation permissions,
|
|
532
|
+
defaulting to `0o600` when neither the call nor the Root supplies it. POSIX
|
|
533
|
+
permissions remain subject to the process umask; existing files are not chmodded.
|
|
534
|
+
The returned numeric `stat` records the admitted descriptor before caller writes.
|
|
535
|
+
|
|
525
536
|
On POSIX, existing-target opens use `O_NONBLOCK` as an admission safeguard so
|
|
526
537
|
a no-reader FIFO cannot stall regular-file validation. This does not change
|
|
527
538
|
ordinary regular-file write semantics. `replace` and `update` remain write-only
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"filesystem",
|
|
@@ -169,13 +169,13 @@
|
|
|
169
169
|
"archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
|
|
170
170
|
},
|
|
171
171
|
"optionalDependencies": {
|
|
172
|
-
"@openclaw/fs-safe-darwin-arm64": "0.
|
|
173
|
-
"@openclaw/fs-safe-darwin-x64": "0.
|
|
174
|
-
"@openclaw/fs-safe-linux-arm64-gnu": "0.
|
|
175
|
-
"@openclaw/fs-safe-linux-arm64-musl": "0.
|
|
176
|
-
"@openclaw/fs-safe-linux-x64-gnu": "0.
|
|
177
|
-
"@openclaw/fs-safe-linux-x64-musl": "0.
|
|
178
|
-
"@openclaw/fs-safe-win32-x64-msvc": "0.
|
|
172
|
+
"@openclaw/fs-safe-darwin-arm64": "0.19.0",
|
|
173
|
+
"@openclaw/fs-safe-darwin-x64": "0.19.0",
|
|
174
|
+
"@openclaw/fs-safe-linux-arm64-gnu": "0.19.0",
|
|
175
|
+
"@openclaw/fs-safe-linux-arm64-musl": "0.19.0",
|
|
176
|
+
"@openclaw/fs-safe-linux-x64-gnu": "0.19.0",
|
|
177
|
+
"@openclaw/fs-safe-linux-x64-musl": "0.19.0",
|
|
178
|
+
"@openclaw/fs-safe-win32-x64-msvc": "0.19.0",
|
|
179
179
|
"jszip": "^3.10.2"
|
|
180
180
|
},
|
|
181
181
|
"devDependencies": {
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import type { BigIntStats, Stats } from "node:fs";
|
|
2
|
-
export type DirectoryModeChecks = {
|
|
3
|
-
check?: () => void;
|
|
4
|
-
beforeChmod?: () => Promise<void>;
|
|
5
|
-
};
|
|
6
|
-
export type DirectoryModeOwner = {
|
|
7
|
-
verify(check?: () => void): Promise<void>;
|
|
8
|
-
apply(mode: number, checks?: DirectoryModeChecks): Promise<void>;
|
|
9
|
-
close(): Promise<void>;
|
|
10
|
-
};
|
|
11
|
-
export declare function assertOwnedDirectory(expected: Stats | BigIntStats, actual: Stats | BigIntStats): void;
|
|
12
|
-
/** Serializes use and close: even a queued path-based fd operation retains its descriptor. */
|
|
13
|
-
export declare function ownDirectoryMode(params: {
|
|
14
|
-
inspect: () => Promise<number>;
|
|
15
|
-
chmod: (mode: number) => Promise<void>;
|
|
16
|
-
prepareChmod?: () => Promise<void>;
|
|
17
|
-
verifyChmod?: () => Promise<void>;
|
|
18
|
-
close: () => Promise<void>;
|
|
19
|
-
ignoreChmodError?: boolean;
|
|
20
|
-
}): DirectoryModeOwner;
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { FsSafeError } from "./errors.js";
|
|
2
|
-
import { sameFileIdentityForCleanup } from "./file-identity.js";
|
|
3
|
-
export function assertOwnedDirectory(expected, actual) {
|
|
4
|
-
if (actual.isSymbolicLink() || !actual.isDirectory()) {
|
|
5
|
-
throw new FsSafeError("not-file", "directory mode target must be a real directory");
|
|
6
|
-
}
|
|
7
|
-
if (!sameFileIdentityForCleanup(expected, actual)) {
|
|
8
|
-
throw new FsSafeError("path-mismatch", "directory changed before its mode could be applied");
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
/** Serializes use and close: even a queued path-based fd operation retains its descriptor. */
|
|
12
|
-
export function ownDirectoryMode(params) {
|
|
13
|
-
let pending = Promise.resolve();
|
|
14
|
-
let closing;
|
|
15
|
-
const enqueue = (run) => {
|
|
16
|
-
if (closing)
|
|
17
|
-
return Promise.reject(new FsSafeError("path-mismatch", "directory mode owner is closed"));
|
|
18
|
-
const operation = pending.then(run);
|
|
19
|
-
pending = operation.catch(() => undefined);
|
|
20
|
-
return operation;
|
|
21
|
-
};
|
|
22
|
-
return {
|
|
23
|
-
verify: (check) => enqueue(async () => {
|
|
24
|
-
check?.();
|
|
25
|
-
await params.inspect();
|
|
26
|
-
check?.();
|
|
27
|
-
}),
|
|
28
|
-
apply: (mode, checks = {}) => enqueue(async () => {
|
|
29
|
-
// inspect() reports permission bits only; chmod ignores file-type bits,
|
|
30
|
-
// so tolerate raw stat modes (e.g. S_IFDIR | 0o755) by masking up front.
|
|
31
|
-
mode &= 0o7777;
|
|
32
|
-
checks.check?.();
|
|
33
|
-
const currentMode = await params.inspect();
|
|
34
|
-
checks.check?.();
|
|
35
|
-
if (currentMode === mode && !checks.beforeChmod && !checks.check)
|
|
36
|
-
return;
|
|
37
|
-
if (currentMode !== mode) {
|
|
38
|
-
await params.prepareChmod?.();
|
|
39
|
-
checks.check?.();
|
|
40
|
-
}
|
|
41
|
-
await checks.beforeChmod?.();
|
|
42
|
-
checks.check?.();
|
|
43
|
-
// Hooks/ancestor checks can yield; recheck the original named association.
|
|
44
|
-
await params.inspect();
|
|
45
|
-
checks.check?.();
|
|
46
|
-
let dispatchDeadlineFailure;
|
|
47
|
-
if (currentMode !== mode) {
|
|
48
|
-
checks.check?.();
|
|
49
|
-
try {
|
|
50
|
-
await params.chmod(mode);
|
|
51
|
-
}
|
|
52
|
-
catch (error) {
|
|
53
|
-
if (!params.ignoreChmodError)
|
|
54
|
-
throw error;
|
|
55
|
-
}
|
|
56
|
-
// Expiry cannot release the fd while post-dispatch verification is pending.
|
|
57
|
-
try {
|
|
58
|
-
checks.check?.();
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
dispatchDeadlineFailure = { error };
|
|
62
|
-
}
|
|
63
|
-
await params.verifyChmod?.();
|
|
64
|
-
}
|
|
65
|
-
const finalMode = await params.inspect();
|
|
66
|
-
if (dispatchDeadlineFailure)
|
|
67
|
-
throw dispatchDeadlineFailure.error;
|
|
68
|
-
checks.check?.();
|
|
69
|
-
if (!params.ignoreChmodError && finalMode !== mode) {
|
|
70
|
-
throw new FsSafeError("path-mismatch", "directory final mode could not be verified");
|
|
71
|
-
}
|
|
72
|
-
}),
|
|
73
|
-
close() {
|
|
74
|
-
closing ??= pending.then(params.close);
|
|
75
|
-
return closing;
|
|
76
|
-
},
|
|
77
|
-
};
|
|
78
|
-
}
|