@openclaw/openshell-sandbox 2026.10.1-beta.2 → 2026.10.5-beta.1
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/dist/index.js +69 -111
- package/node_modules/@openclaw/fs-safe/CHANGELOG.md +91 -0
- package/node_modules/@openclaw/fs-safe/README.md +1 -1
- package/node_modules/@openclaw/fs-safe/dist/advanced.d.ts +1 -0
- package/node_modules/@openclaw/fs-safe/dist/advanced.js +1 -0
- package/node_modules/@openclaw/fs-safe/dist/archive-durability.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/archive-entry.js +7 -3
- package/node_modules/@openclaw/fs-safe/dist/archive-merge.js +4 -4
- package/node_modules/@openclaw/fs-safe/dist/archive-parser.wasm +0 -0
- package/node_modules/@openclaw/fs-safe/dist/archive-read.js +5 -4
- package/node_modules/@openclaw/fs-safe/dist/archive-staging.d.ts +1 -1
- package/node_modules/@openclaw/fs-safe/dist/archive-staging.js +22 -38
- package/node_modules/@openclaw/fs-safe/dist/copy-path-admission.d.ts +17 -0
- package/node_modules/@openclaw/fs-safe/dist/copy-path-admission.js +117 -0
- package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.d.ts +38 -0
- package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.js +195 -0
- package/node_modules/@openclaw/fs-safe/dist/create.d.ts +1 -0
- package/node_modules/@openclaw/fs-safe/dist/create.js +4 -1
- package/node_modules/@openclaw/fs-safe/dist/entry-publication.js +3 -1
- package/node_modules/@openclaw/fs-safe/dist/errors.d.ts +2 -0
- package/node_modules/@openclaw/fs-safe/dist/errors.js +5 -0
- package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-acquire.d.ts +3 -0
- package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-acquire.js +57 -11
- package/node_modules/@openclaw/fs-safe/dist/guarded-mutation.js +5 -5
- package/node_modules/@openclaw/fs-safe/dist/guest-native-python.js +52 -5
- package/node_modules/@openclaw/fs-safe/dist/guest.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/index.d.ts +1 -1
- package/node_modules/@openclaw/fs-safe/dist/index.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/native-binding.d.ts +4 -0
- package/node_modules/@openclaw/fs-safe/dist/native-move-fallback.d.ts +3 -0
- package/node_modules/@openclaw/fs-safe/dist/native-move-fallback.js +54 -0
- package/node_modules/@openclaw/fs-safe/dist/native-noreplace.d.ts +8 -0
- package/node_modules/@openclaw/fs-safe/dist/native-noreplace.js +55 -0
- package/node_modules/@openclaw/fs-safe/dist/native-operations.d.ts +0 -1
- package/node_modules/@openclaw/fs-safe/dist/native-operations.js +22 -26
- package/node_modules/@openclaw/fs-safe/dist/native-pinned-write-windows.js +5 -1
- package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.d.ts +1 -1
- package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.js +12 -2
- package/node_modules/@openclaw/fs-safe/dist/native-rename-outcome.js +3 -1
- package/node_modules/@openclaw/fs-safe/dist/native-staged-file.d.ts +1 -1
- package/node_modules/@openclaw/fs-safe/dist/native-staged-file.js +52 -6
- package/node_modules/@openclaw/fs-safe/dist/permission-exec.js +2 -1
- package/node_modules/@openclaw/fs-safe/dist/pinned-write-input.d.ts +2 -0
- package/node_modules/@openclaw/fs-safe/dist/pinned-write-input.js +17 -0
- package/node_modules/@openclaw/fs-safe/dist/pinned-write-types.d.ts +6 -1
- package/node_modules/@openclaw/fs-safe/dist/pinned-write.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/private-temp-workspace.js +2 -2
- package/node_modules/@openclaw/fs-safe/dist/publish-file-failure.d.ts +5 -0
- package/node_modules/@openclaw/fs-safe/dist/publish-file-failure.js +1 -0
- package/node_modules/@openclaw/fs-safe/dist/publish-file.d.ts +2 -0
- package/node_modules/@openclaw/fs-safe/dist/publish-file.js +35 -2
- package/node_modules/@openclaw/fs-safe/dist/replace-directory.js +6 -5
- package/node_modules/@openclaw/fs-safe/dist/replace-file-copy-fallback.js +1 -4
- package/node_modules/@openclaw/fs-safe/dist/replace-file-descriptor.js +6 -0
- package/node_modules/@openclaw/fs-safe/dist/root-create-input.d.ts +15 -1
- package/node_modules/@openclaw/fs-safe/dist/root-create-input.js +4 -0
- package/node_modules/@openclaw/fs-safe/dist/root-directory-entry.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/root-directory-list.d.ts +0 -1
- package/node_modules/@openclaw/fs-safe/dist/root-directory-list.js +6 -12
- package/node_modules/@openclaw/fs-safe/dist/root-errors.js +25 -4
- package/node_modules/@openclaw/fs-safe/dist/root-impl.js +18 -18
- package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.d.ts +4 -2
- package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.js +13 -17
- package/node_modules/@openclaw/fs-safe/dist/root-paths.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/root-walk.js +4 -4
- package/node_modules/@openclaw/fs-safe/dist/root-write-verification.d.ts +10 -1
- package/node_modules/@openclaw/fs-safe/dist/root-write-verification.js +20 -0
- package/node_modules/@openclaw/fs-safe/dist/secure-temp-dir.js +19 -7
- package/node_modules/@openclaw/fs-safe/dist/sibling-staged-file.js +18 -3
- package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-acquire.js +25 -3
- package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-admission.d.ts +2 -0
- package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-retained.d.ts +14 -0
- package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-retained.js +187 -0
- package/node_modules/@openclaw/fs-safe/dist/sidecar-lock.js +15 -1
- package/node_modules/@openclaw/fs-safe/dist/staged-file-settlement.js +4 -1
- package/node_modules/@openclaw/fs-safe/dist/temp-directory-diagnostics.d.ts +10 -0
- package/node_modules/@openclaw/fs-safe/dist/temp-directory-diagnostics.js +29 -0
- package/node_modules/@openclaw/fs-safe/dist/temp-target.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/temp-workspace-admission.js +6 -6
- package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.d.ts +4 -4
- package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.js +17 -16
- package/node_modules/@openclaw/fs-safe/dist/temp-workspace-descriptor.js +20 -28
- package/node_modules/@openclaw/fs-safe/dist/temp-workspace-owner.d.ts +1 -0
- package/node_modules/@openclaw/fs-safe/dist/temp-workspace-owner.js +27 -4
- package/node_modules/@openclaw/fs-safe/dist/walk.js +2 -13
- package/node_modules/@openclaw/fs-safe/dist/watch-native.d.ts +2 -2
- package/node_modules/@openclaw/fs-safe/dist/watch-native.js +3 -1
- package/node_modules/@openclaw/fs-safe/dist/watch-rescan.js +3 -9
- package/node_modules/@openclaw/fs-safe/dist/watch.js +2 -3
- package/node_modules/@openclaw/fs-safe/docs/advanced.md +1 -0
- package/node_modules/@openclaw/fs-safe/docs/archive.md +5 -1
- package/node_modules/@openclaw/fs-safe/docs/atomic.md +11 -3
- package/node_modules/@openclaw/fs-safe/docs/contributing.md +1 -1
- package/node_modules/@openclaw/fs-safe/docs/copy.md +109 -1
- package/node_modules/@openclaw/fs-safe/docs/durability.md +50 -5
- package/node_modules/@openclaw/fs-safe/docs/errors.md +37 -0
- package/node_modules/@openclaw/fs-safe/docs/guest.md +21 -4
- package/node_modules/@openclaw/fs-safe/docs/native-helper.md +5 -3
- package/node_modules/@openclaw/fs-safe/docs/native.md +39 -6
- package/node_modules/@openclaw/fs-safe/docs/path-scope.md +4 -0
- package/node_modules/@openclaw/fs-safe/docs/public-api.md +3 -1
- package/node_modules/@openclaw/fs-safe/docs/sidecar-lock.md +46 -8
- package/node_modules/@openclaw/fs-safe/docs/staged-file.md +10 -3
- package/node_modules/@openclaw/fs-safe/docs/temp.md +12 -0
- package/node_modules/@openclaw/fs-safe/docs/watch.md +6 -3
- package/node_modules/@openclaw/fs-safe/docs/writing.md +40 -11
- package/node_modules/@openclaw/fs-safe/package.json +16 -16
- package/node_modules/@openclaw/fs-safe-darwin-arm64/fs-safe-native.node +0 -0
- package/node_modules/@openclaw/fs-safe-darwin-arm64/package.json +1 -1
- package/node_modules/@openclaw/fs-safe-darwin-x64/fs-safe-native.node +0 -0
- package/node_modules/@openclaw/fs-safe-darwin-x64/package.json +1 -1
- package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/fs-safe-native.node +0 -0
- package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/package.json +1 -1
- package/node_modules/@openclaw/fs-safe-linux-arm64-musl/fs-safe-native.node +0 -0
- package/node_modules/@openclaw/fs-safe-linux-arm64-musl/package.json +1 -1
- package/node_modules/@openclaw/fs-safe-linux-x64-gnu/fs-safe-native.node +0 -0
- package/node_modules/@openclaw/fs-safe-linux-x64-gnu/package.json +1 -1
- package/node_modules/@openclaw/fs-safe-linux-x64-musl/fs-safe-native.node +0 -0
- package/node_modules/@openclaw/fs-safe-linux-x64-musl/package.json +1 -1
- package/node_modules/@openclaw/fs-safe-win32-x64-msvc/fs-safe-native.node +0 -0
- package/node_modules/@openclaw/fs-safe-win32-x64-msvc/package.json +1 -1
- package/package.json +5 -5
- package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-arbitration.d.ts +0 -17
- package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-arbitration.js +0 -66
- package/node_modules/@openclaw/fs-safe/dist/root-move-preflight.d.ts +0 -7
- package/node_modules/@openclaw/fs-safe/dist/root-move-preflight.js +0 -16
|
@@ -4,6 +4,7 @@ import { pinNodeDirectoryForMode, pinNodeDirectoryForModeSync } from "./director
|
|
|
4
4
|
import { FsSafeError } from "./errors.js";
|
|
5
5
|
import { fileIdentityMismatchError, inspectFileIdentitySync } from "./strict-file-identity.js";
|
|
6
6
|
import { classifyTempWorkspaceOwner, warnUnmappedTempWorkspaceAncestor } from "./temp-workspace-ownership.js";
|
|
7
|
+
import { privateTempDirectoryFix, tempDirectoryMode } from "./temp-directory-diagnostics.js";
|
|
7
8
|
export const TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY = process.platform === "linux" || process.platform === "darwin";
|
|
8
9
|
export function projectTempWorkspaceNumericIdentity(identity) {
|
|
9
10
|
const dev = Number(identity.dev);
|
|
@@ -43,12 +44,12 @@ export function validateTempWorkspaceDirMode(mode) {
|
|
|
43
44
|
throw new FsSafeError("insecure-permissions", "temp workspace must not be group/world writable");
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
|
-
export function assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory
|
|
47
|
+
export function assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory, dir) {
|
|
47
48
|
if (uid === undefined)
|
|
48
49
|
return;
|
|
49
50
|
const ownership = classifyTempWorkspaceOwner(stat, uid);
|
|
50
51
|
if (ownership === "foreign" || (privateDirectory && ownership !== "user")) {
|
|
51
|
-
throw new FsSafeError("not-owned",
|
|
52
|
+
throw new FsSafeError("not-owned", `temp workspace directory ${JSON.stringify(dir)} has an untrusted owner uid ${stat.uid}; expected uid ${uid}${privateDirectory || uid === 0 ? "" : " or root uid 0"}; ${privateTempDirectoryFix(uid)}`);
|
|
52
53
|
}
|
|
53
54
|
// Unmapped host owners are unverifiable; trust the host directory hierarchy.
|
|
54
55
|
// Admit sticky ancestors so PrivateUsers keeps host /tmp and PrivateTmp usable.
|
|
@@ -60,18 +61,18 @@ export function assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory
|
|
|
60
61
|
? (stat.mode & 512n) !== 0n
|
|
61
62
|
: Number.isSafeInteger(stat.mode) && stat.mode >= 0 && (stat.mode & 0o1000) !== 0;
|
|
62
63
|
if (typeof stat.mode !== "bigint" && (!Number.isSafeInteger(stat.mode) || stat.mode < 0)) {
|
|
63
|
-
throw new FsSafeError("insecure-permissions",
|
|
64
|
+
throw new FsSafeError("insecure-permissions", `temp workspace directory ${JSON.stringify(dir)} permissions are invalid; ${privateTempDirectoryFix(uid)}`);
|
|
64
65
|
}
|
|
65
66
|
if (writable && (privateDirectory || !sticky)) {
|
|
66
67
|
throw new FsSafeError("insecure-permissions", privateDirectory
|
|
67
|
-
?
|
|
68
|
-
:
|
|
68
|
+
? `temp workspace directory ${JSON.stringify(dir)} has group/world-writable mode ${tempDirectoryMode(stat.mode)}; root and child must not be group/world writable, even with the sticky bit; ${privateTempDirectoryFix(uid)}`
|
|
69
|
+
: `temp workspace ancestor ${JSON.stringify(dir)} has group/world-writable mode ${tempDirectoryMode(stat.mode)} without sticky protection (sticky bit missing); have its administrator restore safe permissions (1777 for a shared system temp directory), or move the private root under trusted ancestors`);
|
|
69
70
|
}
|
|
70
71
|
if (ownership === "unmapped")
|
|
71
72
|
warnUnmappedTempWorkspaceAncestor();
|
|
72
73
|
}
|
|
73
74
|
const WINDOWS = process.platform === "win32";
|
|
74
|
-
export function assertTempWorkspaceChildState(stat, ownerUid) {
|
|
75
|
+
export function assertTempWorkspaceChildState(stat, ownerUid, dir) {
|
|
75
76
|
const exactIdentity = typeof stat.dev === "bigint" && typeof stat.ino === "bigint" &&
|
|
76
77
|
(!WINDOWS || (stat.dev !== 0n && stat.ino !== 0n));
|
|
77
78
|
const safeNumericIdentity = TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY &&
|
|
@@ -81,12 +82,12 @@ export function assertTempWorkspaceChildState(stat, ownerUid) {
|
|
|
81
82
|
throw new FsSafeError("path-mismatch", "temp workspace child identity could not be verified");
|
|
82
83
|
}
|
|
83
84
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
84
|
-
throw new FsSafeError("not-file",
|
|
85
|
+
throw new FsSafeError("not-file", `temp workspace child ${JSON.stringify(dir)} ${stat.isSymbolicLink() ? "is a symbolic link" : "is not a directory"}; ${privateTempDirectoryFix(ownerUid)}`);
|
|
85
86
|
}
|
|
86
|
-
assertTrustedTempWorkspaceDirectory(stat, ownerUid, true);
|
|
87
|
+
assertTrustedTempWorkspaceDirectory(stat, ownerUid, true, dir);
|
|
87
88
|
}
|
|
88
|
-
export function validateInitialTempWorkspaceChild(stat, ownerUid, mode) {
|
|
89
|
-
assertTempWorkspaceChildState(stat, ownerUid);
|
|
89
|
+
export function validateInitialTempWorkspaceChild(stat, ownerUid, mode, dir) {
|
|
90
|
+
assertTempWorkspaceChildState(stat, ownerUid, dir);
|
|
90
91
|
return !childHasRequestedMode(stat, mode);
|
|
91
92
|
}
|
|
92
93
|
export function childHasRequestedMode(stat, mode) {
|
|
@@ -105,7 +106,7 @@ async function initializeTempWorkspaceChildMode(dir, expected, parent, mode) {
|
|
|
105
106
|
try {
|
|
106
107
|
await owner.apply(mode, { check: parent.assertCurrent });
|
|
107
108
|
const current = inspectDirectoryIdentitySync(dir, expected);
|
|
108
|
-
validateAdmittedTempWorkspaceChild(current, parent.ownerUid, mode);
|
|
109
|
+
validateAdmittedTempWorkspaceChild(current, parent.ownerUid, mode, dir);
|
|
109
110
|
parent.assertCurrent();
|
|
110
111
|
}
|
|
111
112
|
finally {
|
|
@@ -113,12 +114,12 @@ async function initializeTempWorkspaceChildMode(dir, expected, parent, mode) {
|
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
export function admitTempWorkspaceChild(dir, expected, parent, mode) {
|
|
116
|
-
if (!validateInitialTempWorkspaceChild(expected, parent.ownerUid, mode))
|
|
117
|
+
if (!validateInitialTempWorkspaceChild(expected, parent.ownerUid, mode, dir))
|
|
117
118
|
return undefined;
|
|
118
119
|
return initializeTempWorkspaceChildMode(dir, expected, parent, mode);
|
|
119
120
|
}
|
|
120
121
|
export function admitTempWorkspaceChildSync(dir, expected, parent, mode) {
|
|
121
|
-
if (!validateInitialTempWorkspaceChild(expected, parent.ownerUid, mode))
|
|
122
|
+
if (!validateInitialTempWorkspaceChild(expected, parent.ownerUid, mode, dir))
|
|
122
123
|
return;
|
|
123
124
|
parent.assertCurrent();
|
|
124
125
|
const owner = pinNodeDirectoryForModeSync(dir, {
|
|
@@ -128,15 +129,15 @@ export function admitTempWorkspaceChildSync(dir, expected, parent, mode) {
|
|
|
128
129
|
try {
|
|
129
130
|
owner.apply(mode, parent.assertCurrent);
|
|
130
131
|
const current = inspectDirectoryIdentitySync(dir, expected);
|
|
131
|
-
validateAdmittedTempWorkspaceChild(current, parent.ownerUid, mode);
|
|
132
|
+
validateAdmittedTempWorkspaceChild(current, parent.ownerUid, mode, dir);
|
|
132
133
|
parent.assertCurrent();
|
|
133
134
|
}
|
|
134
135
|
finally {
|
|
135
136
|
owner.close();
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
|
-
export function validateAdmittedTempWorkspaceChild(current, ownerUid, mode) {
|
|
139
|
-
assertTempWorkspaceChildState(current, ownerUid);
|
|
139
|
+
export function validateAdmittedTempWorkspaceChild(current, ownerUid, mode, dir) {
|
|
140
|
+
assertTempWorkspaceChildState(current, ownerUid, dir);
|
|
140
141
|
if (!childHasRequestedMode(current, mode)) {
|
|
141
142
|
throw new FsSafeError("path-mismatch", "temp workspace final mode could not be verified");
|
|
142
143
|
}
|
|
@@ -133,9 +133,8 @@ export class TempWorkspaceRetainedChild {
|
|
|
133
133
|
// the synchronous caller validates it and decides whether to correct.
|
|
134
134
|
const stat = fsSync.fstatSync(descriptor.fd, { bigint: true });
|
|
135
135
|
Object.freeze(stat);
|
|
136
|
-
const receipt = Object.freeze({ stat });
|
|
137
136
|
return {
|
|
138
|
-
retained: new TempWorkspaceRetainedChild(dir, stat, descriptor,
|
|
137
|
+
retained: new TempWorkspaceRetainedChild(dir, stat, descriptor, stat),
|
|
139
138
|
stat,
|
|
140
139
|
};
|
|
141
140
|
}
|
|
@@ -151,23 +150,27 @@ export class TempWorkspaceRetainedChild {
|
|
|
151
150
|
// Clear first so validation failures, procfs checks, and later admission
|
|
152
151
|
// can never reuse the creation observation.
|
|
153
152
|
this.#initialReceipt = undefined;
|
|
154
|
-
return receipt
|
|
153
|
+
return receipt;
|
|
155
154
|
}
|
|
156
155
|
finalizeAdmission(ownerUid, mode) {
|
|
157
156
|
this.#assertModeLeaseReleased();
|
|
158
157
|
this.discardInitialReceipt();
|
|
159
|
-
|
|
160
|
-
throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
|
|
161
|
-
}
|
|
158
|
+
const fd = this.#requireDescriptor();
|
|
162
159
|
// A failed revalidation must never leave an earlier receipt transferable.
|
|
163
160
|
this.#transferAuthorized = false;
|
|
164
|
-
const current = inspectTempWorkspaceDescriptorIdentitySync(
|
|
165
|
-
validateAdmittedTempWorkspaceChild(current, ownerUid, mode);
|
|
161
|
+
const current = inspectTempWorkspaceDescriptorIdentitySync(fd, this.#identity, this.#numericIdentity);
|
|
162
|
+
validateAdmittedTempWorkspaceChild(current, ownerUid, mode, this.#dir);
|
|
166
163
|
const named = inspectTempWorkspaceDirectoryIdentitySync(this.#dir, this.#identity, this.#numericIdentity);
|
|
167
|
-
validateAdmittedTempWorkspaceChild(named, ownerUid, mode);
|
|
164
|
+
validateAdmittedTempWorkspaceChild(named, ownerUid, mode, this.#dir);
|
|
168
165
|
this.#transferAuthorized = true;
|
|
169
166
|
return named;
|
|
170
167
|
}
|
|
168
|
+
#requireDescriptor() {
|
|
169
|
+
if (this.#fd === undefined) {
|
|
170
|
+
throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
|
|
171
|
+
}
|
|
172
|
+
return this.#fd;
|
|
173
|
+
}
|
|
171
174
|
#assertModeLeaseReleased() {
|
|
172
175
|
if (this.#modeLease) {
|
|
173
176
|
throw new FsSafeError("path-mismatch", "temp workspace child mode operation is active");
|
|
@@ -175,9 +178,9 @@ export class TempWorkspaceRetainedChild {
|
|
|
175
178
|
}
|
|
176
179
|
#inspectModeTarget(fd, ownerUid, initial) {
|
|
177
180
|
const descriptor = initial ?? inspectTempWorkspaceDescriptorIdentitySync(fd, this.#identity, this.#numericIdentity);
|
|
178
|
-
assertTempWorkspaceChildState(descriptor, ownerUid);
|
|
181
|
+
assertTempWorkspaceChildState(descriptor, ownerUid, this.#dir);
|
|
179
182
|
const named = inspectTempWorkspaceDirectoryIdentitySync(this.#dir, this.#identity, this.#numericIdentity);
|
|
180
|
-
assertTempWorkspaceChildState(named, ownerUid);
|
|
183
|
+
assertTempWorkspaceChildState(named, ownerUid, this.#dir);
|
|
181
184
|
return descriptor;
|
|
182
185
|
}
|
|
183
186
|
async #assertProcAuthority(fd, ownerUid) {
|
|
@@ -196,16 +199,13 @@ export class TempWorkspaceRetainedChild {
|
|
|
196
199
|
const opened = inspectFileIdentitySync(() => fsSync.fstatSync(fd, { bigint: true }), this.#identity);
|
|
197
200
|
const followed = inspectFileIdentitySync(() => fsSync.statSync(procPath, { bigint: true }), this.#identity);
|
|
198
201
|
assertOwnedDirectory(opened, followed);
|
|
199
|
-
assertTempWorkspaceChildState(opened, ownerUid);
|
|
200
|
-
assertTempWorkspaceChildState(followed, ownerUid);
|
|
202
|
+
assertTempWorkspaceChildState(opened, ownerUid, this.#dir);
|
|
203
|
+
assertTempWorkspaceChildState(followed, ownerUid, this.#dir);
|
|
201
204
|
}
|
|
202
205
|
async initializeMode(mode, ownerUid, assertParent) {
|
|
203
206
|
this.#assertModeLeaseReleased();
|
|
204
207
|
this.discardInitialReceipt();
|
|
205
|
-
|
|
206
|
-
throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
|
|
207
|
-
}
|
|
208
|
-
const fd = this.#fd;
|
|
208
|
+
const fd = this.#requireDescriptor();
|
|
209
209
|
this.#transferAuthorized = false;
|
|
210
210
|
this.#modeLease = true;
|
|
211
211
|
try {
|
|
@@ -238,10 +238,7 @@ export class TempWorkspaceRetainedChild {
|
|
|
238
238
|
}
|
|
239
239
|
initializeModeSync(mode, ownerUid, assertParent) {
|
|
240
240
|
this.#assertModeLeaseReleased();
|
|
241
|
-
|
|
242
|
-
throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
|
|
243
|
-
}
|
|
244
|
-
const fd = this.#fd;
|
|
241
|
+
const fd = this.#requireDescriptor();
|
|
245
242
|
this.#transferAuthorized = false;
|
|
246
243
|
this.#modeLease = true;
|
|
247
244
|
try {
|
|
@@ -270,9 +267,7 @@ export class TempWorkspaceRetainedChild {
|
|
|
270
267
|
this.discardInitialReceipt();
|
|
271
268
|
if (this.canEnumerate)
|
|
272
269
|
return true;
|
|
273
|
-
|
|
274
|
-
throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
|
|
275
|
-
}
|
|
270
|
+
this.#requireDescriptor();
|
|
276
271
|
// Opening or validating a replacement crosses a new pathname boundary.
|
|
277
272
|
// Even failure must revoke any earlier final-admission receipt.
|
|
278
273
|
this.#transferAuthorized = false;
|
|
@@ -312,13 +307,10 @@ export class TempWorkspaceRetainedChild {
|
|
|
312
307
|
transfer(retainDescriptor) {
|
|
313
308
|
this.#assertModeLeaseReleased();
|
|
314
309
|
this.discardInitialReceipt();
|
|
315
|
-
|
|
316
|
-
throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
|
|
317
|
-
}
|
|
310
|
+
const fd = this.#requireDescriptor();
|
|
318
311
|
if (!this.#transferAuthorized) {
|
|
319
312
|
throw new FsSafeError("path-mismatch", "temp workspace child has not completed final admission");
|
|
320
313
|
}
|
|
321
|
-
const fd = this.#fd;
|
|
322
314
|
this.#fd = undefined;
|
|
323
315
|
if (retainDescriptor && !this.canEnumerate) {
|
|
324
316
|
closeAfterAdmissionFailure(fd, new FsSafeError("helper-unavailable", "temp workspace cleanup requires a readable child descriptor"), "temp workspace child descriptor rejection and close failed");
|
|
@@ -9,6 +9,7 @@ export declare class TempWorkspaceCleanupCapability {
|
|
|
9
9
|
readonly parent: RetainedDirectory | undefined;
|
|
10
10
|
constructor(root: string, safety: TempWorkspaceCleanupSafety, admission: TempWorkspaceRootAdmission, dirMode: number);
|
|
11
11
|
get canRemoveOwnedTree(): boolean;
|
|
12
|
+
get canUseCompatibleCleanup(): boolean;
|
|
12
13
|
prepareChildCreation(): void;
|
|
13
14
|
admitChildDescriptor(canEnumerate: boolean): boolean;
|
|
14
15
|
assertCurrent(): void;
|
|
@@ -8,6 +8,8 @@ import { sameFileIdentityForCleanup } from "./file-identity.js";
|
|
|
8
8
|
import { getNativeBinding } from "./native.js";
|
|
9
9
|
import { openTempWorkspaceCleanupParent, TempWorkspaceRetainedChild, } from "./temp-workspace-descriptor.js";
|
|
10
10
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
11
|
+
import { isFsSafeNativeRequired } from "./native-config.js";
|
|
12
|
+
import { cachedNoReplaceUnavailable, noReplaceUnavailable, rememberNoReplaceUnavailable } from "./native-noreplace.js";
|
|
11
13
|
function isNativeCleanupBinding(binding) {
|
|
12
14
|
return typeof binding?.renameNoReplace === "function" &&
|
|
13
15
|
typeof binding.removeOwnedTree === "function" &&
|
|
@@ -94,6 +96,9 @@ export class TempWorkspaceCleanupCapability {
|
|
|
94
96
|
return (this.#phase === "ready" || this.#phase === "sealed") &&
|
|
95
97
|
this.#ownedTreeRemovalAvailable;
|
|
96
98
|
}
|
|
99
|
+
get canUseCompatibleCleanup() {
|
|
100
|
+
return this.#safety === "compatible" && !isFsSafeNativeRequired();
|
|
101
|
+
}
|
|
97
102
|
prepareChildCreation() {
|
|
98
103
|
const replay = this.#phase === "ready";
|
|
99
104
|
if (this.#phase !== "new" && !replay) {
|
|
@@ -269,11 +274,27 @@ export class TempWorkspaceCleanupOwner {
|
|
|
269
274
|
}
|
|
270
275
|
const name = `.fs-safe-workspace-cleanup-${randomUUID()}`;
|
|
271
276
|
const quarantinePath = path.join(parent.receipt.path, name);
|
|
272
|
-
|
|
277
|
+
let nativeRemoval = this.#capability.canRemoveOwnedTree && this.#directory !== undefined;
|
|
273
278
|
if (nativeRemoval) {
|
|
274
|
-
this.#capability.binding
|
|
279
|
+
const binding = this.#capability.binding;
|
|
280
|
+
try {
|
|
281
|
+
const cached = cachedNoReplaceUnavailable(binding, parent.fd, "workspace quarantine");
|
|
282
|
+
if (cached)
|
|
283
|
+
throw cached;
|
|
284
|
+
binding.renameNoReplace(parent.fd, path.basename(this.#dir), parent.fd, name);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
const unavailable = error instanceof FsSafeError && error.code === "helper-unavailable"
|
|
288
|
+
? error : noReplaceUnavailable(error, "workspace quarantine", true);
|
|
289
|
+
if (!unavailable)
|
|
290
|
+
throw error;
|
|
291
|
+
rememberNoReplaceUnavailable(binding, parent.fd, unavailable);
|
|
292
|
+
if (!this.#capability.canUseCompatibleCleanup)
|
|
293
|
+
throw unavailable;
|
|
294
|
+
nativeRemoval = false;
|
|
295
|
+
}
|
|
275
296
|
}
|
|
276
|
-
|
|
297
|
+
if (!nativeRemoval) {
|
|
277
298
|
// The admitted receipt is exact and descriptor-associated. Reuse it as
|
|
278
299
|
// the pre/post parent fence instead of layering a numeric guard over it.
|
|
279
300
|
this.#capability.assertCurrent();
|
|
@@ -288,7 +309,9 @@ export class TempWorkspaceCleanupOwner {
|
|
|
288
309
|
}
|
|
289
310
|
return { name, path: quarantinePath, nativeRemoval };
|
|
290
311
|
}
|
|
291
|
-
catch {
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (error instanceof FsSafeError && error.code === "helper-unavailable")
|
|
314
|
+
throw error;
|
|
292
315
|
// A failed rename can still have committed on a remote filesystem.
|
|
293
316
|
return "indeterminate";
|
|
294
317
|
}
|
|
@@ -32,17 +32,6 @@ function kindForDirent(dirent) {
|
|
|
32
32
|
function shouldStop(result, options) {
|
|
33
33
|
return options.maxEntries !== undefined && result.scannedEntryCount >= Math.max(0, options.maxEntries);
|
|
34
34
|
}
|
|
35
|
-
function buildEntry(params) {
|
|
36
|
-
const fullPath = params.fullPath;
|
|
37
|
-
return {
|
|
38
|
-
name: params.dirent.name,
|
|
39
|
-
path: fullPath,
|
|
40
|
-
relativePath: params.relativePath,
|
|
41
|
-
depth: params.depth,
|
|
42
|
-
kind: params.kind,
|
|
43
|
-
dirent: params.dirent,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
35
|
function recordFailedDir(result, root, dir, depth, error) {
|
|
47
36
|
const relativePath = path.relative(root, dir);
|
|
48
37
|
result.failedDirs.push({
|
|
@@ -138,7 +127,7 @@ export function walkDirectorySync(rootDir, options = {}) {
|
|
|
138
127
|
if (!kind)
|
|
139
128
|
continue;
|
|
140
129
|
const relativePath = relativeDir ? `${relativeDir}${path.sep}${dirent.name}` : dirent.name;
|
|
141
|
-
const entry =
|
|
130
|
+
const entry = { name: dirent.name, path: fullPath, relativePath, depth, kind, dirent };
|
|
142
131
|
if (options.include?.(entry) ?? true) {
|
|
143
132
|
result.entries.push(entry);
|
|
144
133
|
}
|
|
@@ -229,7 +218,7 @@ export async function walkDirectory(rootDir, options = {}) {
|
|
|
229
218
|
if (!kind)
|
|
230
219
|
continue;
|
|
231
220
|
const relativePath = relativeDir ? `${relativeDir}${path.sep}${dirent.name}` : dirent.name;
|
|
232
|
-
const entry =
|
|
221
|
+
const entry = { name: dirent.name, path: fullPath, relativePath, depth, kind, dirent };
|
|
233
222
|
const include = options.include;
|
|
234
223
|
const included = include == null ? true : Reflect.apply(include, options, [entry]);
|
|
235
224
|
if ((isObjectResult(included) ? await included : included) ?? true) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type NativeBinding } from "./native.js";
|
|
2
2
|
import type { RootContext } from "./root-context.js";
|
|
3
3
|
import type { DirectoryIdentity, WatchSnapshot } from "./watch-scan.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { WatchScope } from "./watch-types.js";
|
|
5
5
|
export type NativeWatchHint = {
|
|
6
6
|
directory: string;
|
|
7
7
|
name: string;
|
|
@@ -50,6 +50,6 @@ export declare class NativeWatchBackend {
|
|
|
50
50
|
add(name: string, identity: DirectoryIdentity): void;
|
|
51
51
|
testEvent(path: string, flags: number): void;
|
|
52
52
|
entries(snapshot: WatchSnapshot): boolean;
|
|
53
|
-
configure(
|
|
53
|
+
configure(snapshot: WatchSnapshot, scopes: readonly WatchScope[]): boolean;
|
|
54
54
|
close(): void;
|
|
55
55
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FsSafeError } from "./errors.js";
|
|
2
2
|
import { getNativeBinding } from "./native.js";
|
|
3
3
|
import { getFsSafeNativeConfig } from "./native-config.js";
|
|
4
|
+
import { watchStreamPaths } from "./watch-stream.js";
|
|
4
5
|
export function watchBinding(mode) {
|
|
5
6
|
if (mode === "poll")
|
|
6
7
|
return;
|
|
@@ -61,9 +62,10 @@ export class NativeWatchBackend {
|
|
|
61
62
|
throw watchError(cause);
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
|
-
configure(
|
|
65
|
+
configure(snapshot, scopes) {
|
|
65
66
|
if (process.platform !== "darwin")
|
|
66
67
|
return false;
|
|
68
|
+
const paths = watchStreamPaths(snapshot, scopes);
|
|
67
69
|
const key = JSON.stringify(paths);
|
|
68
70
|
if (this.streamPaths === key)
|
|
69
71
|
return false;
|
|
@@ -55,15 +55,9 @@ export function mergeWatchRescan(scopes, before, slice, requested) {
|
|
|
55
55
|
for (const name of slice.childPaths?.get(directory)?.values() ?? [])
|
|
56
56
|
selected.add(name);
|
|
57
57
|
}
|
|
58
|
-
for (const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const current = slice.entries.get(name);
|
|
62
|
-
if ((previous.startsWith("directory:") || current?.startsWith("directory:")) && identity(previous) !== identity(current))
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
for (const [name, current] of slice.entries) {
|
|
66
|
-
if (selected.has(name) && current.startsWith("directory:") && identity(current) !== identity(before.entries.get(name)))
|
|
58
|
+
for (const name of selected) {
|
|
59
|
+
const previous = before.entries.get(name), current = slice.entries.get(name);
|
|
60
|
+
if ((previous?.startsWith("directory:") || current?.startsWith("directory:")) && identity(previous) !== identity(current))
|
|
67
61
|
return;
|
|
68
62
|
}
|
|
69
63
|
// Exclusion changes can retire or admit a subtree, including callback-driven changes.
|
|
@@ -5,7 +5,6 @@ import { rootHandleContext } from "./root-handle-context.js";
|
|
|
5
5
|
import { createSuppressedError } from "./suppressed-error.js";
|
|
6
6
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
7
7
|
import { mergeWatchRescan, watchRescanScopes } from "./watch-rescan.js";
|
|
8
|
-
import { watchStreamPaths } from "./watch-stream.js";
|
|
9
8
|
import { admittedNativeChanges, changedEntries, excludedWatchPath, guardedHintChanges, scopedChanges } from "./watch-hints.js";
|
|
10
9
|
import path from "node:path";
|
|
11
10
|
import { watchBinding, NativeWatchBackend } from "./watch-native.js";
|
|
@@ -338,7 +337,7 @@ export function watch(root, input) {
|
|
|
338
337
|
}, maxPendingPaths, persistent);
|
|
339
338
|
backend = candidate;
|
|
340
339
|
if (snapshot)
|
|
341
|
-
candidate.configure(
|
|
340
|
+
candidate.configure(snapshot, g.scopes);
|
|
342
341
|
const hookResult = getFsSafeTestHooks()?.afterWatchBackendCreated?.(context.rootReal, batch => {
|
|
343
342
|
if (backend === candidate)
|
|
344
343
|
onHint(g, batch);
|
|
@@ -401,7 +400,7 @@ export function watch(root, input) {
|
|
|
401
400
|
}
|
|
402
401
|
try {
|
|
403
402
|
const entriesChanged = backend?.entries(next);
|
|
404
|
-
const streamsChanged = backend?.configure(
|
|
403
|
+
const streamsChanged = backend?.configure(next, g.scopes);
|
|
405
404
|
if (entriesChanged || streamsChanged) {
|
|
406
405
|
// Complete a guarded pass with the new transport before publishing readiness.
|
|
407
406
|
// Bound handovers under churn; later passes still reconcile ongoing changes.
|
|
@@ -82,6 +82,7 @@ concurrent namespace changes.
|
|
|
82
82
|
| `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
|
|
83
83
|
| `retainEntryForPublication`, `RetainedEntryPublication`, `RetainEntryForPublicationOptions`, `EntryPublicationReceipt`, `EntryPublicationResult`, `EntryPublicationIssue`, `PublicationIdentity`, `PublicationParent` | [One-way entry publication](entry-publication.md) | Native no-replace directory/regular-file/symlink export, including Windows NTFS junctions, under caller-exclusive namespace; explicit transition receipts, close-only disposal, no source CAS. |
|
|
84
84
|
| `retainFileInDirectory`, `RetainedFile`, related receipt/result types | [Retained Windows files](retained-file.md) | Existing-file native handle custody and explicit removal; local NTFS, producer authority required, no persistence guarantee. |
|
|
85
|
+
| `copyRootFileSync`, `createRootFileCopyBatchSync`, `RootFileCopyBatchSync`, `CopyRootFileSyncOptions`, `CopiedRootFileSync` | [copy.md](copy.md#synchronous-guarded-file-copies) | Synchronous guarded exclusive file copy, optionally cloned and bound to an expected source identity, returning an owned destination descriptor and both identities. |
|
|
85
86
|
| `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
|
|
86
87
|
| `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. |
|
|
87
88
|
| `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
|
|
@@ -179,7 +179,8 @@ between native and JavaScript paths rather than reimplementing it in Rust.
|
|
|
179
179
|
|
|
180
180
|
ZIP extraction and bounded reads admit every physical central-directory record and its referenced local header before either decoder can normalize or collapse names. Raw names and valid Unicode Path names must pass traversal checks before stripping, filtering, or selecting a requested member; duplicate or colliding names reject with `entry-path`, even in unrelated or skipped members. Materially conflicting local/central or Unicode interpretations, malformed critical metadata, and ambiguous framing reject with `ArchiveFormatError`. Harmless internal separator and dot-component equivalence is allowed only after validation; every raw and Unicode interpretation must also agree on whether its name ends in `/` or `\`. Ordinary legacy filename decoding remains backend-selected. Native ZIP extraction groups nearby metadata reads into at most two 4 KiB read-ahead buffers per admission pass; larger records retain separately bounded reads. Buffered record views remain stable across eviction, and cached work periodically yields for deadline checks.
|
|
181
181
|
|
|
182
|
-
ZIP preflight and bounded reads
|
|
182
|
+
ZIP preflight and bounded reads use extraction's canonical path identity, including
|
|
183
|
+
removing directory trailing separators, and apply its case-insensitive NFC collision
|
|
183
184
|
policy to the complete archive, including entries that extraction would strip
|
|
184
185
|
or skip. Known UTF-8 and Unicode Path names are checked during physical admission;
|
|
185
186
|
legacy names retain backend-selected decoding and undergo the same collision
|
|
@@ -701,6 +702,9 @@ await extractArchive({
|
|
|
701
702
|
|
|
702
703
|
## `readArchiveEntry`
|
|
703
704
|
|
|
705
|
+
The byte limit is selected and validated once before reading the archive; changing
|
|
706
|
+
the options object during a read does not change the active limit.
|
|
707
|
+
|
|
704
708
|
`readArchiveEntry(archivePath, entryPath, { maxBytes, kind? })` reads one
|
|
705
709
|
regular-file entry into a bounded `Buffer` without extracting a tree. It reads
|
|
706
710
|
the input through an identity-checked descriptor, rejects a requested link or
|
|
@@ -16,6 +16,11 @@ import {
|
|
|
16
16
|
|
|
17
17
|
Write `content` to a sibling temp file in the destination directory, apply the parent-directory and final file modes through verified descriptors, optionally `fsync` the file descriptor, optionally `fsync` the parent directory after rename, then atomically rename over the destination. No permission change follows a caller-supplied pathname.
|
|
18
18
|
|
|
19
|
+
On POSIX, non-durable asynchronous writes using Node inspect the staged file's
|
|
20
|
+
complete permission bits with their final identity check and skip a redundant
|
|
21
|
+
mode change when they already match. A restrictive umask or differing special
|
|
22
|
+
bits still require descriptor mode correction before publication.
|
|
23
|
+
|
|
19
24
|
On POSIX, the parent is opened with no-follow and directory-only flags, checked against its exact pre-open device/inode identity, and mode-adjusted through that descriptor. A replacement symlink is rejected rather than followed. If the directory cannot be opened for descriptor access, the operation fails closed instead of retrying by pathname. Windows does not enforce POSIX directory modes and Node cannot consistently open directory descriptors there, so `dirMode` is passed only to `mkdir`; no pathname `chmod` fallback is attempted.
|
|
20
25
|
|
|
21
26
|
Async replacements to the same destination are serialized inside the current process, so two overlapping `replaceFileAtomic()` calls do not interleave their temp-write/rename phases. Use a sidecar lock when multiple processes may write the same target.
|
|
@@ -258,7 +263,9 @@ opened, removed, or replaced. A successor at that name is left untouched.
|
|
|
258
263
|
`details.cleanup: "restored"` means the retained original file's bytes and mode
|
|
259
264
|
were restored and synchronized, not that the pathname still names it. The existing
|
|
260
265
|
`writing` receipt identifies that file; no `published` receipt is emitted for a
|
|
261
|
-
failed replacement. Restoration I/O failures report `"restore-failed"
|
|
266
|
+
failed replacement. Restoration I/O failures report `"restore-failed"`, even if
|
|
267
|
+
the thrown error itself carries a `cleanup: "restored"` receipt from another
|
|
268
|
+
operation; the cause retains both the replacement and restoration failures.
|
|
262
269
|
|
|
263
270
|
Callback refusals, detected identity/type/link changes, and other metadata errors
|
|
264
271
|
remain terminal. Failed descriptor revalidation also stops restoration. These
|
|
@@ -539,8 +546,9 @@ remain a best-effort sequence, not atomic.
|
|
|
539
546
|
`onDestinationPublished` runs exactly once after a successful rename resolves,
|
|
540
547
|
before awaited post-rename directory checks or source cleanup. It receives a
|
|
541
548
|
frozen receipt with the resolved absolute destination path and the exact bigint
|
|
542
|
-
device/inode identity observed on the rename source
|
|
543
|
-
|
|
549
|
+
device/inode identity observed on the rename source after the synchronous
|
|
550
|
+
authority callbacks return and before dispatch: the source selected for a direct
|
|
551
|
+
rename, or the staged copy for fallback. Failed
|
|
544
552
|
rename attempts never emit receipts. The return contract remains `Promise<void>`.
|
|
545
553
|
|
|
546
554
|
The receipt records an observed identity, not authorization, durable storage,
|
|
@@ -175,7 +175,7 @@ temporary storage; session scratch trees may suppress macOS filesystem events.
|
|
|
175
175
|
### Optional Linux Testbox
|
|
176
176
|
|
|
177
177
|
The manual `testbox-validation.yml` workflow prepares a 16-vCPU Ubuntu 24.04
|
|
178
|
-
Blacksmith Testbox with Node 24.21.0, pnpm 12.
|
|
178
|
+
Blacksmith Testbox with Node 24.21.0, pnpm 12.10.1, dependencies, the Rust WASM
|
|
179
179
|
target, and the pinned portable archive compiler. It leaves library builds and
|
|
180
180
|
validation commands to the caller and does not replace required CI checks.
|
|
181
181
|
|
|
@@ -58,7 +58,7 @@ Apple [strongly discourages general directory cloning](https://github.com/apple-
|
|
|
58
58
|
| `"always"` | Require native cloning. Unsupported operations fail without a byte-copy fallback. |
|
|
59
59
|
| `"never"` | Copy regular file bytes using reads and writes. No native cloning or copy-offload calls. Works without a native binding. |
|
|
60
60
|
|
|
61
|
-
Automatic copying does not recover from permission errors, I/O errors, cancellation, or rejected source contents such as ReFS named streams. A failed clone must leave the destination absent before fallback can create it; otherwise copying fails rather than merging into a partial tree.
|
|
61
|
+
On Linux XFS and ZFS, `FICLONE` capability failures (including an ioctl denied by container seccomp or LSM policy with `EPERM`) select byte copying in `"auto"` mode; `"always"` reports `unsupported-platform`. Automatic copying does not recover from ordinary file-access permission errors, I/O errors, cancellation, or rejected source contents such as ReFS named streams. Native reflink workers settle and remove partial output before returning a clone failure. A failed clone must leave the destination absent before fallback can create it; otherwise copying fails rather than merging into a partial tree.
|
|
62
62
|
|
|
63
63
|
`concurrency` accepts integers from 1 through 32 and bounds active file copies. ReFS, XFS, and ZFS cloning default to 16 workers. Byte copying defaults to four concurrent files on Windows and one elsewhere. Btrfs uses its bulk operation. APFS uses a bulk clone followed by native directory-entry enumeration to restore directory timestamps; known regular files and symbolic links need no additional stat or open.
|
|
64
64
|
|
|
@@ -197,3 +197,111 @@ Run `node scripts/clone-xfs-proof.mjs MOUNT` on a real XFS volume to verify the
|
|
|
197
197
|
Run `node scripts/clone-zfs-proof.mjs MOUNT POOL` on a dedicated, otherwise idle Linux ZFS pool with compression and deduplication disabled. It verifies both `copyTree` and `Root.copyIn` through hashes and changes in the documented `bclonesaved` pool counter. It requires `zfs`, `zpool`, and `findmnt`, including permission to run `zpool sync`. Add `no-reflink` for a pool without block cloning to verify strict refusal and automatic byte fallback. The script creates and removes only its temporary directory; it does not create pools or change their properties.
|
|
198
198
|
|
|
199
199
|
Run `node benchmarks/clone.mjs SOURCE DESTINATION_PARENT` after `pnpm build` to compare one, four, and 16 workers on the same immutable source. Add `3 auto` or `3 never` to measure three samples of ordinary copying, including NTFS destinations. It records copying time separately from fixture preparation and full file-hash verification, and retains its uniquely named output directory for inspection. Prepare Btrfs sources with `createCloneSource` first.
|
|
200
|
+
|
|
201
|
+
## Synchronous guarded file copies
|
|
202
|
+
|
|
203
|
+
`copyRootFileSync` from `@openclaw/fs-safe/advanced` is for synchronous module
|
|
204
|
+
hooks and capture owners. Both roots and file paths are absolute; destination
|
|
205
|
+
parents must exist. The destination must be absent, and is created exclusively.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { copyRootFileSync } from "@openclaw/fs-safe/advanced";
|
|
209
|
+
|
|
210
|
+
using copied = copyRootFileSync({
|
|
211
|
+
source: { rootPath: "/srv/plugins", absolutePath: "/srv/plugins/plugin.js" },
|
|
212
|
+
destination: { rootPath: "/srv/capture", absolutePath: "/srv/capture/plugin.js" },
|
|
213
|
+
clone: "auto",
|
|
214
|
+
maxBytes: 16 * 1024 * 1024,
|
|
215
|
+
mode: 0o600,
|
|
216
|
+
});
|
|
217
|
+
// Hash or read copied.fd: this is the owned destination, never a reopened path.
|
|
218
|
+
console.log(copied.bytes, copied.method, copied.identity, copied.sourceIdentity);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`CopyRootFileSyncOptions` selects the source and destination, `clone`, `maxBytes`,
|
|
222
|
+
`mode`, `preserveSourceMode`, `sourceHardlinks`, and optional `expectedSourceIdentity`.
|
|
223
|
+
`CopiedRootFileSync` owns a
|
|
224
|
+
readable/writable `fd`, with idempotent `close()` and `Symbol.dispose`, and returns
|
|
225
|
+
`path`, `bytes`, `method` (`"clone"`, `"copy-file-range"`, or `"copy"`), and exact
|
|
226
|
+
bigint destination `identity` and `sourceIdentity` (`dev`, `ino`). Both identities
|
|
227
|
+
are readonly and frozen. Closing releases the descriptor, not the file.
|
|
228
|
+
|
|
229
|
+
Callers that already pinned a source can pass `expectedSourceIdentity: { dev, ino }`
|
|
230
|
+
from `fs.fstatSync(pinnedSourceFd, { bigint: true })`. fs-safe still admits and
|
|
231
|
+
opens its own source descriptor, and compares its exact bigint identity before
|
|
232
|
+
creating any destination. A mismatch throws `FsSafeError("path-mismatch")`.
|
|
233
|
+
Keep the caller's descriptor open across the call; it remains caller-owned and
|
|
234
|
+
its cursor is unchanged. Do not derive the expectation from numeric stat fields,
|
|
235
|
+
which may have lost identity bits. Omitting the option preserves ordinary source
|
|
236
|
+
admission. Every successful copy method returns `sourceIdentity` for the source
|
|
237
|
+
actually copied, verified current again at the end of the operation. This binds
|
|
238
|
+
identity only; keep any existing source fingerprint/content and destination
|
|
239
|
+
receipt checks for concurrent edits and later replacements.
|
|
240
|
+
|
|
241
|
+
Clone policy matches `Root.copyIn`: the default is `"never"`; `"auto"` tries
|
|
242
|
+
native cloning, then bounded copy offload and byte copying on capability failure;
|
|
243
|
+
`"always"` requires cloning or throws `unsupported-platform`. APFS cloning
|
|
244
|
+
creates the guarded destination name from the admitted source descriptor. Linux
|
|
245
|
+
uses FICLONE and copy_file_range. Windows and native-disabled operation copy
|
|
246
|
+
bytes; they cannot satisfy `"always"`.
|
|
247
|
+
|
|
248
|
+
The source descriptor remains open through before/after source identity checks.
|
|
249
|
+
Symlink components are rejected; source hardlinks are rejected unless
|
|
250
|
+
`sourceHardlinks: "allow"` is selected. The byte limit is an EOF bound, not a
|
|
251
|
+
prefix length: overflow throws `too-large`. Omitted limits are unlimited. An
|
|
252
|
+
explicit mode wins over `preserveSourceMode`; otherwise new files use `0o600`
|
|
253
|
+
subject to the process umask, consistently with `copyIn`.
|
|
254
|
+
|
|
255
|
+
Failures attempt identity-checked removal of the owned destination and preserve
|
|
256
|
+
replacement entries. This helper creates directly at the destination name, so
|
|
257
|
+
other processes may observe partial bytes until it returns. It does not promise
|
|
258
|
+
crash durability or a coherent snapshot of concurrent in-place source edits.
|
|
259
|
+
Use a private capture directory and keep sources unchanged while capturing.
|
|
260
|
+
|
|
261
|
+
### Batches of synchronous guarded copies
|
|
262
|
+
|
|
263
|
+
`createRootFileCopyBatchSync()` on `advanced` returns a disposable
|
|
264
|
+
`RootFileCopyBatchSync`. Its `copyFile(options)` takes the same
|
|
265
|
+
`CopyRootFileSyncOptions` and returns the same owned `CopiedRootFileSync` as
|
|
266
|
+
`copyRootFileSync`:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
import { createRootFileCopyBatchSync } from "@openclaw/fs-safe/advanced";
|
|
270
|
+
|
|
271
|
+
using batch = createRootFileCopyBatchSync();
|
|
272
|
+
for (const name of ["plugin.js", "package.json"]) {
|
|
273
|
+
using copied = batch.copyFile({
|
|
274
|
+
source: { rootPath: "/srv/plugins", absolutePath: `/srv/plugins/${name}` },
|
|
275
|
+
destination: { rootPath: "/srv/capture", absolutePath: `/srv/capture/${name}` },
|
|
276
|
+
clone: "auto",
|
|
277
|
+
maxBytes: 16 * 1024 * 1024,
|
|
278
|
+
});
|
|
279
|
+
console.log(copied.bytes, copied.sourceIdentity);
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
On canonical POSIX paths, the batch retains root and ancestor directory
|
|
284
|
+
identities across files. Every use still checks those identities without
|
|
285
|
+
following directory symlinks; root and parent canonical paths are rechecked.
|
|
286
|
+
Source opens, source and destination file identities, hardlink policy, exclusive
|
|
287
|
+
creation, bounds, permissions, and cleanup are checked separately for every file.
|
|
288
|
+
No source descriptor, file contents, or successful file-admission result is cached.
|
|
289
|
+
An ancestor replacement rejects even when the leaf parent has not changed.
|
|
290
|
+
Windows and noncanonical path spellings keep the full per-file resolver and its
|
|
291
|
+
alias handling. There is no option to bypass validation.
|
|
292
|
+
|
|
293
|
+
Keep the batch scoped to one capture or materialization, with unchanged source
|
|
294
|
+
and destination directory topology. `close()` and `Symbol.dispose` idempotently
|
|
295
|
+
release its retained metadata; later calls reject with `invalid-path`. Returned
|
|
296
|
+
file descriptors remain independently caller-owned. Successful earlier files
|
|
297
|
+
are not rolled back if a later copy fails. A failed copy keeps the ordinary
|
|
298
|
+
identity-checked cleanup contract; the batch is not an atomic tree transaction
|
|
299
|
+
or a coherent snapshot of concurrent edits. The single-file helper also reuses
|
|
300
|
+
directory admissions for the duration of its one copy.
|
|
301
|
+
|
|
302
|
+
Both APIs reuse the freshly checked canonical root during initial POSIX path
|
|
303
|
+
admission. This avoids repeatedly resolving every absolute root component while
|
|
304
|
+
retaining lexical traversal and the before/after root and parent identity checks.
|
|
305
|
+
Windows and root aliases retain the ordinary resolver, including its handling
|
|
306
|
+
of dot segments in symlink targets. No caller changes are needed for the
|
|
307
|
+
single-file improvement.
|