@openclaw/openshell-sandbox 2026.10.5 → 2026.10.7
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 +16 -18
- package/node_modules/@openclaw/fs-safe/CHANGELOG.md +48 -0
- package/node_modules/@openclaw/fs-safe/README.md +14 -4
- package/node_modules/@openclaw/fs-safe/dist/archive-merge.js +28 -3
- package/node_modules/@openclaw/fs-safe/dist/archive-parser.wasm +0 -0
- package/node_modules/@openclaw/fs-safe/dist/archive-read.js +11 -5
- package/node_modules/@openclaw/fs-safe/dist/archive-zip-loader.js +4 -2
- package/node_modules/@openclaw/fs-safe/dist/archive.js +2 -2
- package/node_modules/@openclaw/fs-safe/dist/clone-metadata.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/copy-file-input.d.ts +2 -0
- package/node_modules/@openclaw/fs-safe/dist/copy-file-input.js +4 -3
- package/node_modules/@openclaw/fs-safe/dist/copy-link.d.ts +26 -0
- package/node_modules/@openclaw/fs-safe/dist/copy-link.js +169 -0
- package/node_modules/@openclaw/fs-safe/dist/copy-metadata.d.ts +9 -0
- package/node_modules/@openclaw/fs-safe/dist/copy-metadata.js +39 -0
- package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/copy-tree-portable.js +3 -6
- package/node_modules/@openclaw/fs-safe/dist/copy.js +3 -3
- package/node_modules/@openclaw/fs-safe/dist/darwin-acl.d.ts +12 -0
- package/node_modules/@openclaw/fs-safe/dist/darwin-acl.js +29 -0
- package/node_modules/@openclaw/fs-safe/dist/directory-mode-node.d.ts +3 -0
- package/node_modules/@openclaw/fs-safe/dist/directory-mode-node.js +27 -2
- package/node_modules/@openclaw/fs-safe/dist/entry-publication.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/file-handle-transfer.js +4 -2
- package/node_modules/@openclaw/fs-safe/dist/file-hash.js +4 -3
- package/node_modules/@openclaw/fs-safe/dist/file-lock.d.ts +1 -0
- package/node_modules/@openclaw/fs-safe/dist/file-lock.js +1 -0
- package/node_modules/@openclaw/fs-safe/dist/file-write-lease.d.ts +9 -0
- package/node_modules/@openclaw/fs-safe/dist/file-write-lease.js +42 -0
- package/node_modules/@openclaw/fs-safe/dist/guest-native-python.js +3 -0
- package/node_modules/@openclaw/fs-safe/dist/hardlink-fallback.d.ts +1 -0
- package/node_modules/@openclaw/fs-safe/dist/hardlink-fallback.js +5 -0
- package/node_modules/@openclaw/fs-safe/dist/json-durable-queue-ownership.js +16 -3
- package/node_modules/@openclaw/fs-safe/dist/native-binding.d.ts +57 -18
- package/node_modules/@openclaw/fs-safe/dist/native-directory-observation.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/native-move-fallback.d.ts +1 -1
- package/node_modules/@openclaw/fs-safe/dist/native-move-fallback.js +2 -0
- package/node_modules/@openclaw/fs-safe/dist/native-operations.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/native-parent-admission.d.ts +1 -1
- package/node_modules/@openclaw/fs-safe/dist/native-parent-admission.js +2 -0
- package/node_modules/@openclaw/fs-safe/dist/native-pinned-write-windows.d.ts +1 -1
- package/node_modules/@openclaw/fs-safe/dist/native-pinned-write-windows.js +19 -1
- package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.d.ts +4 -1
- package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.js +18 -0
- package/node_modules/@openclaw/fs-safe/dist/native-policy-parent.d.ts +1 -1
- package/node_modules/@openclaw/fs-safe/dist/native-policy-parent.js +3 -0
- 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 +3 -0
- package/node_modules/@openclaw/fs-safe/dist/native.d.ts +5 -3
- package/node_modules/@openclaw/fs-safe/dist/native.js +25 -6
- package/node_modules/@openclaw/fs-safe/dist/opened-realpath.js +9 -1
- package/node_modules/@openclaw/fs-safe/dist/overwrite-file-handle.js +4 -1
- package/node_modules/@openclaw/fs-safe/dist/permissions-public.d.ts +1 -0
- package/node_modules/@openclaw/fs-safe/dist/permissions-public.js +1 -0
- package/node_modules/@openclaw/fs-safe/dist/permissions-windows.js +36 -47
- package/node_modules/@openclaw/fs-safe/dist/pinned-write-input.js +2 -0
- package/node_modules/@openclaw/fs-safe/dist/pinned-write-staged.js +3 -0
- package/node_modules/@openclaw/fs-safe/dist/pinned-write-types.d.ts +2 -1
- package/node_modules/@openclaw/fs-safe/dist/pinned-write.js +5 -3
- package/node_modules/@openclaw/fs-safe/dist/pipe.d.ts +9 -0
- package/node_modules/@openclaw/fs-safe/dist/pipe.js +26 -0
- package/node_modules/@openclaw/fs-safe/dist/private-producer-handoff.js +2 -5
- package/node_modules/@openclaw/fs-safe/dist/publish-copy-stage.js +92 -10
- package/node_modules/@openclaw/fs-safe/dist/publish-file.d.ts +1 -1
- package/node_modules/@openclaw/fs-safe/dist/publish-file.js +12 -11
- package/node_modules/@openclaw/fs-safe/dist/root-copy.d.ts +18 -0
- package/node_modules/@openclaw/fs-safe/dist/root-copy.js +128 -0
- package/node_modules/@openclaw/fs-safe/dist/root-create-native.js +12 -4
- package/node_modules/@openclaw/fs-safe/dist/root-impl.js +6 -92
- package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/root-options.d.ts +2 -0
- package/node_modules/@openclaw/fs-safe/dist/root-remove-native.js +8 -1
- package/node_modules/@openclaw/fs-safe/dist/sibling-staged-file.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-retained.js +1 -1
- package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-target.js +8 -1
- package/node_modules/@openclaw/fs-safe/dist/test-hooks.d.ts +1 -0
- package/node_modules/@openclaw/fs-safe/dist/test-hooks.js +1 -0
- package/node_modules/@openclaw/fs-safe/dist/watch-native.js +2 -2
- package/node_modules/@openclaw/fs-safe/dist/windows-security-bridge.cs +11 -5
- package/node_modules/@openclaw/fs-safe/dist/windows-security-command.js +9 -1
- package/node_modules/@openclaw/fs-safe/dist/windows-test-hooks.d.ts +16 -0
- package/node_modules/@openclaw/fs-safe/dist/windows-test-hooks.js +51 -0
- package/node_modules/@openclaw/fs-safe/docs/archive.md +16 -0
- package/node_modules/@openclaw/fs-safe/docs/contributing.md +35 -5
- package/node_modules/@openclaw/fs-safe/docs/copy.md +22 -0
- package/node_modules/@openclaw/fs-safe/docs/durability.md +4 -2
- package/node_modules/@openclaw/fs-safe/docs/errors.md +8 -0
- package/node_modules/@openclaw/fs-safe/docs/guest.md +2 -0
- package/node_modules/@openclaw/fs-safe/docs/install.md +13 -5
- package/node_modules/@openclaw/fs-safe/docs/migrating-to-0.5.md +5 -5
- package/node_modules/@openclaw/fs-safe/docs/native-helper.md +15 -5
- package/node_modules/@openclaw/fs-safe/docs/native.md +51 -2
- package/node_modules/@openclaw/fs-safe/docs/pipe.md +81 -0
- package/node_modules/@openclaw/fs-safe/docs/platform-capabilities.md +63 -0
- package/node_modules/@openclaw/fs-safe/docs/quickstart.md +23 -3
- package/node_modules/@openclaw/fs-safe/docs/reading.md +7 -1
- package/node_modules/@openclaw/fs-safe/docs/secret-file.md +5 -5
- package/node_modules/@openclaw/fs-safe/docs/sidecar-lock.md +31 -13
- package/node_modules/@openclaw/fs-safe/docs/store.md +11 -0
- package/node_modules/@openclaw/fs-safe/docs/types.md +45 -80
- package/node_modules/@openclaw/fs-safe/docs/writing.md +121 -14
- package/node_modules/@openclaw/fs-safe/package.json +18 -10
- 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-freebsd-arm64/fs-safe-native.node +0 -0
- package/node_modules/@openclaw/fs-safe-freebsd-arm64/package.json +29 -0
- package/node_modules/@openclaw/fs-safe-freebsd-x64/fs-safe-native.node +0 -0
- package/node_modules/@openclaw/fs-safe-freebsd-x64/package.json +29 -0
- 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-arm64-msvc/fs-safe-native.node +0 -0
- package/node_modules/@openclaw/fs-safe-win32-arm64-msvc/package.json +29 -0
- 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/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
|
|
|
8
8
|
import { GUEST_FILESYSTEM_PYTHON } from "@openclaw/fs-safe/guest";
|
|
9
9
|
import { createDeferred, formatPluginConfigIssue, mapPluginConfigIssues } from "openclaw/plugin-sdk/extension-shared";
|
|
10
10
|
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
|
11
|
+
import { lstatIfExists } from "openclaw/plugin-sdk/path-existence-runtime";
|
|
11
12
|
import { FsSafeError, canonicalPathFromExistingAncestor, extractErrorCode } from "openclaw/plugin-sdk/security-runtime";
|
|
12
13
|
import { asOptionalRecord, normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
13
14
|
import { safeParseJson } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
@@ -269,7 +270,7 @@ var OpenShellFsBridge = class {
|
|
|
269
270
|
async stat(params) {
|
|
270
271
|
const target = this.resolveTarget(params);
|
|
271
272
|
const hostPath = target.hostPath;
|
|
272
|
-
const stats = await
|
|
273
|
+
const stats = await lstatIfExists(hostPath, "ENOENT");
|
|
273
274
|
if (!stats) return null;
|
|
274
275
|
await assertLocalPathSafety({
|
|
275
276
|
target,
|
|
@@ -463,14 +464,14 @@ function createExcludeMatcher(excludeDirs) {
|
|
|
463
464
|
return (name) => excluded.has(normalizeLowercaseStringOrEmpty(name));
|
|
464
465
|
}
|
|
465
466
|
const runLimitedFs = pLimit(COPY_TREE_FS_CONCURRENCY);
|
|
466
|
-
async function lstatIfExists(targetPath) {
|
|
467
|
+
async function lstatIfExists$1(targetPath) {
|
|
467
468
|
return await runLimitedFs(fsPromises.lstat, targetPath).catch((error) => {
|
|
468
469
|
if (extractErrorCode(error) === "ENOENT") return null;
|
|
469
470
|
throw error;
|
|
470
471
|
});
|
|
471
472
|
}
|
|
472
473
|
async function reconcileMirrorPath(params) {
|
|
473
|
-
const targetStats = await lstatIfExists(params.targetPath);
|
|
474
|
+
const targetStats = await lstatIfExists$1(params.targetPath);
|
|
474
475
|
if (targetStats && !targetStats.isDirectory() && !targetStats.isFile()) return true;
|
|
475
476
|
const sourceStats = params.sourcePath ? await runLimitedFs(fsPromises.lstat, params.sourcePath) : null;
|
|
476
477
|
const sourceDir = sourceStats?.isDirectory() ? params.sourcePath : void 0;
|
|
@@ -807,11 +808,15 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
807
808
|
await this.ensureSandboxExists();
|
|
808
809
|
const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
|
|
809
810
|
try {
|
|
811
|
+
let root = this.params.remoteWorkspaceDir;
|
|
812
|
+
try {
|
|
813
|
+
root = this.resolveRemoteTarget(workdir).root;
|
|
814
|
+
} catch {}
|
|
810
815
|
const result = await runSshSandboxCommand({
|
|
811
816
|
session: sshSession,
|
|
812
817
|
remoteCommand: buildRemoteWorkdirValidationCommand({
|
|
813
818
|
workdir,
|
|
814
|
-
root
|
|
819
|
+
root
|
|
815
820
|
}),
|
|
816
821
|
allowFailure: true
|
|
817
822
|
});
|
|
@@ -820,13 +825,6 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
820
825
|
await disposeSshSandboxSession(sshSession);
|
|
821
826
|
}
|
|
822
827
|
}
|
|
823
|
-
resolveWorkdirValidationRoot(workdir) {
|
|
824
|
-
try {
|
|
825
|
-
return this.resolveRemoteTarget(workdir).root;
|
|
826
|
-
} catch {
|
|
827
|
-
return this.params.remoteWorkspaceDir;
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
828
|
async validateMirrorWorkdir(workdir) {
|
|
831
829
|
const normalized = normalizeRemotePath(workdir);
|
|
832
830
|
const roots = this.workspaceUploadRoots();
|
|
@@ -851,7 +849,7 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
851
849
|
let local = root.local;
|
|
852
850
|
for (const part of root.local === skillsWorkspaceDir ? ["", ...parts] : parts) {
|
|
853
851
|
local = path.join(local, part);
|
|
854
|
-
if (!(await
|
|
852
|
+
if (!(await lstatIfExists(local, "ENOENT"))?.isDirectory()) return null;
|
|
855
853
|
}
|
|
856
854
|
return normalized;
|
|
857
855
|
}
|
|
@@ -941,7 +939,7 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
941
939
|
await this.ensureSandboxExists();
|
|
942
940
|
await this.maybeSeedRemoteWorkspace();
|
|
943
941
|
const target = this.resolveRemoteTarget(remotePath);
|
|
944
|
-
const stats = await
|
|
942
|
+
const stats = await lstatIfExists(localPath, "ENOENT");
|
|
945
943
|
if (!stats || stats.isSymbolicLink()) {
|
|
946
944
|
await this.removeRemotePath(remotePath, {
|
|
947
945
|
recursive: true,
|
|
@@ -1160,11 +1158,11 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1160
1158
|
async syncSkillsWorkspaceToRemote() {
|
|
1161
1159
|
if (this.params.createParams.cfg.workspaceAccess !== "rw" || !this.params.createParams.skillsWorkspaceDir) return;
|
|
1162
1160
|
const remoteSkillsWorkspaceDir = resolveRemoteMaterializedSkillsWorkspaceDir(this.params.remoteWorkspaceDir);
|
|
1161
|
+
const stats = await lstatIfExists(this.params.createParams.skillsWorkspaceDir, "ENOENT");
|
|
1163
1162
|
await this.runRemoteShellScriptInternal({
|
|
1164
1163
|
script: `${ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT}\nfind "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`,
|
|
1165
1164
|
args: [remoteSkillsWorkspaceDir, this.params.remoteWorkspaceDir]
|
|
1166
1165
|
});
|
|
1167
|
-
const stats = await fsPromises.lstat(this.params.createParams.skillsWorkspaceDir).catch(() => null);
|
|
1168
1166
|
if (!stats?.isDirectory() || stats.isSymbolicLink()) return;
|
|
1169
1167
|
await this.uploadPathToRemote(this.params.createParams.skillsWorkspaceDir, remoteSkillsWorkspaceDir);
|
|
1170
1168
|
}
|
|
@@ -1332,7 +1330,7 @@ async function removeDownloadedWorkspacePath(tmpDir, parts) {
|
|
|
1332
1330
|
let cursor = tmpDir;
|
|
1333
1331
|
for (const [index, part] of parts.entries()) {
|
|
1334
1332
|
const next = path.join(cursor, part);
|
|
1335
|
-
const stats = await
|
|
1333
|
+
const stats = await lstatIfExists(next, "ENOENT");
|
|
1336
1334
|
if (!stats) return;
|
|
1337
1335
|
if (index === parts.length - 1 || stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
1338
1336
|
await fsPromises.rm(next, {
|
|
@@ -1346,9 +1344,9 @@ async function removeDownloadedWorkspacePath(tmpDir, parts) {
|
|
|
1346
1344
|
}
|
|
1347
1345
|
async function moveLocalShadowAside(params) {
|
|
1348
1346
|
const shadowPath = path.join(params.workspaceDir, ...params.relativeParts);
|
|
1349
|
-
const parentStats = await
|
|
1347
|
+
const parentStats = await lstatIfExists(path.dirname(shadowPath), "ENOENT");
|
|
1350
1348
|
if (!parentStats?.isDirectory() || parentStats.isSymbolicLink()) return;
|
|
1351
|
-
const shadowStats = await
|
|
1349
|
+
const shadowStats = await lstatIfExists(shadowPath, "ENOENT");
|
|
1352
1350
|
if (!shadowStats || shadowStats.isSymbolicLink()) return;
|
|
1353
1351
|
const preserveRoot = await fsPromises.mkdtemp(path.join(path.dirname(params.tmpDir), "openclaw-openshell-preserve-"));
|
|
1354
1352
|
let preserved;
|
|
@@ -1374,7 +1372,7 @@ async function restoreLocalShadow(preserved) {
|
|
|
1374
1372
|
const { shadowPath, receipt } = preserved;
|
|
1375
1373
|
const assertBackup = () => assertPreservedShadowIdentity(receipt);
|
|
1376
1374
|
const parentPath = path.dirname(shadowPath);
|
|
1377
|
-
const parentStats = await
|
|
1375
|
+
const parentStats = await lstatIfExists(parentPath, "ENOENT");
|
|
1378
1376
|
if (parentStats?.isSymbolicLink()) throw new Error(`Refusing to restore workspace shadow through symlink parent: ${parentPath}`);
|
|
1379
1377
|
if (parentStats && !parentStats.isDirectory()) {
|
|
1380
1378
|
assertBackup();
|
|
@@ -2,6 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.26.0 - 2026-10-10
|
|
6
|
+
|
|
7
|
+
### Highlights
|
|
8
|
+
|
|
9
|
+
- **New platforms:** native packages for FreeBSD x64/arm64 and Windows ARM64. ([#905](https://github.com/openclaw/fs-safe/pull/905), [#893](https://github.com/openclaw/fs-safe/pull/893))
|
|
10
|
+
- **Native capabilities:** anonymous pipes, Linux write leases, Darwin ACL inspection, ReFS block cloning, and metadata- and link-preserving `Root.copyIn`. ([#892](https://github.com/openclaw/fs-safe/pull/892), [#896](https://github.com/openclaw/fs-safe/pull/896), [#920](https://github.com/openclaw/fs-safe/pull/920), [#921](https://github.com/openclaw/fs-safe/pull/921))
|
|
11
|
+
- **Android/Termux publication:** staged no-replace copies and writes work when the OS refuses hard links. ([#888](https://github.com/openclaw/fs-safe/pull/888))
|
|
12
|
+
- **Performance:** faster deep archive extraction, borrowed-handle copies and overwrites, sidecar locks, and Linux descriptor verification, with identity checks retained. ([#912](https://github.com/openclaw/fs-safe/pull/912), [#906](https://github.com/openclaw/fs-safe/pull/906), [#902](https://github.com/openclaw/fs-safe/pull/902), [#901](https://github.com/openclaw/fs-safe/pull/901), [#899](https://github.com/openclaw/fs-safe/pull/899))
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
- **ReFS file copies:** support guarded `Root.copyIn` block cloning on Windows ReFS and Dev Drive, with exclusive publication, cancellation cleanup, and automatic byte-copy fallback for unavailable clone capabilities. ([#920](https://github.com/openclaw/fs-safe/pull/920))
|
|
17
|
+
- **Guarded file copying:** add `Root.copyIn` metadata preservation and exclusive literal-link copying, including dangling Windows file and directory links; keep permission selection separate from timestamps and attributes. ([#921](https://github.com/openclaw/fs-safe/pull/921))
|
|
18
|
+
|
|
19
|
+
- **Native filesystem capabilities:** add Linux write leases on caller-owned descriptors, no-follow Darwin ACL inspection with separate file and directory inheritance facts, and Windows sharing-lock, attribute, and physical-extent test fixtures. ([#896](https://github.com/openclaw/fs-safe/pull/896))
|
|
20
|
+
- **Anonymous pipes:** add `@openclaw/fs-safe/pipe` with native-owned, reopenable close-on-exec pipe ends for one-shot child input on POSIX, including safe stream closure in Workers. ([#892](https://github.com/openclaw/fs-safe/pull/892))
|
|
21
|
+
|
|
22
|
+
### Fixes
|
|
23
|
+
|
|
24
|
+
- **Windows creation diagnostics:** retain native Win32 error numbers through private directory and file creation failures, including the command fallback, without changing semantic error codes. ([#919](https://github.com/openclaw/fs-safe/pull/919))
|
|
25
|
+
- **FreeBSD native packages:** add x64 and arm64 packages for FreeBSD 14.4+, with native anonymous pipes and canonicalization, and explicit capability absence for unported operations. ([#905](https://github.com/openclaw/fs-safe/pull/905))
|
|
26
|
+
- **Partial native bindings:** select guarded fallbacks when an operation's native capabilities are absent, and report typed capability errors in required mode before mutation. ([#905](https://github.com/openclaw/fs-safe/pull/905))
|
|
27
|
+
- **Android/Termux publication:** publish staged no-replace copies and writes when hardlinks are refused, using a verified empty placeholder before atomic replacement; include `EACCES` in the public hardlink-to-copy fallback classifier. Reported in [openclaw/openclaw#167791](https://github.com/openclaw/openclaw/issues/167791). ([#888](https://github.com/openclaw/fs-safe/pull/888))
|
|
28
|
+
- **Native ZIP names:** preserve CP437 decoding for unflagged legacy filenames after the ZIP 9 upgrade, including collision rejection and bounded entry selection; continue rejecting invalid Unicode Path checksums and encodings before decoder normalization. ([#885](https://github.com/openclaw/fs-safe/pull/885))
|
|
29
|
+
- **Native ZIP Unicode fields:** retain rejection of invalid Unicode Comment CRCs and UTF-8 before callbacks or bounded reads, and validate sequential Unicode fields in decoder order without repeated hashing of the original name. ([#887](https://github.com/openclaw/fs-safe/pull/887))
|
|
30
|
+
- **Portable ZIP extraction:** accept safe relative root-directory markers such as `./` from libarchive and other producers without turning them into absolute paths; root markers remain skipped before callbacks and publication. ([#886](https://github.com/openclaw/fs-safe/pull/886))
|
|
31
|
+
- **Durable JSON queues on Android/Termux:** claim, failed-entry quarantine, and retirement recovery report typed `helper-unavailable` errors when hardlinks are refused, preserving pending, claimed, and retirement evidence for retry. ([#889](https://github.com/openclaw/fs-safe/pull/889))
|
|
32
|
+
- **Windows exclusive publication:** preserve dangling destination symlinks when a refused hard link falls back to placeholder publication. ([#894](https://github.com/openclaw/fs-safe/pull/894))
|
|
33
|
+
- **Native mkdir collisions:** `Root.mkdir` consistently reports `not-file` for an existing regular-file destination in native require mode. ([#895](https://github.com/openclaw/fs-safe/pull/895))
|
|
34
|
+
- **Guest directory publication:** report a changed source identity instead of a false destination collision when a Linux fallback rename refuses a substituted source. ([#898](https://github.com/openclaw/fs-safe/pull/898))
|
|
35
|
+
- **Native recursive removal:** honor a zero entry budget before ignoring a missing target, including with `force`. ([#904](https://github.com/openclaw/fs-safe/pull/904))
|
|
36
|
+
|
|
37
|
+
### Performance
|
|
38
|
+
|
|
39
|
+
- **Linux opened-file verification:** read ordinary procfs descriptor paths directly instead of walking realpath, keeping identity checks and deleted-path canonicalization; `readLocalFileSafely` is 25–28% faster on deep paths. ([#899](https://github.com/openclaw/fs-safe/pull/899))
|
|
40
|
+
- **Borrowed handles and locks:** keep descriptor metadata inspection off the asynchronous I/O queue for small overwrites (26–28% faster) and copies (31–34% faster), and skip a redundant parent `mkdir` when acquiring sidecar locks (15–18% faster). ([#901](https://github.com/openclaw/fs-safe/pull/901), [#902](https://github.com/openclaw/fs-safe/pull/902), [#906](https://github.com/openclaw/fs-safe/pull/906))
|
|
41
|
+
- **Deep archive extraction:** native extraction verifies every retained ancestor through its open descriptor instead of repeated realpath walks: about 16% faster at depth 8 and 5–6% at depth 4, with every ancestor substitution check retained. ([#912](https://github.com/openclaw/fs-safe/pull/912))
|
|
42
|
+
|
|
43
|
+
### Docs and Tooling
|
|
44
|
+
|
|
45
|
+
- **Docs accuracy:** correct native-mode migration guidance for removed Python settings and bundled TAR fallbacks, clarify Root streaming ownership, create-only visibility, and Linux no-clobber move fallback limits, and fix the JSON-store and stale-lock examples so denied process probes keep live locks; README and docs examples are now type-checked in CI. ([#897](https://github.com/openclaw/fs-safe/pull/897), [#900](https://github.com/openclaw/fs-safe/pull/900), [#903](https://github.com/openclaw/fs-safe/pull/903))
|
|
46
|
+
|
|
47
|
+
### Compatibility
|
|
48
|
+
|
|
49
|
+
- **Windows ARM64:** add a native ARM64 package with real Windows 11 ARM64 and x64-emulation validation, including private ACL creation and Worker descriptor ownership. ([#893](https://github.com/openclaw/fs-safe/pull/893))
|
|
50
|
+
|
|
51
|
+
- **Native ZIP metadata:** invalid UTF-8-flagged names now reject instead of decoding lossily or falling back to CP437. Public ZIP extraction, preflight, and reads already reject these names during physical admission. ([#885](https://github.com/openclaw/fs-safe/pull/885))
|
|
52
|
+
|
|
5
53
|
## 0.25.0 - 2026-10-08
|
|
6
54
|
|
|
7
55
|
### Highlights
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.
|
|
12
12
|
|
|
13
|
+
Platform-specific additions include [Linux write leases, Darwin ACL inspection,
|
|
14
|
+
and Windows test fixtures](docs/platform-capabilities.md).
|
|
15
|
+
|
|
13
16
|
Think Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std), but for Node. Hand `root()` a trusted directory and you get back a handle whose every method resolves relative paths against it and defends against `..`, symlink swaps, hardlink aliases, and TOCTOU rename races. The exact containment strength is reported per mechanism: Linux `openat2` opens are kernel-atomic; guarded Linux fallback, macOS, Windows, and JavaScript paths are best-effort.
|
|
14
17
|
|
|
15
18
|
```ts
|
|
@@ -113,9 +116,14 @@ operation; per-call options handle exceptions. See the [Root reference](docs/roo
|
|
|
113
116
|
|
|
114
117
|
`write()` replaces contents by default. Use `create()` or `overwrite: false`
|
|
115
118
|
when an existing destination should be an error. `move()` defaults to no clobber
|
|
116
|
-
and requires native support
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
and requires native support. On Linux, `auto` can replace a concurrently created
|
|
120
|
+
empty directory if no-replace rename is unsupported; use native `require` for
|
|
121
|
+
strict refusal. See the [move fallback contract](docs/writing.md#fs-move-from-to-options).
|
|
122
|
+
Pass `overwrite: true` when replacement is intended.
|
|
123
|
+
|
|
124
|
+
Staged JavaScript copies and creates also work when hardlinks are refused
|
|
125
|
+
(including Android/Termux), using a verified empty placeholder before atomic
|
|
126
|
+
replacement. See the [visibility and concurrency tradeoffs](docs/writing.md#publication-without-hardlinks).
|
|
119
127
|
|
|
120
128
|
See [Writing](docs/writing.md) for copy sources, mutation authority, symlink
|
|
121
129
|
policy, writable handles, and bounded removal, and [Creation](docs/creation.md)
|
|
@@ -159,6 +167,8 @@ and error exports. Prefer focused subpaths when a consumer needs a narrower
|
|
|
159
167
|
contract. Low-level helpers that OpenClaw needs to compose higher-level APIs are grouped under
|
|
160
168
|
`@openclaw/fs-safe/advanced` instead of being separate public leaf contracts.
|
|
161
169
|
|
|
170
|
+
Use [`@openclaw/fs-safe/pipe`](docs/pipe.md) for native-owned, reopenable anonymous pipes and one-shot child input.
|
|
171
|
+
|
|
162
172
|
See the [complete subpath catalogue](docs/install.md#subpath-exports) for every entry point and its contents.
|
|
163
173
|
|
|
164
174
|
## Failure semantics in the name
|
|
@@ -236,7 +246,7 @@ updates:
|
|
|
236
246
|
import { fileStore } from "@openclaw/fs-safe/store";
|
|
237
247
|
|
|
238
248
|
const files = fileStore({ rootDir: "/safe/workspace/state", private: true });
|
|
239
|
-
const store = files.json("settings.json", { lock: true });
|
|
249
|
+
const store = files.json<{ enabled: boolean }>("settings.json", { lock: true });
|
|
240
250
|
|
|
241
251
|
await store.updateOr({ enabled: false }, (current) => ({ ...current, enabled: true }));
|
|
242
252
|
```
|
|
@@ -17,6 +17,7 @@ import { syncFileBestEffortSync } from "./file-sync.js";
|
|
|
17
17
|
import { finalizeArchivePublication } from "./archive-durability.js";
|
|
18
18
|
import { assertNoWindowsPathAlias } from "./windows-path-alias.js";
|
|
19
19
|
import { inspectFileIdentitySync } from "./strict-file-identity.js";
|
|
20
|
+
import { ArchiveSecurityError } from "./archive-errors.js";
|
|
20
21
|
export async function withStagedArchivePublication(params, extract) {
|
|
21
22
|
await withStagedArchiveDestination({
|
|
22
23
|
destinationRealDir: params.destinationGuard.realPath,
|
|
@@ -101,6 +102,23 @@ async function mergeTree(params, publication, durable = true, entryUmask = 0) {
|
|
|
101
102
|
assertDirectoryIdentityGuard(destinationGuard);
|
|
102
103
|
check();
|
|
103
104
|
for (const ancestor of ancestors) {
|
|
105
|
+
// Verify every retained ancestor, including its current name and physical path.
|
|
106
|
+
// Shallow chains cost less to verify directly than crossing the native bridge.
|
|
107
|
+
if (ancestors.length >= 4 && ancestor.owner.verifyCanonical) {
|
|
108
|
+
let verified;
|
|
109
|
+
try {
|
|
110
|
+
verified = await ancestor.owner.verifyCanonical();
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof FsSafeError || isNotFoundPathError(error)) {
|
|
114
|
+
throw createArchiveSymlinkTraversalError(path.relative(destinationDir, ancestor.guard.dir));
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
check();
|
|
119
|
+
if (verified)
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
104
122
|
assertDirectoryIdentityGuard(ancestor.guard);
|
|
105
123
|
check();
|
|
106
124
|
await ancestor.owner.verify(check);
|
|
@@ -156,15 +174,22 @@ async function mergeTree(params, publication, durable = true, entryUmask = 0) {
|
|
|
156
174
|
await ownExtractionDestinationMutation(params.deadline, async () => {
|
|
157
175
|
await assertGuards();
|
|
158
176
|
assertSourceFrontier();
|
|
159
|
-
const
|
|
177
|
+
const guard = await createDirectoryIdentityGuard(destinationPath).catch((error) => {
|
|
178
|
+
if (error instanceof ArchiveSecurityError && error.code === "destination-symlink") {
|
|
179
|
+
throw createArchiveSymlinkTraversalError(originalPath);
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
});
|
|
183
|
+
check();
|
|
184
|
+
const owner = await pinNodeDirectoryForMode(destinationPath, {
|
|
185
|
+
expectedIdentity: guard.stat, canonicalPath: guard.realPath,
|
|
186
|
+
}).catch((error) => {
|
|
160
187
|
if (error instanceof FsSafeError && (error.code === "not-file" || error.code === "path-mismatch")) {
|
|
161
188
|
throw createArchiveSymlinkTraversalError(originalPath);
|
|
162
189
|
}
|
|
163
190
|
throw error;
|
|
164
191
|
});
|
|
165
192
|
try {
|
|
166
|
-
check();
|
|
167
|
-
const guard = await createDirectoryIdentityGuard(destinationPath);
|
|
168
193
|
check();
|
|
169
194
|
await owner.verify(check);
|
|
170
195
|
ancestors.push({ guard, owner });
|
|
Binary file
|
|
@@ -17,7 +17,7 @@ import { FsSafeError } from "./errors.js";
|
|
|
17
17
|
import { inspectFileIdentity } from "./strict-file-identity.js";
|
|
18
18
|
import { resolveReadOpenFlags } from "./read-open-flags.js";
|
|
19
19
|
import { realpathSync } from "./realpath.js";
|
|
20
|
-
import { getNativeBinding } from "./native.js";
|
|
20
|
+
import { assertNativeCapabilities, getNativeBinding } from "./native.js";
|
|
21
21
|
import { admitZipBuffer } from "./archive-zip-admission.js";
|
|
22
22
|
import { validateNativeZipManifest } from "./archive-zip-manifest.js";
|
|
23
23
|
import { resolveExtractLimits, resolveTarMeterLimits } from "./archive-limits.js";
|
|
@@ -199,9 +199,15 @@ async function readNativeBufferEntry(native, buffer, kind, requested, displayPat
|
|
|
199
199
|
try {
|
|
200
200
|
const signal = new AbortController().signal;
|
|
201
201
|
const limits = resolveTarMeterLimits();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
202
|
+
let reader;
|
|
203
|
+
if (kind === "zip") {
|
|
204
|
+
assertNativeCapabilities(native, "openZipBufferNative");
|
|
205
|
+
reader = await native.openZipBufferNative(buffer, limits, AbortSignal.any([signal]));
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
assertNativeCapabilities(native, "openTarBufferNative");
|
|
209
|
+
reader = await native.openTarBufferNative(buffer, kind, limits, AbortSignal.any([signal]));
|
|
210
|
+
}
|
|
205
211
|
const manifest = reader.entries;
|
|
206
212
|
if (kind === "zip")
|
|
207
213
|
validateNativeZipManifest(manifest, zipEntries);
|
|
@@ -227,7 +233,7 @@ export async function readArchiveEntry(archivePath, entryPath, options) {
|
|
|
227
233
|
const zipEntries = [];
|
|
228
234
|
if (kind === "zip")
|
|
229
235
|
admitZipBuffer(buffer, resolveExtractLimits(), entry => { zipEntries.push(entry); });
|
|
230
|
-
const native = getNativeBinding();
|
|
236
|
+
const native = getNativeBinding(kind === "zip" ? "openZipBufferNative" : "openTarBufferNative");
|
|
231
237
|
if (native)
|
|
232
238
|
return await readNativeBufferEntry(native, buffer, kind, requestedEntry, entryPath, maxBytes, zipEntries);
|
|
233
239
|
return kind === "zip" ? await readZipEntry(buffer, requestedEntry, maxBytes, zipEntries)
|
|
@@ -118,11 +118,13 @@ export async function loadAdmittedZipArchive(buffer, admitted) {
|
|
|
118
118
|
const entries = new Map();
|
|
119
119
|
for (const [name, entry, physical] of normalized) {
|
|
120
120
|
const appendSlash = physical.kind === "directory" && !name.endsWith("/");
|
|
121
|
-
|
|
121
|
+
// JSZip reduces a relative root marker such as "./" to an empty name.
|
|
122
|
+
// Keep its directory spelling relative when restoring the terminal slash.
|
|
123
|
+
const normalizedName = appendSlash ? `${name || "."}/` : name;
|
|
122
124
|
if (Object.hasOwn(files, normalizedName))
|
|
123
125
|
collision();
|
|
124
126
|
if (appendSlash) {
|
|
125
|
-
entry.name = `${entry.name}/`;
|
|
127
|
+
entry.name = `${entry.name || "."}/`;
|
|
126
128
|
}
|
|
127
129
|
files[normalizedName] = entry;
|
|
128
130
|
entries.set(physical.portableKey, createAdmittedZipEntry(entry, normalizedName, physical));
|
|
@@ -154,7 +154,7 @@ export async function extractArchive(params) {
|
|
|
154
154
|
const label = kind === "zip" ? "extract zip" : "extract tar";
|
|
155
155
|
const limits = resolveExtractLimits(params.limits);
|
|
156
156
|
const tarLimits = resolveTarMeterLimits(limits);
|
|
157
|
-
const native = getNativeBinding();
|
|
157
|
+
const native = getNativeBinding("inspectArchiveNative", "extractArchiveNative");
|
|
158
158
|
assertNoWindowsPathAlias(archivePath, "filesystem", "archive source uses a Windows filesystem namespace alias");
|
|
159
159
|
assertNoWindowsPathAlias(destDir, "filesystem", "archive destination uses a Windows filesystem namespace alias");
|
|
160
160
|
// Read the declared public fields before private adapters copy options: class
|
|
@@ -282,7 +282,7 @@ export async function inspectTarArchive(params) {
|
|
|
282
282
|
const onFiltered = resolveArchiveFilteredEntryPolicy(params.onFiltered);
|
|
283
283
|
const limits = resolveExtractLimits(params.limits);
|
|
284
284
|
const tarLimits = resolveTarMeterLimits(limits);
|
|
285
|
-
const native = getNativeBinding();
|
|
285
|
+
const native = getNativeBinding("inspectArchiveNative");
|
|
286
286
|
assertNoWindowsPathAlias(archivePath, "filesystem", "archive source uses a Windows filesystem namespace alias");
|
|
287
287
|
return await withExtractionDeadline(params.timeoutMs, "inspect tar", async (deadline) => {
|
|
288
288
|
const staged = await stageArchiveFileForExtraction({ archivePath, limits, deadline });
|
|
@@ -6,7 +6,7 @@ import { getNativeBinding, requireNativeBinding } from "./native.js";
|
|
|
6
6
|
*/
|
|
7
7
|
export async function readCloneFileMetadata(files) {
|
|
8
8
|
const paths = files.map(assertAbsolutePathInput);
|
|
9
|
-
const native = process.platform === "darwin" ? requireNativeBinding() : getNativeBinding();
|
|
9
|
+
const native = process.platform === "darwin" ? requireNativeBinding("readCloneFileMetadata") : getNativeBinding("readCloneFileMetadata");
|
|
10
10
|
if (!native)
|
|
11
11
|
return paths.map(() => undefined);
|
|
12
12
|
const results = await native.readCloneFileMetadata(paths);
|
|
@@ -3,12 +3,14 @@ import type { FileHandle } from "node:fs/promises";
|
|
|
3
3
|
import { type CopyCloneMode } from "./copy-policy.js";
|
|
4
4
|
import { type NativeBinding } from "./native.js";
|
|
5
5
|
import { type NativeFileCopyResult } from "./native-binding.js";
|
|
6
|
+
import type { CopyMetadata } from "./copy-metadata.js";
|
|
6
7
|
export type CopyFileInput = {
|
|
7
8
|
kind: "file";
|
|
8
9
|
handle: FileHandle;
|
|
9
10
|
size: number;
|
|
10
11
|
clone: CopyCloneMode;
|
|
11
12
|
signal?: AbortSignal;
|
|
13
|
+
metadata?: CopyMetadata;
|
|
12
14
|
verifySource(): Promise<void>;
|
|
13
15
|
};
|
|
14
16
|
export declare function resolveFileCopyCloneMode(mode?: CopyCloneMode): CopyCloneMode;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs, {} from "node:fs";
|
|
2
2
|
import { resolveCopyCloneMode } from "./copy-policy.js";
|
|
3
3
|
import { FsSafeError } from "./errors.js";
|
|
4
|
-
import { getNativeBinding } from "./native.js";
|
|
4
|
+
import { getNativeBinding, selectNativeBinding } from "./native.js";
|
|
5
5
|
import { captureNativeFdClose } from "./native-binding.js";
|
|
6
6
|
import { inspectFileIdentity } from "./strict-file-identity.js";
|
|
7
7
|
import { transferFileHandle } from "./file-handle-transfer.js";
|
|
@@ -29,7 +29,8 @@ export async function writeCopyFileToFd(fd, input, maxBytes, assertBeforeMutatio
|
|
|
29
29
|
}
|
|
30
30
|
export async function createNativeCopyFile(native, input, parentFd, basename, maxBytes) {
|
|
31
31
|
input.signal?.throwIfAborted();
|
|
32
|
-
|
|
32
|
+
const copier = selectNativeBinding(native, "copyFileExclusive");
|
|
33
|
+
if (!copier) {
|
|
33
34
|
if (input.clone === "always") {
|
|
34
35
|
throw new FsSafeError("helper-unavailable", "native file cloning is unavailable");
|
|
35
36
|
}
|
|
@@ -39,7 +40,7 @@ export async function createNativeCopyFile(native, input, parentFd, basename, ma
|
|
|
39
40
|
const nativeSignal = input.signal ? AbortSignal.any([input.signal]) : undefined;
|
|
40
41
|
try {
|
|
41
42
|
// The caller adopts this descriptor before observing a later cancellation.
|
|
42
|
-
return await
|
|
43
|
+
return await copier.copyFileExclusive(input.handle.fd, parentFd, basename, input.clone, maxBytes !== undefined && Number.isFinite(maxBytes) ? maxBytes : undefined, nativeSignal);
|
|
43
44
|
}
|
|
44
45
|
catch (error) {
|
|
45
46
|
const code = error?.code;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type BigIntStats } from "node:fs";
|
|
2
|
+
import { type AnyAsyncDirectoryGuard } from "./directory-guard.js";
|
|
3
|
+
import type { NativeBinding } from "./native-binding.js";
|
|
4
|
+
import type { PinnedWriteParams } from "./pinned-write-types.js";
|
|
5
|
+
export type CopyLinkInput = {
|
|
6
|
+
kind: "link";
|
|
7
|
+
stageBeforePublish: true;
|
|
8
|
+
sourcePath: string;
|
|
9
|
+
identity: BigIntStats;
|
|
10
|
+
target?: Buffer;
|
|
11
|
+
preserveMetadata: boolean;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
verifySource(): Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
/** Shared by tree byte copying and per-file copying; never resolves the target. */
|
|
16
|
+
export declare function readCopyLinkTarget(source: string): Promise<Buffer>;
|
|
17
|
+
export declare function assertCopyLinkAvailable(): void;
|
|
18
|
+
export declare function admitCopyLink(sourcePath: string, preserveMetadata: boolean, signal?: AbortSignal): Promise<CopyLinkInput | undefined>;
|
|
19
|
+
/** The caller has admitted the destination parent under Root's mutation policy. */
|
|
20
|
+
export declare function copyLinkAtParent(params: PinnedWriteParams, input: CopyLinkInput, parent: AnyAsyncDirectoryGuard, native?: {
|
|
21
|
+
binding: NativeBinding;
|
|
22
|
+
fd: number;
|
|
23
|
+
}): Promise<{
|
|
24
|
+
dev: bigint;
|
|
25
|
+
ino: bigint;
|
|
26
|
+
}>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs, {} from "node:fs";
|
|
3
|
+
import fsp from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { assertAsyncDirectoryGuard, assertSyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
6
|
+
import { FsSafeError } from "./errors.js";
|
|
7
|
+
import { getNativeBinding } from "./native.js";
|
|
8
|
+
import { isFsSafeNativeRequired } from "./native-config.js";
|
|
9
|
+
import { inspectFileIdentitySync } from "./strict-file-identity.js";
|
|
10
|
+
import { timestampSeconds } from "./copy-metadata.js";
|
|
11
|
+
import { syncDirectoryBestEffort } from "./directory-durability.js";
|
|
12
|
+
import { noReplaceUnavailable } from "./native-noreplace.js";
|
|
13
|
+
import { createSuppressedError } from "./suppressed-error.js";
|
|
14
|
+
/** Shared by tree byte copying and per-file copying; never resolves the target. */
|
|
15
|
+
export async function readCopyLinkTarget(source) {
|
|
16
|
+
return await fsp.readlink(source, { encoding: "buffer" });
|
|
17
|
+
}
|
|
18
|
+
export function assertCopyLinkAvailable() {
|
|
19
|
+
const native = getNativeBinding();
|
|
20
|
+
if (process.platform === "darwin" && !native?.createCopySymlink) {
|
|
21
|
+
throw new FsSafeError("helper-unavailable", "exclusive macOS link copying requires the native helper; link(2) follows its source");
|
|
22
|
+
}
|
|
23
|
+
if (process.platform === "win32" && (!native?.copyLinkExclusive || !native.publishCopyLink || !native.removeCopyLink)) {
|
|
24
|
+
throw new FsSafeError("helper-unavailable", "copying Windows links without following them requires the native helper");
|
|
25
|
+
}
|
|
26
|
+
if (native && process.platform !== "win32" && !native.createCopySymlink) {
|
|
27
|
+
throw new FsSafeError("helper-unavailable", "native link copying is unavailable");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function admitCopyLink(sourcePath, preserveMetadata, signal) {
|
|
31
|
+
const parent = await createAsyncDirectoryGuard(path.dirname(sourcePath), { bigint: true });
|
|
32
|
+
sourcePath = path.join(parent.realPath, path.basename(sourcePath));
|
|
33
|
+
const identity = fs.lstatSync(sourcePath, { bigint: true });
|
|
34
|
+
if (!identity.isSymbolicLink())
|
|
35
|
+
return undefined;
|
|
36
|
+
const target = process.platform === "win32" ? undefined : await readCopyLinkTarget(sourcePath);
|
|
37
|
+
const verifySource = async () => {
|
|
38
|
+
signal?.throwIfAborted();
|
|
39
|
+
await assertAsyncDirectoryGuard(parent);
|
|
40
|
+
const current = inspectFileIdentitySync(() => fs.lstatSync(sourcePath, { bigint: true }), identity);
|
|
41
|
+
if (!current.isSymbolicLink() || current.mtimeNs !== identity.mtimeNs || current.ctimeNs !== identity.ctimeNs) {
|
|
42
|
+
throw new FsSafeError("path-mismatch", "copy source link changed");
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
await verifySource();
|
|
46
|
+
return { kind: "link", stageBeforePublish: true, sourcePath, identity, target, preserveMetadata, signal, verifySource };
|
|
47
|
+
}
|
|
48
|
+
/** The caller has admitted the destination parent under Root's mutation policy. */
|
|
49
|
+
export async function copyLinkAtParent(params, input, parent, native) {
|
|
50
|
+
const temporaryName = `.fs-safe-${randomUUID()}.tmp`;
|
|
51
|
+
const temporaryPath = path.join(parent.realPath, temporaryName);
|
|
52
|
+
const destination = path.join(parent.realPath, params.basename);
|
|
53
|
+
let identity;
|
|
54
|
+
let sourceParent;
|
|
55
|
+
let failure;
|
|
56
|
+
const assertStage = () => {
|
|
57
|
+
assertSyncDirectoryGuard(parent);
|
|
58
|
+
const stat = inspectFileIdentitySync(() => fs.lstatSync(temporaryPath, { bigint: true }), identity);
|
|
59
|
+
if (!stat.isSymbolicLink() || stat.nlink !== 1n)
|
|
60
|
+
throw new FsSafeError("path-mismatch", "copy link stage changed");
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
await input.verifySource();
|
|
64
|
+
await assertAsyncDirectoryGuard(parent);
|
|
65
|
+
if (process.platform === "win32") {
|
|
66
|
+
if (!native?.binding.copyLinkExclusive)
|
|
67
|
+
throw new FsSafeError("helper-unavailable", "native Windows link copying is unavailable");
|
|
68
|
+
const sourceGuard = await createAsyncDirectoryGuard(path.dirname(input.sourcePath), { bigint: true });
|
|
69
|
+
sourceParent = await fsp.open(sourceGuard.realPath, fs.constants.O_RDONLY);
|
|
70
|
+
inspectFileIdentitySync(() => fs.fstatSync(sourceParent.fd, { bigint: true }), sourceGuard.stat);
|
|
71
|
+
await input.verifySource();
|
|
72
|
+
params.assertBeforeMutation?.();
|
|
73
|
+
assertSyncDirectoryGuard(parent);
|
|
74
|
+
identity = native.binding.copyLinkExclusive(sourceParent.fd, path.basename(input.sourcePath), native.fd, temporaryName, input.identity.dev, input.identity.ino, input.preserveMetadata, params.mode);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
if (native && !native.binding.createCopySymlink)
|
|
78
|
+
throw new FsSafeError("helper-unavailable", "native link copying is unavailable");
|
|
79
|
+
params.assertBeforeMutation?.();
|
|
80
|
+
assertSyncDirectoryGuard(parent);
|
|
81
|
+
if (native?.binding.createCopySymlink) {
|
|
82
|
+
const created = native.binding.createCopySymlink(native.fd, temporaryName, input.target, params.mode, input.preserveMetadata ? input.identity.atimeNs : undefined, input.preserveMetadata ? input.identity.mtimeNs : undefined);
|
|
83
|
+
identity = created;
|
|
84
|
+
if (created.errorCode)
|
|
85
|
+
throw Object.assign(new Error(created.errorMessage), { code: created.errorCode });
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
fs.symlinkSync(input.target, temporaryPath);
|
|
89
|
+
identity = fs.lstatSync(temporaryPath, { bigint: true });
|
|
90
|
+
}
|
|
91
|
+
if (!native && input.preserveMetadata) {
|
|
92
|
+
assertStage();
|
|
93
|
+
fs.lutimesSync(temporaryPath, timestampSeconds(input.identity.atimeNs), timestampSeconds(input.identity.mtimeNs));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
assertStage();
|
|
97
|
+
await input.verifySource();
|
|
98
|
+
params.assertBeforeMutation?.();
|
|
99
|
+
input.signal?.throwIfAborted();
|
|
100
|
+
assertStage();
|
|
101
|
+
if (process.platform === "win32") {
|
|
102
|
+
native.binding.publishCopyLink(native.fd, temporaryName, params.basename, identity.dev, identity.ino);
|
|
103
|
+
}
|
|
104
|
+
else if (native) {
|
|
105
|
+
const renameNoReplace = native.binding.renameNoReplace;
|
|
106
|
+
if (!renameNoReplace)
|
|
107
|
+
throw new FsSafeError("helper-unavailable", "native link publication is unavailable");
|
|
108
|
+
try {
|
|
109
|
+
renameNoReplace(native.fd, temporaryName, native.fd, params.basename);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const unavailable = noReplaceUnavailable(error, "link copy publication", true);
|
|
113
|
+
if (!unavailable || isFsSafeNativeRequired() || process.platform !== "linux")
|
|
114
|
+
throw error;
|
|
115
|
+
// Linux link(2) links the symlink inode itself, never its target.
|
|
116
|
+
assertStage();
|
|
117
|
+
fs.linkSync(temporaryPath, destination);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
fs.linkSync(temporaryPath, destination);
|
|
122
|
+
}
|
|
123
|
+
params.onPublished?.(identity);
|
|
124
|
+
assertSyncDirectoryGuard(parent);
|
|
125
|
+
const final = inspectFileIdentitySync(() => fs.lstatSync(destination, { bigint: true }), identity);
|
|
126
|
+
if (!final.isSymbolicLink())
|
|
127
|
+
throw new FsSafeError("path-mismatch", "published copy link changed");
|
|
128
|
+
if (params.sync !== false)
|
|
129
|
+
await syncDirectoryBestEffort(parent.realPath);
|
|
130
|
+
await input.verifySource();
|
|
131
|
+
return identity;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
failure = { error };
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
let cleanupFailure;
|
|
139
|
+
try {
|
|
140
|
+
if (identity) {
|
|
141
|
+
assertSyncDirectoryGuard(parent);
|
|
142
|
+
const remaining = fs.lstatSync(temporaryPath, { bigint: true, throwIfNoEntry: false });
|
|
143
|
+
if (remaining) {
|
|
144
|
+
inspectFileIdentitySync(() => remaining, identity);
|
|
145
|
+
if (!remaining.isSymbolicLink())
|
|
146
|
+
throw new FsSafeError("path-mismatch", "copy link cleanup identity changed");
|
|
147
|
+
if (process.platform === "win32")
|
|
148
|
+
native.binding.removeCopyLink(native.fd, temporaryName, identity.dev, identity.ino);
|
|
149
|
+
else
|
|
150
|
+
fs.unlinkSync(temporaryPath);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
cleanupFailure = { error };
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await sourceParent?.close();
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
cleanupFailure = { error: cleanupFailure
|
|
162
|
+
? createSuppressedError(error, cleanupFailure.error, "copy link cleanup and close failed") : error };
|
|
163
|
+
}
|
|
164
|
+
if (cleanupFailure)
|
|
165
|
+
throw failure
|
|
166
|
+
? createSuppressedError(cleanupFailure.error, failure.error, "copy link operation and cleanup failed")
|
|
167
|
+
: cleanupFailure.error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type BigIntStats } from "node:fs";
|
|
2
|
+
export type CopyMetadata = Readonly<{
|
|
3
|
+
atimeNs: bigint;
|
|
4
|
+
mtimeNs: bigint;
|
|
5
|
+
restoreNative?: (fd: number) => void;
|
|
6
|
+
}>;
|
|
7
|
+
export declare function timestampSeconds(nanoseconds: bigint): string;
|
|
8
|
+
export declare function captureCopyMetadata(fd: number, stat: BigIntStats): CopyMetadata;
|
|
9
|
+
export declare function restoreCopyMetadata(fd: number, metadata: CopyMetadata | undefined): void;
|