@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/native-binding.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TarMeterLimits } from "./archive-limits.js";
|
|
2
2
|
import type { ArchiveMemberKind } from "./archive-plan.js";
|
|
3
3
|
import type { CopyCloneMode } from "./copy-policy.js";
|
|
4
|
+
import type { WindowsAceFlags } from "./owner-dacl.js";
|
|
4
5
|
export interface NativeFileHash {
|
|
5
6
|
bytes: number;
|
|
6
7
|
digest: string;
|
|
@@ -58,16 +59,7 @@ export interface NativeWindowsAccessControlEntry {
|
|
|
58
59
|
sid: string;
|
|
59
60
|
mask: number;
|
|
60
61
|
aceType: string;
|
|
61
|
-
flags:
|
|
62
|
-
raw: number;
|
|
63
|
-
objectInherit: boolean;
|
|
64
|
-
containerInherit: boolean;
|
|
65
|
-
noPropagateInherit: boolean;
|
|
66
|
-
inheritOnly: boolean;
|
|
67
|
-
inherited: boolean;
|
|
68
|
-
successfulAccess: boolean;
|
|
69
|
-
failedAccess: boolean;
|
|
70
|
-
};
|
|
62
|
+
flags: WindowsAceFlags;
|
|
71
63
|
}
|
|
72
64
|
export interface NativeWindowsSecurityFacts {
|
|
73
65
|
ownerSid: string;
|
|
@@ -96,6 +88,12 @@ export interface NativeWindowsDirectoryReceipt {
|
|
|
96
88
|
export interface NativeDarwinAclFacts {
|
|
97
89
|
state: "absent" | "empty" | "present";
|
|
98
90
|
}
|
|
91
|
+
type NativeTwoPathArgs = [
|
|
92
|
+
sourceRootFd: number,
|
|
93
|
+
sourceRelPath: string,
|
|
94
|
+
targetRootFd: number,
|
|
95
|
+
targetRelPath: string
|
|
96
|
+
];
|
|
99
97
|
export interface NativeBinding {
|
|
100
98
|
/** Internal: consumes only a descriptor returned by this binding. */
|
|
101
99
|
closeOwnedFd(fd: number): void;
|
|
@@ -125,6 +123,11 @@ export interface NativeBinding {
|
|
|
125
123
|
readCloneFileMetadata(paths: string[]): Promise<(Buffer | null)[]>;
|
|
126
124
|
probeTreeClone(parentFd: number): "apfs" | "btrfs" | "refs" | "xfs" | "zfs" | null;
|
|
127
125
|
cloneTree(sourceFd: number | null, parentFd: number, basename: string, concurrency: number, signal?: AbortSignal): Promise<void>;
|
|
126
|
+
openStagedSymlink?(parentFd: number, basename: string): number;
|
|
127
|
+
stagedSymlinkTarget?(parentFd: number, basename: string, linkFd: number): string;
|
|
128
|
+
stagedSymlinkMatches?(parentFd: number, basename: string, linkFd: number): boolean;
|
|
129
|
+
publishStagedSymlink?(parentFd: number, basename: string, linkFd: number, destination: string): void;
|
|
130
|
+
removeStagedSymlink?(parentFd: number, basename: string, linkFd: number): "removed" | "name-absent" | "preserved";
|
|
128
131
|
createStagedFile?(parentFd: number, basename: string): number;
|
|
129
132
|
stagedFileMatches?(parentFd: number, basename: string, fileFd: number): boolean;
|
|
130
133
|
removeStagedFile?(parentFd: number, basename: string, fileFd: number): "removed" | "name-absent" | "preserved";
|
|
@@ -141,7 +144,7 @@ export interface NativeBinding {
|
|
|
141
144
|
extractArchiveNative(path: string, kind: string, rootFd: number, plan: NativeArchivePlanEntry[], limits: TarMeterLimits, signal: AbortSignal): Promise<void>;
|
|
142
145
|
fstatIdentity(fd: number): NativeFileIdentity;
|
|
143
146
|
inspectArchiveNative(path: string, kind: string, limits: TarMeterLimits, signal: AbortSignal): Promise<NativeArchiveEntry[]>;
|
|
144
|
-
linkBeneath(
|
|
147
|
+
linkBeneath(...args: NativeTwoPathArgs): void;
|
|
145
148
|
/** Direct-child mkdir; true is receipt provenance only, never cleanup ownership. */
|
|
146
149
|
mkdirChildBeneath?(parentFd: number, basename: string, mode: number): boolean;
|
|
147
150
|
mkdirBeneath(rootFd: number, relPath: string, mode: number): void;
|
|
@@ -153,10 +156,11 @@ export interface NativeBinding {
|
|
|
153
156
|
ownedTreeRemovalAvailable?(parentFd: number): boolean;
|
|
154
157
|
removeOwnedTree?(parentFd: number, basename: string, directoryFd: number): Promise<NativeOwnedTreeRemovalResult>;
|
|
155
158
|
removeOwnedTreeSync?(parentFd: number, basename: string, directoryFd: number): NativeOwnedTreeRemovalResult;
|
|
156
|
-
renameNoReplace(
|
|
159
|
+
renameNoReplace(...args: NativeTwoPathArgs): void;
|
|
157
160
|
/** Identity-fenced retained-directory rename capability. */
|
|
158
|
-
renameNoReplaceWithIdentity?(
|
|
159
|
-
renameReplace(
|
|
161
|
+
renameNoReplaceWithIdentity?(...args: [...paths: NativeTwoPathArgs, expectedSourceDev: bigint, expectedSourceIno: bigint]): void;
|
|
162
|
+
renameReplace(...args: NativeTwoPathArgs): void;
|
|
160
163
|
sha256File(fd: number, maxBytes?: number, signal?: AbortSignal): Promise<NativeFileHash>;
|
|
161
164
|
}
|
|
162
165
|
export declare function captureNativeFdClose(binding: NativeBinding): (fd: number) => void;
|
|
166
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type BigIntStats, type Stats } from "node:fs";
|
|
2
|
+
import type { DirectoryReceipt } from "./directory-durability.js";
|
|
3
|
+
import type { StagedSymlink, StagedSymlinkExpected } from "./staged-symlink-types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Admit an existing staged symlink against caller-captured identity and retain
|
|
6
|
+
* its no-follow descriptor. Admission never creates, adopts by target, or unlinks.
|
|
7
|
+
*/
|
|
8
|
+
export declare function retainSymlinkInDirectory(options: {
|
|
9
|
+
directory: string | DirectoryReceipt<Stats | BigIntStats>;
|
|
10
|
+
basename: string;
|
|
11
|
+
expected: StagedSymlinkExpected;
|
|
12
|
+
assertBeforeMutation: () => void;
|
|
13
|
+
}): Promise<StagedSymlink>;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import fs, {} from "node:fs";
|
|
2
|
+
import { FsSafeError } from "./errors.js";
|
|
3
|
+
import { assertSynchronousCallbackResult } from "./mutation-authority.js";
|
|
4
|
+
import { captureNativeFdClose } from "./native-binding.js";
|
|
5
|
+
import { requireNativeBinding } from "./native.js";
|
|
6
|
+
import { classifyNativeRenameFailure } from "./native-rename-outcome.js";
|
|
7
|
+
import { assertStagedDirectoryCurrent, openStagedDirectory } from "./staged-directory.js";
|
|
8
|
+
import { createStagedFileReceipt } from "./staged-file-settlement.js";
|
|
9
|
+
const NOT_PUBLISHED = Object.freeze({ status: "not-published" });
|
|
10
|
+
function basename(name) {
|
|
11
|
+
if (typeof name !== "string" || !name || name === "." || name === ".." ||
|
|
12
|
+
/[/\\:\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(name)) {
|
|
13
|
+
throw new FsSafeError("invalid-path", "staged symlinks require a direct-child basename");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function failure(error, details) {
|
|
17
|
+
let code = "helper-failed";
|
|
18
|
+
try {
|
|
19
|
+
code = error instanceof FsSafeError ? error.code
|
|
20
|
+
: error?.code === "EEXIST" ? "already-exists" : "helper-failed";
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Uninspectable error metadata must not interrupt terminal settlement.
|
|
24
|
+
}
|
|
25
|
+
return new FsSafeError(code, `staged symlink ${details.phase} failed`, { cause: error, details });
|
|
26
|
+
}
|
|
27
|
+
function assertAuthority(assertion) {
|
|
28
|
+
assertSynchronousCallbackResult(assertion(), "assertBeforeMutation");
|
|
29
|
+
}
|
|
30
|
+
function expectedIdentity(stat, expected) {
|
|
31
|
+
return stat.isSymbolicLink() && stat.nlink === 1n &&
|
|
32
|
+
stat.dev === expected.dev && stat.ino === expected.ino &&
|
|
33
|
+
stat.uid === BigInt(expected.uid) && stat.gid === BigInt(expected.gid) &&
|
|
34
|
+
stat.ctimeNs === expected.ctimeNs;
|
|
35
|
+
}
|
|
36
|
+
class NativeStagedSymlink {
|
|
37
|
+
#publication = NOT_PUBLISHED;
|
|
38
|
+
#closed;
|
|
39
|
+
#removal;
|
|
40
|
+
#busy = false;
|
|
41
|
+
#receipt;
|
|
42
|
+
#binding;
|
|
43
|
+
#parentFd;
|
|
44
|
+
#linkFd;
|
|
45
|
+
#closeLink;
|
|
46
|
+
#assertion;
|
|
47
|
+
constructor(receipt, binding, parentFd, linkFd, closeLink, assertion) {
|
|
48
|
+
this.#receipt = receipt;
|
|
49
|
+
this.#binding = binding;
|
|
50
|
+
this.#parentFd = parentFd;
|
|
51
|
+
this.#linkFd = linkFd;
|
|
52
|
+
this.#closeLink = closeLink;
|
|
53
|
+
this.#assertion = assertion;
|
|
54
|
+
}
|
|
55
|
+
get receipt() { return this.#receipt; }
|
|
56
|
+
#idle() {
|
|
57
|
+
if (this.#busy)
|
|
58
|
+
throw new FsSafeError("helper-failed", "reentrant symlink operation");
|
|
59
|
+
}
|
|
60
|
+
#open() {
|
|
61
|
+
if (this.#closed)
|
|
62
|
+
throw new FsSafeError("helper-failed", "staged symlink is closed");
|
|
63
|
+
}
|
|
64
|
+
#matches(name) {
|
|
65
|
+
if (!this.#binding.stagedSymlinkMatches(this.#parentFd, name, this.#linkFd))
|
|
66
|
+
return false;
|
|
67
|
+
const stat = fs.fstatSync(this.#linkFd, { bigint: true });
|
|
68
|
+
const identity = this.#receipt.identity;
|
|
69
|
+
// ctime changes on rename; identity, ownership, mode and target do not.
|
|
70
|
+
return stat.dev === identity.dev && stat.ino === identity.ino &&
|
|
71
|
+
stat.uid === BigInt(identity.uid) && stat.gid === BigInt(identity.gid) &&
|
|
72
|
+
Number(stat.mode & 4095n) === identity.mode && stat.nlink === 1n &&
|
|
73
|
+
this.#binding.stagedSymlinkTarget(this.#parentFd, name, this.#linkFd) === this.#receipt.target;
|
|
74
|
+
}
|
|
75
|
+
#assertNamed(name) {
|
|
76
|
+
if (!this.#matches(name))
|
|
77
|
+
throw new FsSafeError("path-mismatch", "symlink no longer names the retained object");
|
|
78
|
+
}
|
|
79
|
+
#assertCurrent() {
|
|
80
|
+
this.#open();
|
|
81
|
+
if (this.#publication.status !== "not-published") {
|
|
82
|
+
throw new FsSafeError("helper-failed", "symlink publication has already been attempted");
|
|
83
|
+
}
|
|
84
|
+
assertStagedDirectoryCurrent(this.#receipt.directory);
|
|
85
|
+
this.#assertNamed(this.#receipt.temporaryBasename);
|
|
86
|
+
}
|
|
87
|
+
async assertCurrent() { this.#idle(); this.#assertCurrent(); }
|
|
88
|
+
async publish(name) {
|
|
89
|
+
this.#idle();
|
|
90
|
+
this.#busy = true;
|
|
91
|
+
try {
|
|
92
|
+
basename(name);
|
|
93
|
+
if (name === this.#receipt.temporaryBasename) {
|
|
94
|
+
throw new FsSafeError("invalid-path", "publication requires a distinct basename");
|
|
95
|
+
}
|
|
96
|
+
this.#assertCurrent();
|
|
97
|
+
assertAuthority(this.#assertion);
|
|
98
|
+
// A synchronous caller assertion can itself change either pathname.
|
|
99
|
+
this.#assertCurrent();
|
|
100
|
+
try {
|
|
101
|
+
this.#binding.publishStagedSymlink(this.#parentFd, this.#receipt.temporaryBasename, this.#linkFd, name);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
let outcome = "indeterminate";
|
|
105
|
+
try {
|
|
106
|
+
outcome = classifyNativeRenameFailure(error);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Unreadable diagnostics cannot prove that the rename was uncommitted.
|
|
110
|
+
}
|
|
111
|
+
if (outcome === "indeterminate") {
|
|
112
|
+
this.#publication = Object.freeze({ status: "indeterminate", basename: name, overwrite: false });
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const published = Object.freeze({
|
|
117
|
+
status: "published", staged: this.#receipt, basename: name, overwrite: false,
|
|
118
|
+
});
|
|
119
|
+
// Record dispatch success before any fallible post-observation.
|
|
120
|
+
this.#publication = published;
|
|
121
|
+
this.#assertNamed(name);
|
|
122
|
+
assertStagedDirectoryCurrent(this.#receipt.directory);
|
|
123
|
+
return published;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
throw failure(error, { phase: "publish", publication: this.#publication });
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
this.#busy = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
#publishedName() {
|
|
133
|
+
this.#open();
|
|
134
|
+
if (this.#publication.status !== "published" || this.#removal) {
|
|
135
|
+
throw new FsSafeError("helper-failed", "no unsettled published symlink");
|
|
136
|
+
}
|
|
137
|
+
return this.#publication.basename;
|
|
138
|
+
}
|
|
139
|
+
async assertPublished() {
|
|
140
|
+
this.#idle();
|
|
141
|
+
const name = this.#publishedName();
|
|
142
|
+
assertStagedDirectoryCurrent(this.#receipt.directory);
|
|
143
|
+
this.#assertNamed(name);
|
|
144
|
+
}
|
|
145
|
+
#remove(name) {
|
|
146
|
+
try {
|
|
147
|
+
if (!this.#matches(name))
|
|
148
|
+
return "preserved";
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
let missing = false;
|
|
152
|
+
try {
|
|
153
|
+
missing = error?.code === "ENOENT";
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Even a failed errno inspection must retain the original cause.
|
|
157
|
+
}
|
|
158
|
+
if (missing)
|
|
159
|
+
return "name-absent";
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
assertAuthority(this.#assertion);
|
|
163
|
+
// Recheck after application code and immediately before guarded native unlink.
|
|
164
|
+
if (!this.#matches(name))
|
|
165
|
+
return "preserved";
|
|
166
|
+
return this.#binding.removeStagedSymlink(this.#parentFd, name, this.#linkFd);
|
|
167
|
+
}
|
|
168
|
+
async removePublished() {
|
|
169
|
+
this.#idle();
|
|
170
|
+
this.#open();
|
|
171
|
+
if (this.#removal) {
|
|
172
|
+
if (this.#removal.error)
|
|
173
|
+
throw this.#removal.error;
|
|
174
|
+
return this.#removal.receipt;
|
|
175
|
+
}
|
|
176
|
+
const name = this.#publishedName();
|
|
177
|
+
this.#busy = true;
|
|
178
|
+
try {
|
|
179
|
+
const receipt = this.#remove(name);
|
|
180
|
+
this.#removal = { receipt };
|
|
181
|
+
return receipt;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
const wrapped = failure(error, { phase: "remove-published", publication: this.#publication });
|
|
185
|
+
// A failed unlink is not safe to retry automatically.
|
|
186
|
+
this.#removal = { error: wrapped };
|
|
187
|
+
throw wrapped;
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
this.#busy = false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async cleanup() {
|
|
194
|
+
this.#idle();
|
|
195
|
+
if (this.#closed) {
|
|
196
|
+
if (this.#closed.error)
|
|
197
|
+
throw this.#closed.error;
|
|
198
|
+
return this.#closed.receipt;
|
|
199
|
+
}
|
|
200
|
+
this.#busy = true;
|
|
201
|
+
let status = "not-needed";
|
|
202
|
+
const errors = [];
|
|
203
|
+
if (this.#publication.status === "indeterminate")
|
|
204
|
+
status = "preserved";
|
|
205
|
+
else if (this.#publication.status === "not-published") {
|
|
206
|
+
try {
|
|
207
|
+
status = this.#remove(this.#receipt.temporaryBasename);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
status = "failed";
|
|
211
|
+
errors.push(error);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
let resources = "closed";
|
|
215
|
+
for (const close of [() => this.#closeLink(this.#linkFd), () => fs.closeSync(this.#parentFd)]) {
|
|
216
|
+
try {
|
|
217
|
+
close();
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
resources = "close-failed";
|
|
221
|
+
errors.push(error);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const receipt = Object.freeze({
|
|
225
|
+
temporaryBasename: this.#receipt.temporaryBasename,
|
|
226
|
+
publication: this.#publication, status, resources,
|
|
227
|
+
});
|
|
228
|
+
const error = errors.length ? failure(errors.length === 1 ? errors[0] : new AggregateError(errors, "symlink settlement failed"), { phase: "cleanup", publication: this.#publication, cleanup: receipt }) : undefined;
|
|
229
|
+
this.#closed = { receipt, error };
|
|
230
|
+
this.#busy = false;
|
|
231
|
+
if (error)
|
|
232
|
+
throw error;
|
|
233
|
+
return receipt;
|
|
234
|
+
}
|
|
235
|
+
async [Symbol.asyncDispose]() {
|
|
236
|
+
const cleanup = await this.cleanup();
|
|
237
|
+
if (cleanup.status === "preserved") {
|
|
238
|
+
throw new FsSafeError("not-removable", "symlink settlement preserved an unverified entry", {
|
|
239
|
+
details: { phase: "cleanup", publication: this.#publication, cleanup },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Admit an existing staged symlink against caller-captured identity and retain
|
|
246
|
+
* its no-follow descriptor. Admission never creates, adopts by target, or unlinks.
|
|
247
|
+
*/
|
|
248
|
+
export async function retainSymlinkInDirectory(options) {
|
|
249
|
+
if (process.platform !== "linux" && process.platform !== "darwin") {
|
|
250
|
+
throw new FsSafeError("unsupported-platform", "retained symlinks require Linux or macOS");
|
|
251
|
+
}
|
|
252
|
+
const native = requireNativeBinding();
|
|
253
|
+
if ([
|
|
254
|
+
native.closeOwnedFd, native.openStagedSymlink, native.stagedSymlinkTarget,
|
|
255
|
+
native.stagedSymlinkMatches, native.publishStagedSymlink, native.removeStagedSymlink,
|
|
256
|
+
].some((fn) => typeof fn !== "function")) {
|
|
257
|
+
throw new FsSafeError("helper-unavailable", "native retained symlink support is unavailable");
|
|
258
|
+
}
|
|
259
|
+
const binding = native;
|
|
260
|
+
const name = options.basename;
|
|
261
|
+
basename(name);
|
|
262
|
+
const expected = Object.freeze({ ...options.expected });
|
|
263
|
+
const assertion = options.assertBeforeMutation;
|
|
264
|
+
if (typeof assertion !== "function" || typeof expected.dev !== "bigint" ||
|
|
265
|
+
typeof expected.ino !== "bigint" || typeof expected.ctimeNs !== "bigint" ||
|
|
266
|
+
!Number.isSafeInteger(expected.uid) || expected.uid < 0 ||
|
|
267
|
+
!Number.isSafeInteger(expected.gid) || expected.gid < 0 ||
|
|
268
|
+
typeof expected.target !== "string" || !expected.target || expected.target.includes("\0")) {
|
|
269
|
+
throw new FsSafeError("invalid-path", "exact symlink identity, target and synchronous authority are required");
|
|
270
|
+
}
|
|
271
|
+
const closeLink = captureNativeFdClose(binding);
|
|
272
|
+
const parent = openStagedDirectory(options.directory);
|
|
273
|
+
let fd;
|
|
274
|
+
try {
|
|
275
|
+
fd = binding.openStagedSymlink(parent.fd, name);
|
|
276
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
277
|
+
if (!expectedIdentity(stat, expected) ||
|
|
278
|
+
binding.stagedSymlinkTarget(parent.fd, name, fd) !== expected.target) {
|
|
279
|
+
throw new FsSafeError("path-mismatch", "staged symlink does not match the supplied identity");
|
|
280
|
+
}
|
|
281
|
+
const receipt = Object.freeze({
|
|
282
|
+
...createStagedFileReceipt(parent.receipt, name, stat), target: expected.target,
|
|
283
|
+
});
|
|
284
|
+
assertAuthority(assertion);
|
|
285
|
+
const owner = new NativeStagedSymlink(receipt, binding, parent.fd, fd, closeLink, assertion);
|
|
286
|
+
await owner.assertCurrent();
|
|
287
|
+
return owner;
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
const errors = [error];
|
|
291
|
+
for (const close of [() => { if (fd !== undefined)
|
|
292
|
+
closeLink(fd); }, () => fs.closeSync(parent.fd)]) {
|
|
293
|
+
try {
|
|
294
|
+
close();
|
|
295
|
+
}
|
|
296
|
+
catch (closeError) {
|
|
297
|
+
errors.push(closeError);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Until admission succeeds, the caller retains every namespace cleanup duty.
|
|
301
|
+
throw failure(errors.length === 1 ? error : new AggregateError(errors, "symlink admission and close failed"), { phase: "prepare", publication: NOT_PUBLISHED });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// One isolated process owns every native query in a batch, including stuck OS calls.
|
|
2
|
+
import { FsSafeError } from "./errors.js";
|
|
3
|
+
import { getNativeBinding } from "./native.js";
|
|
4
|
+
import { configureFsSafeNative } from "./native-config.js";
|
|
5
|
+
import { formatCaughtPermissionFailure } from "./permission-exec.js";
|
|
6
|
+
import { assertNoWindowsPathAlias } from "./windows-path-alias.js";
|
|
7
|
+
const MAX_INPUT_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
async function inspectBatch() {
|
|
9
|
+
const mode = process.env.FS_SAFE_OWNER_DACL_BATCH_MODE;
|
|
10
|
+
if (mode !== "auto" && mode !== "require") {
|
|
11
|
+
throw new FsSafeError("helper-unavailable", "isolated Windows native inspection was not enabled");
|
|
12
|
+
}
|
|
13
|
+
configureFsSafeNative({ mode });
|
|
14
|
+
const chunks = [];
|
|
15
|
+
let bytes = 0;
|
|
16
|
+
for await (const chunk of process.stdin) {
|
|
17
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
18
|
+
bytes += buffer.byteLength;
|
|
19
|
+
if (bytes > MAX_INPUT_BYTES)
|
|
20
|
+
throw new Error("Windows security batch exceeded its input budget");
|
|
21
|
+
chunks.push(buffer);
|
|
22
|
+
}
|
|
23
|
+
const paths = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
|
|
24
|
+
if (!Array.isArray(paths) || !paths.every(value => typeof value === "string" && value && !value.includes("\0"))) {
|
|
25
|
+
throw new Error("Windows security batch contains invalid paths");
|
|
26
|
+
}
|
|
27
|
+
for (const pathname of paths)
|
|
28
|
+
assertNoWindowsPathAlias(pathname);
|
|
29
|
+
const binding = getNativeBinding();
|
|
30
|
+
const inspect = binding?.readOwnerAndDacl;
|
|
31
|
+
if (!binding || typeof inspect !== "function") {
|
|
32
|
+
throw new FsSafeError("helper-unavailable", "isolated Windows native inspection is unavailable");
|
|
33
|
+
}
|
|
34
|
+
const result = [];
|
|
35
|
+
let outputBytes = Buffer.byteLength(JSON.stringify({ ok: true, result: [] }));
|
|
36
|
+
for (const pathname of paths) {
|
|
37
|
+
const row = { path: pathname, security: inspect.call(binding, pathname) };
|
|
38
|
+
outputBytes += Buffer.byteLength(JSON.stringify(row)) + (result.length ? 1 : 0);
|
|
39
|
+
if (outputBytes > MAX_INPUT_BYTES) {
|
|
40
|
+
throw new FsSafeError("too-large", "Windows security batch exceeded its output budget");
|
|
41
|
+
}
|
|
42
|
+
result.push(row);
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const result = await inspectBatch();
|
|
48
|
+
process.stdout.write(JSON.stringify({ ok: true, result }));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string"
|
|
52
|
+
? error.code : "EIO";
|
|
53
|
+
process.stdout.write(JSON.stringify({ ok: false, code, message: formatCaughtPermissionFailure(error) }));
|
|
54
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type OwnerAndDaclResult } from "./owner-dacl.js";
|
|
2
|
+
/** Inspect an ordered batch outside the caller's event loop, without applying trust policy. */
|
|
3
|
+
export declare function readOwnerAndDaclBatch(paths: readonly string[], options?: {
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
}): Promise<OwnerAndDaclResult[]>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { FsSafeError } from "./errors.js";
|
|
3
|
+
import { getNativeBinding } from "./native.js";
|
|
4
|
+
import { getFsSafeNativeConfig } from "./native-config.js";
|
|
5
|
+
import { warnNativeFallback } from "./native-fallback-warning.js";
|
|
6
|
+
import { projectOwnerAndDacl } from "./owner-dacl.js";
|
|
7
|
+
import { anchorWindowsDriveRelativePath, assertNoWindowsPathAlias } from "./windows-path-alias.js";
|
|
8
|
+
import { readWindowsSecurityFactsBatch, WINDOWS_SECURITY_BATCH_MAX_BYTES } from "./windows-security-command.js";
|
|
9
|
+
const DEFAULT_BATCH_TIMEOUT_MS = 60_000;
|
|
10
|
+
function snapshotPaths(paths) {
|
|
11
|
+
if (!Array.isArray(paths))
|
|
12
|
+
throw new TypeError("paths must be an array of path strings");
|
|
13
|
+
const length = paths.length;
|
|
14
|
+
const selected = [];
|
|
15
|
+
let bytes = 2;
|
|
16
|
+
for (let index = 0; index < length; index += 1) {
|
|
17
|
+
const value = paths[index];
|
|
18
|
+
if (!Object.hasOwn(paths, index) || typeof value !== "string" || !value || value.includes("\0")) {
|
|
19
|
+
throw new TypeError("paths must contain nonempty path strings without null bytes");
|
|
20
|
+
}
|
|
21
|
+
let captured = value;
|
|
22
|
+
if (process.platform === "win32") {
|
|
23
|
+
captured = anchorWindowsDriveRelativePath(value);
|
|
24
|
+
assertNoWindowsPathAlias(captured, "filesystem", "owner and DACL path uses a Windows filesystem namespace alias");
|
|
25
|
+
const root = path.win32.parse(captured).root;
|
|
26
|
+
// Anchor cwd-dependent paths without normalizing their physical . or .. suffix.
|
|
27
|
+
if (root.length <= 1 || !path.win32.isAbsolute(captured)) {
|
|
28
|
+
const base = path.win32.resolve(root || ".");
|
|
29
|
+
captured = `${base}${base.endsWith("\\") ? "" : "\\"}${captured.slice(root.length)}`;
|
|
30
|
+
}
|
|
31
|
+
assertNoWindowsPathAlias(captured, "filesystem", "owner and DACL path uses a Windows filesystem namespace alias");
|
|
32
|
+
}
|
|
33
|
+
bytes += Buffer.byteLength(JSON.stringify(captured), "utf8") + (index ? 1 : 0);
|
|
34
|
+
if (bytes > WINDOWS_SECURITY_BATCH_MAX_BYTES) {
|
|
35
|
+
throw new FsSafeError("too-large", "owner and DACL batch exceeds its input budget");
|
|
36
|
+
}
|
|
37
|
+
selected.push(captured);
|
|
38
|
+
}
|
|
39
|
+
return selected;
|
|
40
|
+
}
|
|
41
|
+
/** Inspect an ordered batch outside the caller's event loop, without applying trust policy. */
|
|
42
|
+
export async function readOwnerAndDaclBatch(paths, options = {}) {
|
|
43
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_BATCH_TIMEOUT_MS;
|
|
44
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
|
|
45
|
+
throw new RangeError("timeoutMs must be a positive integer no greater than 2147483647");
|
|
46
|
+
}
|
|
47
|
+
const selected = snapshotPaths(paths);
|
|
48
|
+
if (process.platform !== "win32") {
|
|
49
|
+
return selected.map(() => ({ status: "unsupported-platform", platform: process.platform }));
|
|
50
|
+
}
|
|
51
|
+
if (selected.length === 0)
|
|
52
|
+
return [];
|
|
53
|
+
const config = getFsSafeNativeConfig();
|
|
54
|
+
const native = getNativeBinding();
|
|
55
|
+
const useNative = typeof native?.readOwnerAndDacl === "function";
|
|
56
|
+
if (!useNative) {
|
|
57
|
+
if (config.mode === "require") {
|
|
58
|
+
throw new FsSafeError("helper-unavailable", "Windows owner and DACL facts require an up-to-date native helper");
|
|
59
|
+
}
|
|
60
|
+
warnNativeFallback("windows-owner-dacl", "Windows owner and DACL inspection uses a slower built-in system command.");
|
|
61
|
+
}
|
|
62
|
+
const facts = await readWindowsSecurityFactsBatch(selected, { timeoutMs, native: useNative, mode: config.mode });
|
|
63
|
+
return facts.map(projectOwnerAndDacl);
|
|
64
|
+
}
|
package/dist/owner-dacl.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DescriptorFacts } from "./windows-security-facts.js";
|
|
1
2
|
export type WindowsAceFlags = {
|
|
2
3
|
raw: number;
|
|
3
4
|
objectInherit: boolean;
|
|
@@ -28,3 +29,4 @@ export type OwnerAndDaclResult = {
|
|
|
28
29
|
platform: NodeJS.Platform;
|
|
29
30
|
};
|
|
30
31
|
export declare function readOwnerAndDacl(targetPath: string): OwnerAndDaclResult;
|
|
32
|
+
export declare function projectOwnerAndDacl(facts: DescriptorFacts): OwnerAndDaclResult;
|
package/dist/owner-dacl.js
CHANGED
|
@@ -18,6 +18,9 @@ export function readOwnerAndDacl(targetPath) {
|
|
|
18
18
|
warnNativeFallback("windows-owner-dacl", "Windows owner and DACL inspection uses a slower built-in system command.");
|
|
19
19
|
}
|
|
20
20
|
const facts = typeof inspect === "function" ? inspect.call(native, targetPath) : readWindowsSecurityFactsCommand(targetPath);
|
|
21
|
+
return projectOwnerAndDacl(facts);
|
|
22
|
+
}
|
|
23
|
+
export function projectOwnerAndDacl(facts) {
|
|
21
24
|
return {
|
|
22
25
|
status: "supported",
|
|
23
26
|
ownerSid: facts.ownerSid,
|
package/dist/path.js
CHANGED
|
@@ -81,7 +81,23 @@ export function isPathInside(root, target) {
|
|
|
81
81
|
return relative === "" || (firstSegment !== ".." && !path.isAbsolute(relative));
|
|
82
82
|
}
|
|
83
83
|
export function isPathRelativeEscape(relativePath) {
|
|
84
|
-
|
|
84
|
+
if (path.isAbsolute(relativePath)) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
let depth = 0;
|
|
88
|
+
// Windows accepts both separators; POSIX backslashes are filename bytes.
|
|
89
|
+
const segments = relativePath.split(process.platform === "win32" ? /[/\\]/ : /\//);
|
|
90
|
+
for (const segment of segments) {
|
|
91
|
+
if (segment === "..") {
|
|
92
|
+
if (depth === 0)
|
|
93
|
+
return true;
|
|
94
|
+
depth -= 1;
|
|
95
|
+
}
|
|
96
|
+
else if (segment !== "" && segment !== ".") {
|
|
97
|
+
depth += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
85
101
|
}
|
|
86
102
|
export function resolveSafeBaseDir(rootDir) {
|
|
87
103
|
const resolved = path.resolve(rootDir);
|
package/dist/permission-exec.js
CHANGED
|
@@ -58,14 +58,11 @@ function dataProperty(value, name) {
|
|
|
58
58
|
}
|
|
59
59
|
return undefined;
|
|
60
60
|
}
|
|
61
|
-
function caughtPrimitiveDisplay(value) {
|
|
62
|
-
if (value !== null && (typeof value === "object" || typeof value === "function"))
|
|
63
|
-
return undefined;
|
|
64
|
-
return primitiveString(value);
|
|
65
|
-
}
|
|
66
61
|
/** Formats only caught permission-query failures without invoking user code. */
|
|
67
62
|
export function formatCaughtPermissionFailure(error) {
|
|
68
|
-
const primitive =
|
|
63
|
+
const primitive = error !== null && (typeof error === "object" || typeof error === "function")
|
|
64
|
+
? undefined
|
|
65
|
+
: primitiveString(error);
|
|
69
66
|
if (primitive !== undefined)
|
|
70
67
|
return formatBoundedCaughtDetail(primitive);
|
|
71
68
|
if (!isInspectableCaughtObject(error)) {
|
|
@@ -2,3 +2,4 @@ export { formatOctal, formatPermissionDetail, formatPermissionRemediation, inspe
|
|
|
2
2
|
export type { PermissionCommandFailure } from "./permission-exec.js";
|
|
3
3
|
export { createPrivateDirectory, type CreatePrivateDirectoryOptions, } from "./private-directory.js";
|
|
4
4
|
export { readOwnerAndDacl, type OwnerAndDaclResult, type WindowsAccessControlEntry, type WindowsAceFlags, } from "./owner-dacl.js";
|
|
5
|
+
export { readOwnerAndDaclBatch } from "./owner-dacl-batch.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { formatOctal, formatPermissionDetail, formatPermissionRemediation, inspectPathPermissions, isGroupReadable, isGroupWritable, isWorldReadable, isWorldWritable, modeBits, safeStat, } from "./permissions.js";
|
|
2
2
|
export { createPrivateDirectory, } from "./private-directory.js";
|
|
3
3
|
export { readOwnerAndDacl, } from "./owner-dacl.js";
|
|
4
|
+
export { readOwnerAndDaclBatch } from "./owner-dacl-batch.js";
|
|
@@ -9,7 +9,6 @@ export type PinnedMutationPolicySnapshot = Readonly<{
|
|
|
9
9
|
export declare function snapshotPinnedMutationPolicy(denyMutations: DenyMutationPolicy | undefined, mutationSymlinks: MutationSymlinkPolicy | undefined): PinnedMutationPolicySnapshot | undefined;
|
|
10
10
|
export declare function preparePinnedWriteMutationAdmission(params: {
|
|
11
11
|
rootReal: string;
|
|
12
|
-
rootWithSep: string;
|
|
13
12
|
rootIdentity?: RootBoundaryIdentity;
|
|
14
13
|
resolvedTargetPath: string;
|
|
15
14
|
defaultRelativeParentPath: string;
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PinnedMutationPolicySnapshot } from "./pinned-mutation-admission.js";
|
|
2
2
|
import type { RootBoundaryIdentity } from "./root-boundary.js";
|
|
3
|
-
import type { MutationSymlinkPolicy } from "./root-symlink-policy.js";
|
|
4
3
|
export type ExactRootIdentity = Readonly<{
|
|
5
4
|
dev: bigint;
|
|
6
5
|
ino: bigint;
|
|
7
6
|
}>;
|
|
8
|
-
type SharedMutationPolicy = Readonly<{
|
|
9
|
-
denyMutations?: DenyMutationPolicy;
|
|
10
|
-
mutationSymlinks?: MutationSymlinkPolicy;
|
|
11
|
-
}>;
|
|
12
7
|
export declare function ordinaryWindowsSegments(relativePath: string): boolean;
|
|
13
8
|
export declare function ordinarySharedAbsoluteInsideRoot(rootReal: string, candidatePath: string, rootIdentity: ExactRootIdentity): boolean;
|
|
14
9
|
export declare function simpleSharedRoute(params: {
|
|
@@ -16,9 +11,8 @@ export declare function simpleSharedRoute(params: {
|
|
|
16
11
|
rootIdentity?: RootBoundaryIdentity;
|
|
17
12
|
originalPath?: string;
|
|
18
13
|
selectedTarget: string;
|
|
19
|
-
policy:
|
|
14
|
+
policy: PinnedMutationPolicySnapshot;
|
|
20
15
|
}): {
|
|
21
16
|
route: string;
|
|
22
17
|
rootIdentity: ExactRootIdentity;
|
|
23
18
|
} | undefined;
|
|
24
|
-
export {};
|
package/dist/pinned-open.d.ts
CHANGED
|
@@ -14,7 +14,6 @@ export type PinnedOpenSyncResult = {
|
|
|
14
14
|
export type PinnedOpenSyncAllowedType = "file" | "directory";
|
|
15
15
|
export type PinnedOpenSyncFinalAdmission = (params: {
|
|
16
16
|
path: string;
|
|
17
|
-
preRealpathIdentity: fs.BigIntStats;
|
|
18
17
|
descriptorIdentity: fs.BigIntStats;
|
|
19
18
|
}) => string;
|
|
20
19
|
export type PinnedOpenSyncFs = Pick<typeof fs, "constants" | "lstatSync" | "realpathSync" | "openSync" | "fstatSync" | "closeSync">;
|
package/dist/pinned-open.js
CHANGED
|
@@ -53,14 +53,13 @@ export function openPinnedFileSync(params) {
|
|
|
53
53
|
assertAllowedStat(stat, allowedType, statPolicy);
|
|
54
54
|
return stat;
|
|
55
55
|
}, preOpenStat);
|
|
56
|
-
|
|
56
|
+
inspectFileIdentitySync(() => {
|
|
57
57
|
const stat = ioFs.lstatSync(realPath, { bigint: true });
|
|
58
58
|
assertAllowedStat(stat, allowedType, statPolicy);
|
|
59
59
|
return stat;
|
|
60
60
|
}, identity);
|
|
61
61
|
const admittedPath = params.finalAdmission?.({
|
|
62
62
|
path: realPath,
|
|
63
|
-
preRealpathIdentity: pathIdentity,
|
|
64
63
|
descriptorIdentity: identity,
|
|
65
64
|
}) ?? realPath;
|
|
66
65
|
const opened = { ok: true, path: admittedPath, fd, stat: openedStat, identity };
|