@openclaw/fs-safe 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- package/LICENSE +1 -0
- package/README.md +52 -4
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +3 -2
- package/dist/advanced.d.ts +5 -0
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +5 -0
- package/dist/archive-crc32.d.ts.map +1 -1
- package/dist/archive-crc32.js +6 -1
- package/dist/archive-deadline.d.ts.map +1 -1
- package/dist/archive-deadline.js +3 -4
- package/dist/archive-durability.d.ts +6 -6
- package/dist/archive-durability.d.ts.map +1 -1
- package/dist/archive-durability.js +1 -1
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +4 -5
- package/dist/archive-gzip-tail.d.ts +3 -0
- package/dist/archive-gzip-tail.d.ts.map +1 -1
- package/dist/archive-gzip-tail.js +23 -3
- package/dist/archive-input.d.ts.map +1 -1
- package/dist/archive-input.js +4 -2
- package/dist/archive-merge.d.ts +5 -1
- package/dist/archive-merge.d.ts.map +1 -1
- package/dist/archive-merge.js +16 -13
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +7 -6
- package/dist/archive-parser.wasm +0 -0
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +83 -74
- package/dist/archive-staging.d.ts +6 -3
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +42 -22
- package/dist/archive-tar-stream.d.ts +11 -4
- package/dist/archive-tar-stream.d.ts.map +1 -1
- package/dist/archive-tar-stream.js +23 -10
- package/dist/archive-tar-wasm.d.ts.map +1 -1
- package/dist/archive-tar-wasm.js +18 -14
- package/dist/archive-zip-admission.d.ts +1 -1
- package/dist/archive-zip-admission.d.ts.map +1 -1
- package/dist/archive-zip-admission.js +48 -12
- package/dist/archive-zip-loader.d.ts +6 -0
- package/dist/archive-zip-loader.d.ts.map +1 -0
- package/dist/archive-zip-loader.js +38 -0
- package/dist/archive-zip-names.d.ts.map +1 -1
- package/dist/archive-zip-names.js +13 -8
- package/dist/archive-zip-preflight.d.ts +2 -3
- package/dist/archive-zip-preflight.d.ts.map +1 -1
- package/dist/archive-zip-preflight.js +2 -34
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +12 -10
- package/dist/bounded-read.d.ts +12 -0
- package/dist/bounded-read.d.ts.map +1 -1
- package/dist/bounded-read.js +82 -45
- package/dist/clone-metadata.d.ts +19 -0
- package/dist/clone-metadata.d.ts.map +1 -0
- package/dist/clone-metadata.js +32 -0
- package/dist/copy-file-input.d.ts +22 -0
- package/dist/copy-file-input.d.ts.map +1 -0
- package/dist/copy-file-input.js +69 -0
- package/dist/copy-policy.d.ts +3 -0
- package/dist/copy-policy.d.ts.map +1 -0
- package/dist/copy-policy.js +8 -0
- package/dist/copy-publication.d.ts +10 -1
- package/dist/copy-publication.d.ts.map +1 -1
- package/dist/copy-publication.js +27 -0
- package/dist/copy-tree-portable.d.ts +9 -0
- package/dist/copy-tree-portable.d.ts.map +1 -0
- package/dist/copy-tree-portable.js +222 -0
- package/dist/copy.d.ts +16 -0
- package/dist/copy.d.ts.map +1 -0
- package/dist/copy.js +125 -0
- package/dist/directory-durability.d.ts.map +1 -1
- package/dist/directory-durability.js +5 -4
- package/dist/directory-guard.d.ts +11 -1
- package/dist/directory-guard.d.ts.map +1 -1
- package/dist/directory-guard.js +53 -11
- package/dist/durability.d.ts +1 -1
- package/dist/durability.d.ts.map +1 -1
- package/dist/durability.js +1 -1
- package/dist/error-detail.d.ts.map +1 -1
- package/dist/error-detail.js +4 -1
- package/dist/file-handle-transfer.d.ts +14 -0
- package/dist/file-handle-transfer.d.ts.map +1 -0
- package/dist/file-handle-transfer.js +64 -0
- package/dist/file-hash.d.ts +9 -2
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +135 -39
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +8 -4
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +7 -5
- package/dist/file-store-path.d.ts +3 -0
- package/dist/file-store-path.d.ts.map +1 -0
- package/dist/file-store-path.js +27 -0
- package/dist/file-store-prune.d.ts.map +1 -1
- package/dist/file-store-prune.js +6 -4
- package/dist/file-store-sync-write.d.ts.map +1 -1
- package/dist/file-store-sync-write.js +56 -44
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +2 -18
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +4 -13
- package/dist/guarded-mkdir.d.ts +1 -0
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +4 -2
- package/dist/guarded-mutation.d.ts +2 -0
- package/dist/guarded-mutation.d.ts.map +1 -1
- package/dist/guarded-mutation.js +8 -4
- package/dist/guest-dispatch-python.d.ts +2 -0
- package/dist/guest-dispatch-python.d.ts.map +1 -0
- package/dist/guest-dispatch-python.js +117 -0
- package/dist/guest-native-python.d.ts +4 -0
- package/dist/guest-native-python.d.ts.map +1 -0
- package/dist/guest-native-python.js +135 -0
- package/dist/guest.d.ts +9 -0
- package/dist/guest.d.ts.map +1 -0
- package/dist/guest.js +413 -0
- package/dist/home-dir.d.ts.map +1 -1
- package/dist/home-dir.js +9 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/install-path.d.ts.map +1 -1
- package/dist/install-path.js +5 -8
- package/dist/json-durable-queue-directory.js +3 -3
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +19 -18
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +2 -1
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +24 -25
- package/dist/move-path-stage.d.ts +8 -0
- package/dist/move-path-stage.d.ts.map +1 -0
- package/dist/move-path-stage.js +56 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +30 -34
- package/dist/mutation-authority.d.ts +9 -0
- package/dist/mutation-authority.d.ts.map +1 -0
- package/dist/mutation-authority.js +36 -0
- package/dist/native-binding.d.ts +29 -1
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-operations.d.ts +1 -1
- package/dist/native-operations.d.ts.map +1 -1
- package/dist/native-operations.js +11 -5
- package/dist/native-pinned-write-windows.d.ts.map +1 -1
- package/dist/native-pinned-write-windows.js +19 -7
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +3 -1
- package/dist/native-staged-file.d.ts +3 -3
- package/dist/native-staged-file.d.ts.map +1 -1
- package/dist/native-staged-file.js +35 -8
- package/dist/opened-realpath.d.ts.map +1 -1
- package/dist/opened-realpath.js +5 -4
- package/dist/output.d.ts +2 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +2 -0
- package/dist/overwrite-file-handle.d.ts +8 -0
- package/dist/overwrite-file-handle.d.ts.map +1 -0
- package/dist/overwrite-file-handle.js +42 -0
- package/dist/path-case.d.ts +7 -0
- package/dist/path-case.d.ts.map +1 -0
- package/dist/path-case.js +136 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +5 -2
- package/dist/permissions-windows.d.ts +1 -1
- package/dist/permissions-windows.d.ts.map +1 -1
- package/dist/permissions-windows.js +85 -53
- package/dist/pinned-open.d.ts.map +1 -1
- package/dist/pinned-open.js +3 -1
- package/dist/pinned-operation.js +1 -1
- package/dist/pinned-write.d.ts +7 -3
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +51 -30
- package/dist/positional-read.d.ts +9 -0
- package/dist/positional-read.d.ts.map +1 -0
- package/dist/positional-read.js +36 -0
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +6 -4
- package/dist/publish-copy-stage.d.ts +13 -0
- package/dist/publish-copy-stage.d.ts.map +1 -0
- package/dist/publish-copy-stage.js +47 -0
- package/dist/publish-file.d.ts.map +1 -1
- package/dist/publish-file.js +6 -5
- package/dist/realpath.d.ts +4 -0
- package/dist/realpath.d.ts.map +1 -0
- package/dist/realpath.js +43 -0
- package/dist/recursive-mkdir-path.d.ts +3 -0
- package/dist/recursive-mkdir-path.d.ts.map +1 -0
- package/dist/recursive-mkdir-path.js +8 -0
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +2 -1
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +23 -31
- package/dist/replace-file-copy-source.d.ts.map +1 -1
- package/dist/replace-file-copy-source.js +7 -12
- package/dist/replace-file-mode.d.ts +3 -0
- package/dist/replace-file-mode.d.ts.map +1 -0
- package/dist/replace-file-mode.js +10 -0
- package/dist/replace-file-temp-owner.d.ts.map +1 -1
- package/dist/replace-file-temp-owner.js +6 -2
- package/dist/replace-file.d.ts +1 -0
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +14 -8
- package/dist/root-context.d.ts +3 -0
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +53 -28
- package/dist/root-create-input.d.ts +10 -0
- package/dist/root-create-input.d.ts.map +1 -0
- package/dist/root-create-input.js +80 -0
- package/dist/root-directory-list.d.ts +26 -0
- package/dist/root-directory-list.d.ts.map +1 -0
- package/dist/root-directory-list.js +219 -0
- package/dist/root-entries.d.ts +11 -0
- package/dist/root-entries.d.ts.map +1 -0
- package/dist/root-entries.js +61 -0
- package/dist/root-errors.d.ts +6 -5
- package/dist/root-errors.d.ts.map +1 -1
- package/dist/root-errors.js +16 -12
- package/dist/root-file.d.ts +2 -0
- package/dist/root-file.d.ts.map +1 -1
- package/dist/root-file.js +12 -4
- package/dist/root-impl.d.ts +17 -51
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +358 -234
- package/dist/root-options.d.ts +77 -0
- package/dist/root-options.d.ts.map +1 -0
- package/dist/root-options.js +18 -0
- package/dist/root-path-existing.d.ts +5 -0
- package/dist/root-path-existing.d.ts.map +1 -1
- package/dist/root-path-existing.js +59 -3
- package/dist/root-path-symlink.d.ts.map +1 -1
- package/dist/root-path-symlink.js +3 -2
- package/dist/root-path.d.ts +1 -0
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +74 -162
- package/dist/root-paths.d.ts.map +1 -1
- package/dist/root-paths.js +13 -9
- package/dist/root-remove.d.ts +5 -0
- package/dist/root-remove.d.ts.map +1 -0
- package/dist/root-remove.js +286 -0
- package/dist/root-symlink-policy.d.ts +14 -0
- package/dist/root-symlink-policy.d.ts.map +1 -0
- package/dist/root-symlink-policy.js +34 -0
- package/dist/root-walk.d.ts +5 -4
- package/dist/root-walk.d.ts.map +1 -1
- package/dist/root-walk.js +99 -50
- package/dist/root-write-mode.d.ts.map +1 -1
- package/dist/root-write-mode.js +3 -5
- package/dist/root.d.ts +4 -1
- package/dist/root.d.ts.map +1 -1
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +2 -1
- package/dist/secret-read-async.d.ts.map +1 -1
- package/dist/secret-read-async.js +2 -1
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +25 -8
- package/dist/secure-temp-dir.d.ts.map +1 -1
- package/dist/secure-temp-dir.js +2 -1
- package/dist/sibling-staged-file.d.ts +1 -0
- package/dist/sibling-staged-file.d.ts.map +1 -1
- package/dist/sibling-staged-file.js +42 -8
- package/dist/sibling-temp.d.ts +2 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +6 -4
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +17 -5
- package/dist/sidecar-lock-policy.d.ts +2 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +20 -1
- package/dist/staged-directory.d.ts.map +1 -1
- package/dist/staged-directory.js +4 -3
- package/dist/temp-cleanup.d.ts.map +1 -1
- package/dist/temp-cleanup.js +3 -2
- package/dist/temp-target.d.ts +14 -12
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +12 -6
- package/dist/timing.d.ts +1 -0
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +25 -6
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +9 -7
- package/dist/unicode-path.d.ts +3 -0
- package/dist/unicode-path.d.ts.map +1 -0
- package/dist/unicode-path.js +13 -0
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +9 -28
- package/dist/windows-owner.d.ts +9 -12
- package/dist/windows-owner.d.ts.map +1 -1
- package/dist/windows-owner.js +24 -58
- package/dist/write-file-handle.d.ts +7 -0
- package/dist/write-file-handle.d.ts.map +1 -0
- package/dist/write-file-handle.js +26 -0
- package/docs/advanced.md +22 -4
- package/docs/archive.md +44 -9
- package/docs/atomic.md +24 -1
- package/docs/config.md +1 -0
- package/docs/contributing.md +36 -1
- package/docs/copy.md +155 -0
- package/docs/directory-identity.md +85 -0
- package/docs/durability.md +53 -3
- package/docs/entries.md +109 -0
- package/docs/errors.md +4 -4
- package/docs/file-store.md +15 -0
- package/docs/guest.md +141 -0
- package/docs/in-place-write.md +81 -0
- package/docs/index.md +2 -0
- package/docs/install.md +31 -0
- package/docs/local-roots.md +8 -1
- package/docs/native-helper.md +10 -3
- package/docs/native.md +18 -1
- package/docs/output.md +32 -6
- package/docs/path-case.md +64 -0
- package/docs/path-scope.md +1 -1
- package/docs/permissions.md +29 -10
- package/docs/positional-read.md +63 -0
- package/docs/public-api.md +31 -2
- package/docs/root.md +196 -6
- package/docs/secure-file.md +2 -0
- package/docs/security-model.md +14 -0
- package/docs/sidecar-lock.md +12 -3
- package/docs/temp.md +35 -6
- package/docs/timing.md +2 -0
- package/docs/types.md +19 -12
- package/docs/walk.md +54 -5
- package/docs/writing.md +173 -5
- package/package.json +19 -8
package/dist/windows-owner.js
CHANGED
|
@@ -23,65 +23,28 @@ function windowsOwnerQueryCommand(targetPath) {
|
|
|
23
23
|
"$driveRoot=if($extendedDrive){$p.Substring(4,3)}else{$root}",
|
|
24
24
|
"$namespacePath=$p.StartsWith('\\\\')",
|
|
25
25
|
"$remote=($namespacePath -and -not $extendedDrive) -or ([IO.DriveInfo]::new($driveRoot).DriveType -eq [IO.DriveType]::Network)",
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
26
|
+
// Emit only ASCII SID and numeric facts: console encodings can replace both
|
|
27
|
+
// Unicode pathnames and account names before JavaScript receives the bytes.
|
|
28
|
+
"$raw=[System.Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(),0)",
|
|
29
|
+
"$dacl=$raw.DiscretionaryAcl;$complete=$true",
|
|
30
|
+
"$aces=@(foreach($ace in $dacl){if($ace -isnot [System.Security.AccessControl.CommonAce] -or $ace.IsCallback -or [int]$ace.AceType -notin @(0,1)){$complete=$false;continue};@{sid=$ace.SecurityIdentifier.Value;mask=([long]$ace.AccessMask -band 4294967295);deny=([int]$ace.AceType -eq 1);inheritOnly=([int]$ace.AceFlags -band 8) -ne 0}})",
|
|
31
|
+
"@{ownerSid=$ownerSid;currentUserSid=$currentSid;daclPresent=($null -ne $dacl);aces=$aces;complete=$complete;remote=$remote}|ConvertTo-Json -Depth 4 -Compress",
|
|
29
32
|
].join(";");
|
|
30
33
|
}
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"$ErrorActionPreference='Stop'",
|
|
35
|
-
`$names=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPrincipals}'))|ConvertFrom-Json`,
|
|
36
|
-
"$rows=@($names|ForEach-Object {@{name=$_;sid=(New-Object System.Security.Principal.NTAccount($_)).Translate([System.Security.Principal.SecurityIdentifier]).Value}})",
|
|
37
|
-
"ConvertTo-Json -InputObject $rows -Compress",
|
|
38
|
-
].join(";");
|
|
39
|
-
}
|
|
40
|
-
function parsePrincipalSidRows(value) {
|
|
41
|
-
const rows = Array.isArray(value) ? value : value ? [value] : [];
|
|
42
|
-
const result = {};
|
|
43
|
-
for (const row of rows) {
|
|
44
|
-
if (!row || typeof row !== "object") {
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
const name = "name" in row && typeof row.name === "string" ? row.name.trim() : "";
|
|
48
|
-
const sid = "sid" in row && typeof row.sid === "string" ? normalizeSid(row.sid) : "";
|
|
49
|
-
if (name && SID_RE.test(sid)) {
|
|
50
|
-
result[name.toLowerCase()] = sid;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return result;
|
|
54
|
-
}
|
|
55
|
-
export async function resolveWindowsPrincipalSids(params) {
|
|
56
|
-
const principals = [...new Set(params.principals.map((value) => value.trim()).filter(Boolean))];
|
|
57
|
-
const known = Object.fromEntries(Object.entries(params.known ?? {}).map(([name, sid]) => [name.toLowerCase(), normalizeSid(sid)]));
|
|
58
|
-
const unresolved = principals.filter((principal) => !known[principal.toLowerCase()]);
|
|
59
|
-
if (unresolved.length === 0) {
|
|
60
|
-
return known;
|
|
34
|
+
function parseWindowsAclFacts(parsed) {
|
|
35
|
+
if (parsed.complete !== true || typeof parsed.daclPresent !== "boolean" || !Array.isArray(parsed.aces)) {
|
|
36
|
+
return { aclError: "Windows ACL query returned incomplete descriptor data" };
|
|
61
37
|
}
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const resolved = { ...known, ...parsePrincipalSidRows(JSON.parse(stdout.trim())) };
|
|
71
|
-
if (principals.some((principal) => !resolved[principal.toLowerCase()])) {
|
|
72
|
-
throw new Error("Windows ACL principal translation returned incomplete SID data");
|
|
73
|
-
}
|
|
74
|
-
return resolved;
|
|
75
|
-
}
|
|
76
|
-
export async function resolveWindowsCurrentUserSid(params) {
|
|
77
|
-
try {
|
|
78
|
-
const { stdout, stderr } = await params.exec(resolveWindowsSystemCommand("whoami.exe", params.env), ["/user", "/fo", "csv", "/nh"]);
|
|
79
|
-
const match = `${stdout}\n${stderr}`.match(/\*?S-\d+-\d+(?:-\d+)+/i);
|
|
80
|
-
return match ? normalizeSid(match[0]) : null;
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
return null;
|
|
38
|
+
const aces = [];
|
|
39
|
+
for (const row of parsed.aces) {
|
|
40
|
+
if (!row || typeof row !== "object" || typeof row.sid !== "string" || !SID_RE.test(row.sid) ||
|
|
41
|
+
typeof row.mask !== "number" || !Number.isInteger(row.mask) || row.mask < 0 || row.mask > 0xffff_ffff ||
|
|
42
|
+
typeof row.deny !== "boolean" || typeof row.inheritOnly !== "boolean") {
|
|
43
|
+
return { aclError: "Windows ACL query returned invalid access-rule data" };
|
|
44
|
+
}
|
|
45
|
+
aces.push({ sid: normalizeSid(row.sid), mask: row.mask, deny: row.deny, inheritOnly: row.inheritOnly });
|
|
84
46
|
}
|
|
47
|
+
return { daclPresent: parsed.daclPresent, aces };
|
|
85
48
|
}
|
|
86
49
|
export async function inspectWindowsOwner(params) {
|
|
87
50
|
let command = "";
|
|
@@ -96,7 +59,11 @@ export async function inspectWindowsOwner(params) {
|
|
|
96
59
|
"-EncodedCommand",
|
|
97
60
|
encodePowerShellCommand(windowsOwnerQueryCommand(params.targetPath)),
|
|
98
61
|
]);
|
|
99
|
-
const
|
|
62
|
+
const value = JSON.parse(stdout.trim());
|
|
63
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
64
|
+
return { error: "Windows owner query returned invalid SID data" };
|
|
65
|
+
}
|
|
66
|
+
const parsed = value;
|
|
100
67
|
const ownerSid = typeof parsed.ownerSid === "string" && SID_RE.test(parsed.ownerSid)
|
|
101
68
|
? normalizeSid(parsed.ownerSid)
|
|
102
69
|
: undefined;
|
|
@@ -110,8 +77,7 @@ export async function inspectWindowsOwner(params) {
|
|
|
110
77
|
return {
|
|
111
78
|
sid: ownerSid,
|
|
112
79
|
currentUserSid,
|
|
113
|
-
|
|
114
|
-
principalTranslationFailed: parsed.principalTranslationFailed === true,
|
|
80
|
+
...parseWindowsAclFacts(parsed),
|
|
115
81
|
remote,
|
|
116
82
|
trusted: !remote && (ownerSid === currentUserSid || TRUSTED_OWNER_SIDS.has(ownerSid)),
|
|
117
83
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FileHandle } from "node:fs/promises";
|
|
2
|
+
export declare function writeAllToFile(target: FileHandle | number, data: string | Uint8Array, options?: {
|
|
3
|
+
encoding?: BufferEncoding;
|
|
4
|
+
position?: number;
|
|
5
|
+
assertBeforeMutation?: () => void;
|
|
6
|
+
}): Promise<void>;
|
|
7
|
+
//# sourceMappingURL=write-file-handle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"write-file-handle.d.ts","sourceRoot":"","sources":["../src/write-file-handle.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAKnD,wBAAsB,cAAc,CAClC,MAAM,EAAE,UAAU,GAAG,MAAM,EAC3B,IAAI,EAAE,MAAM,GAAG,UAAU,EACzB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,cAAc,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,oBAAoB,CAAC,EAAE,MAAM,IAAI,CAAA;CAAO,GAChG,OAAO,CAAC,IAAI,CAAC,CAoBf"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { FsSafeError } from "./errors.js";
|
|
3
|
+
const WRITE_CHUNK_BYTES = 512 * 1024;
|
|
4
|
+
export async function writeAllToFile(target, data, options = {}) {
|
|
5
|
+
const buffer = typeof data === "string" ? Buffer.from(data, options.encoding ?? "utf8") : data;
|
|
6
|
+
let offset = 0;
|
|
7
|
+
while (offset < buffer.byteLength) {
|
|
8
|
+
const length = Math.min(WRITE_CHUNK_BYTES, buffer.byteLength - offset);
|
|
9
|
+
const position = options.position === undefined ? null : options.position + offset;
|
|
10
|
+
options.assertBeforeMutation?.();
|
|
11
|
+
const written = typeof target === "number"
|
|
12
|
+
? await new Promise((resolve, reject) => {
|
|
13
|
+
fs.write(target, buffer, offset, length, position, (error, bytesWritten) => {
|
|
14
|
+
if (error)
|
|
15
|
+
reject(error);
|
|
16
|
+
else
|
|
17
|
+
resolve(bytesWritten);
|
|
18
|
+
});
|
|
19
|
+
})
|
|
20
|
+
: (await target.write(buffer, offset, length, position)).bytesWritten;
|
|
21
|
+
if (written <= 0) {
|
|
22
|
+
throw new FsSafeError("helper-failed", "file write made no progress");
|
|
23
|
+
}
|
|
24
|
+
offset += written;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/docs/advanced.md
CHANGED
|
@@ -32,6 +32,7 @@ The exports group into a handful of themes. Each documented helper has its own p
|
|
|
32
32
|
| `resolveWritablePathWithinRoot` | – | Resolve a write target inside a root. |
|
|
33
33
|
| `resolveRootPath`, `resolveRootPathSync`, `ResolvedRootPath`, `ROOT_PATH_ALIAS_POLICIES`, `RootPathAliasPolicy` | – | Resolve a root directory honoring alias policy. |
|
|
34
34
|
| `resolvePathViaExistingAncestorSync` | – | Walk to an existing ancestor for paths whose tail does not yet exist. |
|
|
35
|
+
| `probePathCaseInsensitiveSync`, `ProbePathCaseOptions` | [path-case.md](path-case.md) | Observe local ASCII-case behavior with explicit read-only mode and owned temporary-probe cleanup. |
|
|
35
36
|
|
|
36
37
|
`ensureDirectoryWithinRoot({ rootDir, requestedPath, scopeLabel, defaultDirName?, mode? })`
|
|
37
38
|
returns `{ ok: true, path }` or `{ ok: false, error: string, diagnostic?: FsSafeError }`.
|
|
@@ -65,9 +66,13 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
|
|
|
65
66
|
| Export | Page | Notes |
|
|
66
67
|
|---|---|---|
|
|
67
68
|
| `readFileDescriptorBounded`, `readFileDescriptorBoundedSync`, `readFileHandleBounded` | – | Incremental whole-file reads for already-open descriptors/handles. They consume at most `maxBytes + 1`, do not close the input, and throw `FsSafeError("too-large")` on overflow. |
|
|
68
|
-
| `
|
|
69
|
+
| `readFileWindowFully`, `readFileWindowFullySync`, `ReadFileWindowOptions` | [positional-read.md](positional-read.md) | Fill a caller-owned buffer at an explicit file position, completing short reads and returning the EOF count without moving or closing the descriptor. |
|
|
70
|
+
| `copyFileHandle`, `CopyFileHandleOptions` | [copy.md](copy.md#borrowed-filehandle-transfers) | Copy caller-owned regular-file handles from position zero with byte limits, settled cancellation, and a synchronous source observer; preserves cursors and leaves publication and cleanup to the caller. |
|
|
71
|
+
| `overwriteFileHandle`, `OverwriteFileHandleOptions` | [in-place-write.md](in-place-write.md) | Overwrite a borrowed read/write handle with prefix-only preparation and best-effort rollback; preserves its inode, cursor, and caller-owned lifetime. |
|
|
72
|
+
| `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level root-bounded open; rejects every symlink component by default, with `symlinks: "follow-parents-within-root"` for contained parent aliases or `"follow-within-root"` for final links too. |
|
|
69
73
|
| `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
|
|
70
74
|
| `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
|
|
75
|
+
| `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. |
|
|
71
76
|
| `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
|
|
72
77
|
| `assertNoSymlinkParents`, `assertNoSymlinkParentsSync`, `AssertNoSymlinkParentsOptions` | – | Reject paths whose ancestor chain contains symlinks. |
|
|
73
78
|
| `assertNoHardlinkedFinalPath`, `assertNoPathAliasEscape`, `PATH_ALIAS_POLICIES`, `PathAliasPolicy` | – | Hardlink/alias defense building blocks. |
|
|
@@ -79,6 +84,19 @@ receipts remain numeric. Custom `ioFs` adapters must honor `{ bigint: true }` fo
|
|
|
79
84
|
Windows identities receive one re-inspection without reopening, then fail
|
|
80
85
|
validation if still unknown.
|
|
81
86
|
|
|
87
|
+
The explicit `symlinks` policy takes precedence over the existing `rejectSymlinks`
|
|
88
|
+
boolean. Without `symlinks`, `rejectSymlinks: false` retains its existing behavior
|
|
89
|
+
of following contained links, and omission still rejects all symlink components.
|
|
90
|
+
|
|
91
|
+
The bounded descriptor helpers cap their initial speculative allocation at
|
|
92
|
+
16 MiB plus the overflow-probe byte, even when a file reports a much larger
|
|
93
|
+
size. Regular files up to that size can return from one read without copying
|
|
94
|
+
chunks; larger files and short reads continue incrementally under the same
|
|
95
|
+
byte limit. Continuation buffers grow only after filling with actual bytes,
|
|
96
|
+
up to the byte budget plus its probe; file-size hints cannot force that growth.
|
|
97
|
+
Unknown-size inputs start with at most 64 KiB. This avoids per-chunk copies and
|
|
98
|
+
a final concatenation when a file exceeds the initial allocation.
|
|
99
|
+
|
|
82
100
|
The bounded descriptor helpers start at the descriptor's current offset and
|
|
83
101
|
leave ownership with the caller. They are intended for the second half of a
|
|
84
102
|
safe read: first open and validate the path using the boundary appropriate to
|
|
@@ -117,7 +135,7 @@ component is followed by another segment, both helpers throw
|
|
|
117
135
|
|---|---|---|
|
|
118
136
|
| `safeDirName`, `safePathSegmentHashed`, `resolveSafeInstallDir`, `assertCanonicalPathWithinBase` | [install-path.md](install-path.md) | Build install-target directories from caller-supplied identifiers. |
|
|
119
137
|
| `sanitizeUntrustedFileName` | [filename.md](filename.md) | Coerce an untrusted string into a safe filename. |
|
|
120
|
-
| `resolveHomeRelativePath` | – | Expand
|
|
138
|
+
| `resolveHomeRelativePath` | – | Expand a leading `~` before resolving `.` and `..`; tildes inside relative paths stay literal. |
|
|
121
139
|
|
|
122
140
|
### Temp targets and sibling-temp writes
|
|
123
141
|
|
|
@@ -139,7 +157,7 @@ component is followed by another segment, both helpers throw
|
|
|
139
157
|
| Export | Page | Notes |
|
|
140
158
|
|---|---|---|
|
|
141
159
|
| `createAsyncLock` | – | In-process async lock (separate from cross-process file locks). |
|
|
142
|
-
| `withTimeout` | [timing.md](timing.md) | Wrap a promise with a timeout that raises `
|
|
160
|
+
| `withTimeout` | [timing.md](timing.md) | Wrap a promise with a timeout that raises `Error` by default, or an error supplied by `createError`. |
|
|
143
161
|
| `movePathToTrash`, `MovePathToTrashOptions` | – | Best-effort move to the platform trash. |
|
|
144
162
|
|
|
145
163
|
## Stability
|
|
@@ -149,5 +167,5 @@ Items in this surface can change shape between minor versions if a higher-level
|
|
|
149
167
|
## Related pages
|
|
150
168
|
|
|
151
169
|
- [Root API](root.md) — built on top of these helpers.
|
|
152
|
-
- [Errors](errors.md) —
|
|
170
|
+
- [Errors](errors.md) — shared filesystem error codes; generic timing helpers retain their documented error types.
|
|
153
171
|
- [Security model](security-model.md) — what the underlying boundary checks promise.
|
package/docs/archive.md
CHANGED
|
@@ -126,6 +126,12 @@ untrusted authority rejects explicitly instead of silently accepting a wrong
|
|
|
126
126
|
mode. Other unsupported search-only routes also fail closed. Windows retains
|
|
127
127
|
its existing bounded lack of POSIX mode enforcement.
|
|
128
128
|
|
|
129
|
+
Extraction and TAR inspection first copy the admitted source into a private
|
|
130
|
+
staging file. This copy reuses at most 512 KiB of scratch space, reduced for
|
|
131
|
+
small inputs and capped by the archive byte limit plus one overflow-probe byte.
|
|
132
|
+
Each read stays within the remaining budget plus that probe; deadline checks
|
|
133
|
+
surround reads, and short writes finish before the buffer is reused.
|
|
134
|
+
|
|
129
135
|
Native extraction is deliberately split into two phases. Rust first reports an
|
|
130
136
|
entry manifest without creating paths. TypeScript validates paths, applies
|
|
131
137
|
`stripComponents`, filters, limits, and mode policy, then passes an explicit
|
|
@@ -133,7 +139,7 @@ accepted-entry plan back to Rust. Rust owns raw-stream admission, decompression,
|
|
|
133
139
|
and fd-relative `mkdirBeneath`/exclusive-open writes. This keeps filter policy identical
|
|
134
140
|
between native and JavaScript paths rather than reimplementing it in Rust.
|
|
135
141
|
|
|
136
|
-
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 separator and dot-component equivalence is allowed only after validation. Ordinary legacy filename decoding remains backend-selected.
|
|
142
|
+
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 separator and dot-component equivalence is allowed only after validation. 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.
|
|
137
143
|
|
|
138
144
|
`stripComponents` removes leading nonempty, non-`.` path components after
|
|
139
145
|
normalizing separators. For example, `./pkg/hello.txt` with
|
|
@@ -222,14 +228,23 @@ record and are then cleared; local PAX on unsupported types and GNU sparse
|
|
|
222
228
|
|
|
223
229
|
If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and throws if the extension is not recognized. Pass `kind` explicitly when the archive name doesn't carry the type (e.g. content-addressed names). Archive inputs must remain regular files from preview through descriptor admission; POSIX opens are no-follow and nonblocking, so a FIFO swap cannot stall before deadline checks resume. A positive finite `timeoutMs` is a wall-clock budget; zero, negative, `NaN`, and infinity disable the deadline. Non-mutating work rejects promptly when the budget expires. If a live destination mutation is already in flight, rejection waits only for that mutation and any rollback to finish; no later destination mutation can begin.
|
|
224
230
|
|
|
231
|
+
Extraction captures the destination's lossless filesystem identity before any
|
|
232
|
+
entry filter runs and retains that capability through final publication. If a
|
|
233
|
+
filter or concurrent actor renames or replaces the destination, extraction
|
|
234
|
+
rejects with `destination-symlink-traversal` before publishing into the
|
|
235
|
+
replacement. This check uses bigint device and inode identities so large native
|
|
236
|
+
identifiers cannot compare equal after JavaScript number rounding.
|
|
237
|
+
|
|
225
238
|
The destination merge is nontransactional: each file is published atomically,
|
|
226
239
|
but completed files and directories can remain when a later copy, post-copy
|
|
227
240
|
check, mode application, or deadline fails. This also applies to
|
|
228
|
-
`mergeExtractedTreeIntoDestination()`. Guarded `Root.copyIn()` owns
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
241
|
+
`mergeExtractedTreeIntoDestination()`. Guarded `Root.copyIn()` owns unpublished
|
|
242
|
+
stage cleanup and preserves completed publications. The archive merge never
|
|
243
|
+
acquires rollback authority over the current destination name. Replacing a
|
|
244
|
+
source path after publication rejects the merge with `path-mismatch` while
|
|
245
|
+
preserving the admitted bytes already published, or any later destination edit.
|
|
246
|
+
A failure before publication preserves a pre-existing file, and rejection does
|
|
247
|
+
not grant authority to delete a substituted file or alias. Failed extraction does not
|
|
233
248
|
restore overwritten contents. Active destination mutations and their guarded
|
|
234
249
|
cleanup still finish before rejection; no later destination mutation begins.
|
|
235
250
|
New directories whose finalization was never reached can retain their
|
|
@@ -352,7 +367,9 @@ bypass validation. Decompression remains streaming; no complete decoded archive
|
|
|
352
367
|
is retained in memory or written to a decoded spool.
|
|
353
368
|
|
|
354
369
|
The WASM transport has a fixed 64 KiB input buffer, one pending member event,
|
|
355
|
-
and a 256 MiB maximum linear memory per isolated parser instance.
|
|
370
|
+
and a 256 MiB maximum linear memory per isolated parser instance. JavaScript
|
|
371
|
+
gzip decoding emits chunks of at most 64 KiB for both staged files and buffered
|
|
372
|
+
inputs, matching that input window. Metadata is
|
|
356
373
|
bounded before allocation; allocation failure rejects. Stream backpressure
|
|
357
374
|
bounds queued chunks, and completion/error destroys the instance's parser
|
|
358
375
|
state. The manifest retains the existing charged budget below; linear memory
|
|
@@ -555,8 +572,8 @@ await extractArchive({
|
|
|
555
572
|
## `readArchiveEntry`
|
|
556
573
|
|
|
557
574
|
`readArchiveEntry(archivePath, entryPath, { maxBytes, kind? })` reads one
|
|
558
|
-
regular-file entry into a bounded `Buffer` without extracting a tree. It
|
|
559
|
-
|
|
575
|
+
regular-file entry into a bounded `Buffer` without extracting a tree. It reads
|
|
576
|
+
the input through an identity-checked descriptor, rejects link, directory, and duplicate
|
|
560
577
|
entries, verifies ZIP CRC and declared size,
|
|
561
578
|
and throws `ArchiveLimitError` if the requested entry's output exceeds
|
|
562
579
|
`maxBytes`. ZIP output within that cap must match the declared uncompressed
|
|
@@ -571,6 +588,24 @@ limits. It does not apply payload budgets to unrequested members. ZIP
|
|
|
571
588
|
inputs retain the archive subpath's 256 MiB compressed-input ceiling.
|
|
572
589
|
With a native binding it uses the same Rust decoders as extraction, including
|
|
573
590
|
zstd and bzip2 TAR. Without native it retains the JS ZIP/TAR/gzip implementation.
|
|
591
|
+
Archive member reads retain their private in-memory input without a disk
|
|
592
|
+
snapshot. JavaScript ZIP member reads reuse their completed physical admission
|
|
593
|
+
when loading the decoder, which still checks its decoded names and entry count.
|
|
594
|
+
The native ZIP reader retains the private allocation and parsed directory across worker-thread
|
|
595
|
+
inspection and reading without an extra archive-byte copy.
|
|
596
|
+
Decompression still allocates its bounded output; Node receives that native
|
|
597
|
+
allocation without another copy where external buffers are supported.
|
|
598
|
+
Native TAR retains the fully admitted member offsets alongside the same input
|
|
599
|
+
allocation. Plain TAR copies only the selected payload range after full archive
|
|
600
|
+
validation. Gzip, zstd, and bzip2 replay bounded decompression and still validate
|
|
601
|
+
all framing, trailers, and physical padding before returning. The JavaScript
|
|
602
|
+
TAR/gzip fallback copies each input window into WASM once, consuming member
|
|
603
|
+
events at offsets within that window. After full admission, plain TAR copies the
|
|
604
|
+
selected range directly from its private snapshot; gzip still replays bounded
|
|
605
|
+
decompression through the parser. WASM transport and selected output still
|
|
606
|
+
require copies.
|
|
607
|
+
Returned buffers own their bytes, so changing a result cannot modify an archive
|
|
608
|
+
reader or retain an unrelated part of the input through its backing ArrayBuffer.
|
|
574
609
|
|
|
575
610
|
Requested paths and effective member names use extraction's canonical pre-strip
|
|
576
611
|
identity: backslashes become `/`, and repeated separators and `.` components
|
package/docs/atomic.md
CHANGED
|
@@ -40,7 +40,7 @@ type ReplaceFileAtomicOptions = {
|
|
|
40
40
|
content: string | Uint8Array;
|
|
41
41
|
dirMode?: number; // parent-directory mode (POSIX; default 0o700)
|
|
42
42
|
mode?: number; // new-file mode (default 0o600)
|
|
43
|
-
preserveExistingMode?: boolean; //
|
|
43
|
+
preserveExistingMode?: boolean; // inherit existing regular-file rwx bits; default false
|
|
44
44
|
tempPrefix?: string; // default ".fs-safe-replace"
|
|
45
45
|
renameMaxRetries?: number; // EBUSY retries; default 0
|
|
46
46
|
renameRetryBaseDelayMs?: number; // exponential base; default 50
|
|
@@ -57,6 +57,17 @@ type ReplaceFileAtomicOptions = {
|
|
|
57
57
|
};
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
`preserveExistingMode` snapshots only the ordinary rwx bits (`0o777`) from an
|
|
61
|
+
existing non-symlink regular destination. A final symlink fails with
|
|
62
|
+
`FsSafeError("symlink")`; a directory or other non-regular destination fails
|
|
63
|
+
with `FsSafeError("not-file")`. Set-user-ID, set-group-ID, and sticky bits are
|
|
64
|
+
never inherited. Mode inheritance does not copy ownership, ACLs, extended
|
|
65
|
+
attributes, or exact destination identity. Rename publication creates a new
|
|
66
|
+
inode; an in-place copy fallback can retain metadata already attached to its
|
|
67
|
+
pinned destination. The snapshot does not make replacement a compare-and-swap
|
|
68
|
+
operation, so the destination parent must still be protected from untrusted
|
|
69
|
+
concurrent namespace mutation.
|
|
70
|
+
|
|
60
71
|
### `beforeRename`
|
|
61
72
|
|
|
62
73
|
Runs after the temp file is fully written and before the rename. Use it to take a backup snapshot, capture the about-to-be-replaced contents, or notify an observer. The helper retains the staged descriptor and exact bigint identity across the hook; replacing, deleting, hardlinking, or changing the temp entry to a non-regular file is rejected before publication:
|
|
@@ -135,6 +146,11 @@ than `maxRestoreBytes` fails with `too-large` before mutation. A missing
|
|
|
135
146
|
destination has no original to restore and follows the exclusive-create copy
|
|
136
147
|
fallback.
|
|
137
148
|
|
|
149
|
+
Restore snapshots use the pinned file's size as an allocation hint, with an
|
|
150
|
+
initial allocation capped at 16 MiB plus the overflow byte. Reads continue
|
|
151
|
+
through short reads and EOF, grow only as data arrives, and enforce the same
|
|
152
|
+
`maxRestoreBytes` budget even if the destination grows after its size was read.
|
|
153
|
+
|
|
138
154
|
### Sync variant
|
|
139
155
|
|
|
140
156
|
`replaceFileAtomicSync` accepts the same base options, a synchronous
|
|
@@ -207,6 +223,13 @@ directory mode. Staged file modes are applied through their still-open handles.
|
|
|
207
223
|
If descriptor-bound mode application fails, the staged path is removed and the
|
|
208
224
|
move fails before publication. A transient staged-path cleanup failure retains
|
|
209
225
|
an identity-bound process-exit cleanup retry.
|
|
226
|
+
The staging entry's initially admitted identity is checked before publication
|
|
227
|
+
and cleanup; a later substituted entry is preserved. Regular files are admitted
|
|
228
|
+
through their new descriptor. Node provides no creation descriptor for directories
|
|
229
|
+
or symlinks, so their first identity comes from an immediate pathname observation.
|
|
230
|
+
Replacement before that observation remains a best-effort detection gap: use a
|
|
231
|
+
parent protected from concurrent untrusted mutation or OS isolation. A copy write
|
|
232
|
+
that makes no progress rejects instead of looping indefinitely.
|
|
210
233
|
On POSIX, staged directory modes are applied through no-follow directory
|
|
211
234
|
descriptors; on Windows, Node cannot portably open those descriptors and no
|
|
212
235
|
pathname `chmod` fallback is attempted, so directory modes remain subject to
|
package/docs/config.md
CHANGED
|
@@ -65,6 +65,7 @@ type FsSafeLockConfig = {
|
|
|
65
65
|
Set process-wide defaults for sidecar lock options. This does **not** turn locking on globally; callers still need to pass `lock: true` or a lock options object for the specific JSON store/resource that needs cross-process coordination.
|
|
66
66
|
|
|
67
67
|
`staleRecovery` defaults to `"fail-closed"`. The opt-in `"remove-if-unchanged"` mode requires caller approval and serializes the final snapshot check and unlink with an exclusive `.reclaim` guard. A reclaim guard left by a killed reclaimer fails closed and requires externally coordinated cleanup.
|
|
68
|
+
`staleMs` must be non-negative and not `NaN`; `Infinity` disables age-based staleness.
|
|
68
69
|
|
|
69
70
|
For a daemon that should wait briefly for normal contention but never delete a
|
|
70
71
|
stale owner without per-lock approval:
|
package/docs/contributing.md
CHANGED
|
@@ -42,6 +42,32 @@ Vitest. Tests live in `test/` and follow `*.test.ts`. Run a single file with:
|
|
|
42
42
|
pnpm test test/archive.test.ts
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
Guest filesystem tests and package smoke also need `python3` on Linux and
|
|
46
|
+
macOS. They execute the exported source on synthetic files; Windows checks
|
|
47
|
+
the import surface and leaves POSIX execution to the Linux/macOS lanes.
|
|
48
|
+
After building on Linux, `node scripts/check-pack.mjs --guest-cross-device`
|
|
49
|
+
also proves an installed-package directory move from temporary storage to
|
|
50
|
+
`/dev/shm`; the command requires those locations to be different filesystems.
|
|
51
|
+
|
|
52
|
+
With Bun 1.4.2 installed, build the host addon and run the native compatibility
|
|
53
|
+
lane in real Bun workers, then exercise the built package with JIT disabled:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pnpm native:build
|
|
57
|
+
pnpm test:bun:native
|
|
58
|
+
bun --jitless scripts/bun-native-proof.mjs
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Keep the Node/pnpm build toolchain above. CI runs the native compatibility lane
|
|
62
|
+
on Linux, macOS, and Windows. The built-package proof checks `auto`/`require`,
|
|
63
|
+
native-off loading policy, and a separate installation without the addon.
|
|
64
|
+
|
|
65
|
+
`pnpm test:bun` runs the entire Node-oriented suite as a diagnostic. On released
|
|
66
|
+
Bun, its explicit native-off and missing-helper cases include unsupported
|
|
67
|
+
permission/path behavior described in [install](install.md#bun-runtime); this
|
|
68
|
+
command is not a passing compatibility gate. Node CI retains every fallback
|
|
69
|
+
assertion. Neither lane rewrites `off` to `auto` or marks defects as expected passes.
|
|
70
|
+
|
|
45
71
|
Use `vi.mock` sparingly. Most tests should drive real disk operations in a `mkdtemp`-created scratch directory, asserting on observable behavior. The library has [test hooks](testing.md) for the rare cases where you need to inject a TOCTOU race deterministically.
|
|
46
72
|
|
|
47
73
|
Vitest timeouts do not cancel filesystem promises. Shared fixtures with expensive
|
|
@@ -68,6 +94,13 @@ pnpm check
|
|
|
68
94
|
This runs the filesystem boundary checks, build, tests, and package
|
|
69
95
|
tarball/import validation.
|
|
70
96
|
|
|
97
|
+
### Method benchmarks
|
|
98
|
+
|
|
99
|
+
`pnpm benchmark:methods` measures the callable library surface against synthetic
|
|
100
|
+
fixtures and fails on uncovered exports or returned methods. See the
|
|
101
|
+
[benchmark guide](https://github.com/openclaw/fs-safe/tree/main/benchmarks) for native/fallback runs, per-call
|
|
102
|
+
timings, exclusions, and comparison methodology. Run `pnpm build` first.
|
|
103
|
+
|
|
71
104
|
### Real TAR producers
|
|
72
105
|
|
|
73
106
|
After installing the freshly packed root (and optionally its freshly built host
|
|
@@ -116,7 +149,9 @@ collection uses the actual seven collected native tarballs instead. Run it with
|
|
|
116
149
|
`pnpm package:collect` after assembling all seven real bindings; missing targets
|
|
117
150
|
fail collection. `pnpm package:collect --allow-host-only` exercises the same
|
|
118
151
|
lifecycle boundary locally but proves only the host. Both collection commands
|
|
119
|
-
require the pnpm lifecycle CLI path;
|
|
152
|
+
require the pnpm lifecycle CLI path; JavaScript CLIs run through Node and standalone
|
|
153
|
+
`@pnpm/exe` binaries run directly. Shell/cmd shims and PATH fallback are not used;
|
|
154
|
+
direct `node` invocation without lifecycle metadata is unsupported. Archive
|
|
120
155
|
codecs and their dependencies are packed from the installed dependency graph.
|
|
121
156
|
|
|
122
157
|
PR CI builds and executes four host targets: Linux x64 glibc, Linux x64 musl
|
package/docs/copy.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Directory copying and cloning
|
|
2
|
+
|
|
3
|
+
`@openclaw/fs-safe/copy` materializes independent, caller-owned directory trees. `copyTree` prefers native copy-on-write operations by default, can require cloning, or can copy regular file bytes without cloning or copy offload.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { copyTree, createCloneSource, probeTreeClone } from "@openclaw/fs-safe/copy";
|
|
7
|
+
|
|
8
|
+
const parent = "/srv/worktrees";
|
|
9
|
+
const backend = probeTreeClone(parent);
|
|
10
|
+
if (backend) {
|
|
11
|
+
const template = `${parent}/template`;
|
|
12
|
+
await createCloneSource(template);
|
|
13
|
+
// Populate this caller-owned template, then keep its contents unchanged.
|
|
14
|
+
await copyTree(template, `${parent}/checkout`, {
|
|
15
|
+
clone: "always",
|
|
16
|
+
signal: AbortSignal.timeout(60_000),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Filesystems
|
|
22
|
+
|
|
23
|
+
| Backend | Operation | Source preparation |
|
|
24
|
+
| ------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `apfs` | Native bulk clone, then directory timestamp repair | `createCloneSource` creates an empty directory. |
|
|
26
|
+
| `btrfs` | One native writable subvolume snapshot | `createCloneSource` creates a subvolume; an ordinary directory is not a snapshot source. No `btrfs` executable is required. |
|
|
27
|
+
| `refs` | Native directory traversal with parallel file block clones | `createCloneSource` creates an empty directory on ReFS, including Dev Drive volumes. |
|
|
28
|
+
| `xfs` | Native directory traversal with parallel file reflinks | `createCloneSource` creates an empty directory. The XFS volume must support reflinks. |
|
|
29
|
+
| `zfs` | Native directory traversal with parallel file reflinks | `createCloneSource` creates an empty directory. Requires Linux OpenZFS file reflinks and the pool block-cloning feature. |
|
|
30
|
+
|
|
31
|
+
Native cloning requires source and destination filesystems that support cloning between them. Automatic and ordinary copying can cross filesystems. The source repository used to populate a template can live elsewhere. ReFS, XFS, and ZFS share file data rather than the whole directory metadata tree, so creating many small files still has a cost.
|
|
32
|
+
|
|
33
|
+
ZFS uses strict file reflinks within one dataset, not dataset snapshots. The installed Linux OpenZFS version must implement `FICLONE`, and the pool must enable `feature@block_cloning`. The probe identifies ZFS even when that feature is unavailable; `clone: "always"` then fails and `"auto"` can copy bytes. Native cloning was verified on OpenZFS 2.4.1 with POSIX ACLs. See the [OpenZFS block-cloning contract](https://openzfs.github.io/openzfs-docs/Basic%20Concepts/Data%20Storage/Block%20Cloning.html) for filesystem limits and pool sharing counters.
|
|
34
|
+
|
|
35
|
+
Btrfs preserves native subvolume snapshot semantics: nested subvolume contents are not included. Prepare source-only templates without nested subvolumes. This API does not recursively snapshot a hierarchy of subvolumes.
|
|
36
|
+
|
|
37
|
+
### APFS permissions
|
|
38
|
+
|
|
39
|
+
APFS directory cloning does not guarantee descendant ACL preservation. With the `CLONE_ACL` flag used here, live macOS testing preserved the source root's ACL but dropped an explicit ACL on a source descendant. Destination ACL inheritance was also omitted below the cloned root. `probeTreeClone` checks filesystem support only; neither it nor `copyTree` checks whether these ACL semantics meet the caller's permission policy. A successful clone is not proof of source ACL preservation or normal file-creation inheritance throughout the tree.
|
|
40
|
+
|
|
41
|
+
Callers that require source ACL preservation or destination ACL inheritance must use a creation path that preserves their permission policy. For example, a private Git template cache can prohibit custom descendant ACLs and decline cloning when the destination parent has inheritable ACL entries, the template root carries ACLs, or ACL inspection fails; it must also account for policy changes during cloning. Checking only the source root cannot establish that an arbitrary tree has no descendant ACLs. This library does not inspect or repair ACLs after a clone.
|
|
42
|
+
|
|
43
|
+
Apple [strongly discourages general directory cloning](https://github.com/apple-oss-distributions/xnu/blob/f6217f891ac0bb64f3d375211650a4c1ff8ca1ea/bsd/man/man2/clonefile.2). The [XNU directory-clone authorizer notes unfinished descendant ACL inheritance](https://github.com/apple-oss-distributions/xnu/blob/f6217f891ac0bb64f3d375211650a4c1ff8ca1ea/bsd/vfs/vfs_subr.c#L8879); this is one verified limitation, not Apple's stated complete rationale. The bulk operation remains useful for controlled, immutable templates whose callers accept its metadata semantics.
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
`TreeCloneBackend` is the `"apfs" | "btrfs" | "refs" | "xfs" | "zfs"` union returned by the probe. `CopyTreeOptions` contains the optional `clone`, `signal`, and `concurrency` arguments. `CopyCloneMode` is the `"auto" | "always" | "never"` strategy shared with [`Root.copyIn`](root.md#writes); tree copies default to `"auto"`, while guarded file copies default to `"never"`.
|
|
48
|
+
|
|
49
|
+
`probeTreeClone(parentPath)` synchronously inspects an existing directory and returns its supported backend name or `undefined`. It creates no probe artifacts. A filesystem name identifies a candidate backend; for example, an older XFS volume may have reflinks disabled. The actual operation determines availability. An unavailable native binding produces `undefined` in automatic mode; the package's explicit native `require` mode still reports a missing binding as an error.
|
|
50
|
+
|
|
51
|
+
`createCloneSource(destination, { signal? })` creates an empty cloneable source. Its parent must already exist and the destination must be absent.
|
|
52
|
+
|
|
53
|
+
`copyTree(source, destination, { clone?, signal?, concurrency? })` copies a directory into an absent destination. Existing destinations are never merged or overwritten. The destination must be outside the source tree.
|
|
54
|
+
|
|
55
|
+
| `clone` policy | Behavior |
|
|
56
|
+
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
57
|
+
| `"auto"` (default) | Prefer native cloning; copy bytes when the binding or filesystem capability is unavailable, or cloning cannot cross the filesystem boundary. |
|
|
58
|
+
| `"always"` | Require native cloning. Unsupported operations fail without a byte-copy fallback. |
|
|
59
|
+
| `"never"` | Copy regular file bytes using reads and writes. No native cloning or copy-offload calls. Works without a native binding. |
|
|
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.
|
|
62
|
+
|
|
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
|
+
|
|
65
|
+
On Windows, automatic byte copying uses the native binding when available to transfer data between the already-checked file handles in chunks of at most 1 MiB, with smaller buffers for small files. This accelerates NTFS and cross-volume copies without reopening source or destination pathnames. The native worker finishes before its descriptors are closed or cancellation is reported. `clone: "never"` and native-disabled copies use JavaScript read/write loops with reusable buffers: at most 1 MiB per active file on Windows, or 128 KiB elsewhere. Both paths share the file-worker budget across sibling directories, wait for all admitted writes after cancellation or failure, and restore directory timestamps after their descendants finish. Completed directory traversals awaiting writes or metadata are bounded by concurrency; ancestors remain pinned while traversing their children.
|
|
66
|
+
|
|
67
|
+
On Linux, automatic byte copying also uses the native binding when available. It reads in 1 MiB chunks and leaves leading and trailing zero-filled portions of each chunk unwritten in the new file, avoiding their allocation on filesystems that support sparse files. A final size update preserves trailing holes and all-zero files. This path uses reads and writes, without cloning or copy offload; it still reads the full logical contents and does not promise identical sparse extent layout. `clone: "never"` retains the JavaScript byte-copy path.
|
|
68
|
+
|
|
69
|
+
Clones preserve file contents, empty directories, timestamps, executable modes where supported, and literal symbolic links. Editing a clone does not modify its source. Unsupported filesystem operations fail; callers may choose their own copy or checkout fallback after the failed operation has settled.
|
|
70
|
+
|
|
71
|
+
Native Windows byte copies can store large zero-filled chunks as sparse ranges when the destination is initially empty and its filesystem supports sparse files. This still reads every source byte and creates an independent copy.
|
|
72
|
+
|
|
73
|
+
The ReFS backend rejects files with alternate data streams and unsupported reparse-point types instead of silently losing their contents. Symbolic links and junctions are preserved.
|
|
74
|
+
|
|
75
|
+
XFS and ZFS preserve regular-file and directory modes, timestamps, extended attributes, and ACLs. They reject special files, symlink extended attributes, non-UTF-8 names, and directory nesting deeper than 128 levels. Hardlinked source files become independent reflinked files. Portable byte copying preserves file contents, empty directories, modes where supported, file and directory timestamps, and literal symbolic links; it does not promise ownership, ACL, extended-attribute, alternate-stream, or sparse-layout preservation. On Windows, byte copying rejects unresolved symbolic links because Node does not expose their file/directory link type; resolved links keep their literal target and source type. POSIX dangling links are preserved. Choose a copying policy that meets the caller's metadata requirements; automatic copying can select either path.
|
|
76
|
+
|
|
77
|
+
`readCloneFileMetadata(files)` asynchronously reads APFS data-stream identities and file metadata in one native batch. Results correspond to input order; missing or unsupported entries return `undefined`. The returned `CloneFileMetadata` includes clone ID, device/inode, size, mode, ownership, and timestamps. These are point-in-time observations, not authorization or proof that later reads remain unchanged. Consumers such as Git index adapters must validate their own content and timestamp invariants. The reader does not follow leaf symbolic links.
|
|
78
|
+
|
|
79
|
+
## Borrowed FileHandle transfers
|
|
80
|
+
|
|
81
|
+
`copyFileHandle` from `@openclaw/fs-safe/advanced` copies bytes between two
|
|
82
|
+
already-open regular files. Use it when a snapshot or materialization owner
|
|
83
|
+
has admitted the source and opened its own destination:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { createHash } from "node:crypto";
|
|
87
|
+
import { copyFileHandle } from "@openclaw/fs-safe/advanced";
|
|
88
|
+
|
|
89
|
+
const digest = createHash("sha256");
|
|
90
|
+
const bytes = await copyFileHandle(sourceHandle, targetHandle, {
|
|
91
|
+
maxBytes: expectedSize,
|
|
92
|
+
signal: AbortSignal.timeout(30_000),
|
|
93
|
+
onChunk: (chunk) => { digest.update(chunk); },
|
|
94
|
+
assertBeforeMutation: assertSnapshotOwnerCurrent,
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`CopyFileHandleOptions` contains optional `maxBytes`, `signal`, `onChunk`, and
|
|
99
|
+
`assertBeforeMutation`. The result is the actual byte count copied through EOF.
|
|
100
|
+
The byte limit is not a prefix length: excess data rejects with `too-large`,
|
|
101
|
+
including data added after admission. Omitted limits are unlimited; Root's
|
|
102
|
+
default read cap does not apply. Zero accepts only an empty source. Invalid
|
|
103
|
+
limits reject before descriptor inspection.
|
|
104
|
+
|
|
105
|
+
The source and target must be distinct regular files; exact device/inode
|
|
106
|
+
aliases, including two handles to hardlinked names, reject before writing.
|
|
107
|
+
Both reads and writes start at position zero and preserve the handles' current
|
|
108
|
+
cursors. Existing destination bytes beyond the copied prefix remain intact.
|
|
109
|
+
The target must have been opened **without append mode**: some platforms ignore
|
|
110
|
+
positional writes on append handles, and Node exposes no portable open-flags
|
|
111
|
+
query. Keep both handles open and free of concurrent I/O through settlement.
|
|
112
|
+
|
|
113
|
+
The synchronous `onChunk` observer sees each source chunk before any target
|
|
114
|
+
write for that chunk. It receives a borrowed view reused by later reads; consume
|
|
115
|
+
it immediately without retaining or mutating it. This supports source hashing;
|
|
116
|
+
it does not verify bytes persisted by the destination. Callers that require a
|
|
117
|
+
destination digest must still hash the destination handle afterward. Observer
|
|
118
|
+
and authority callbacks may throw; thenable returns reject with `TypeError`
|
|
119
|
+
before the affected write. `assertBeforeMutation` runs immediately before every
|
|
120
|
+
partial-write submission and must inspect current authority each time.
|
|
121
|
+
|
|
122
|
+
The helper reuses Root copying's bounded read buffer and completes positive
|
|
123
|
+
short reads and writes. JavaScript file transfers use at most 512 KiB of scratch
|
|
124
|
+
space, reduced for smaller source-size hints and capped by a finite byte budget
|
|
125
|
+
plus its one-byte overflow probe. A zero-progress write rejects with `helper-failed`.
|
|
126
|
+
Cancellation is checked before I/O, after source reads, and before each write;
|
|
127
|
+
admitted reads and writes settle before rejection. A rejected operation can
|
|
128
|
+
leave a copied prefix. There is no rollback or pathname cleanup.
|
|
129
|
+
|
|
130
|
+
This helper never opens or closes a file, truncates, chmods, syncs, renames, or
|
|
131
|
+
publishes it. Source admission, immutability checks, destination preparation,
|
|
132
|
+
durability, publication, and failure recovery stay with the caller. Initial
|
|
133
|
+
descriptor inspection does not prove that the source remained unchanged while
|
|
134
|
+
copying. Keep existing source-fingerprint and publication checks around the
|
|
135
|
+
transfer when building snapshot operations.
|
|
136
|
+
|
|
137
|
+
## Ownership and cancellation
|
|
138
|
+
|
|
139
|
+
These are low-level operations on caller-owned absolute paths, not Root-relative methods. The source and destination parent must be real directories. The library pins their descriptors and verifies their identities; it does not establish the caller's authorization to use them. Keep the source immutable for the operation, including writes through other aliases, and keep the destination namespace under the caller's control. Literal symlinks in the cloned contents are preserved rather than followed or sanitized.
|
|
140
|
+
|
|
141
|
+
An already aborted signal prevents dispatch. In-flight cancellation stops cancellable traversal and waits for admitted native writes to finish before rejecting. APFS and Btrfs bulk operations cannot be interrupted once dispatched. An aborted or failed call can therefore leave a destination, including a complete bulk clone. It remains caller-owned; after settlement, the caller decides whether to retain or remove it. Do not start cleanup by racing the cloning promise against an abort promise.
|
|
142
|
+
|
|
143
|
+
Completion is not a crash-durability guarantee. The API is suitable for reconstructible templates and checkouts; it does not sync every file or replace application-level publication and recovery rules.
|
|
144
|
+
|
|
145
|
+
Byte copying retains fractional file and directory access/modification timestamps to the precision supported by Node's timestamp APIs and the destination filesystem. This includes dates before 1970 on Unix. On Windows, [Node's unsigned stat seconds](https://github.com/nodejs/node/blob/v26.8.2/src/node_file-inl.h#L93-L104) can report pre-1970 timestamps as dates about 136 years later; byte copying inherits that upstream limitation.
|
|
146
|
+
|
|
147
|
+
## Platform tests and benchmarks
|
|
148
|
+
|
|
149
|
+
After building the host native binding, run `pnpm test test/clone.test.ts test/copy-tree.test.ts`. APFS tests can use the normal macOS temporary directory. For Btrfs, ReFS, XFS, or ZFS, set `FS_SAFE_CLONE_TEST_ROOT` to an existing writable directory on that filesystem. The test creates and cleans only its own temporary children. An explicitly configured unsupported directory fails the test rather than silently skipping platform proof. XFS and ZFS metadata tests require the `attr` and `acl` utilities.
|
|
150
|
+
|
|
151
|
+
Run `node scripts/clone-xfs-proof.mjs MOUNT` on a real XFS volume to verify the public API, hashes, independent writes, and shared physical extents. It requires `filefrag` from `e2fsprogs`. Add `no-reflink` for an XFS fixture formatted with reflinks disabled; strict copying must fail and automatic copying must succeed through byte copying.
|
|
152
|
+
|
|
153
|
+
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.
|
|
154
|
+
|
|
155
|
+
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.
|