@openclaw/fs-safe 0.9.0 → 0.10.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 +78 -0
- package/README.md +17 -0
- package/dist/advanced.d.ts +1 -0
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +1 -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-gzip-tail.d.ts +2 -0
- package/dist/archive-gzip-tail.d.ts.map +1 -1
- package/dist/archive-gzip-tail.js +20 -3
- package/dist/archive-merge.js +1 -1
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +3 -2
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +67 -66
- 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 +20 -8
- package/dist/archive-tar-wasm.d.ts.map +1 -1
- package/dist/archive-tar-wasm.js +2 -1
- package/dist/bounded-read.d.ts +7 -0
- package/dist/bounded-read.d.ts.map +1 -1
- package/dist/bounded-read.js +73 -43
- 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 +23 -0
- package/dist/copy-file-input.d.ts.map +1 -0
- package/dist/copy-file-input.js +91 -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 +191 -0
- package/dist/copy.d.ts +16 -0
- package/dist/copy.d.ts.map +1 -0
- package/dist/copy.js +123 -0
- package/dist/durability.d.ts +1 -1
- package/dist/durability.d.ts.map +1 -1
- package/dist/error-detail.d.ts.map +1 -1
- package/dist/error-detail.js +4 -1
- package/dist/file-hash.d.ts +6 -2
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +44 -8
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +2 -12
- package/dist/guarded-mkdir.d.ts +1 -0
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +1 -0
- 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/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 +2 -6
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +13 -14
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +22 -24
- 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 +55 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +26 -31
- package/dist/mutation-authority.d.ts +8 -0
- package/dist/mutation-authority.d.ts.map +1 -0
- package/dist/mutation-authority.js +36 -0
- package/dist/native-binding.d.ts +24 -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 +18 -7
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +1 -0
- 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/path.d.ts.map +1 -1
- package/dist/path.js +3 -1
- package/dist/permissions-windows.d.ts.map +1 -1
- package/dist/permissions-windows.js +38 -48
- package/dist/pinned-operation.js +1 -1
- package/dist/pinned-write.d.ts +5 -1
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +50 -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/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/replace-file-temp-owner.d.ts.map +1 -1
- package/dist/replace-file-temp-owner.js +6 -2
- package/dist/root-context.d.ts +3 -0
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +51 -26
- package/dist/root-directory-list.d.ts +24 -0
- package/dist/root-directory-list.d.ts.map +1 -0
- package/dist/root-directory-list.js +201 -0
- package/dist/root-errors.d.ts +1 -0
- package/dist/root-errors.d.ts.map +1 -1
- package/dist/root-errors.js +3 -0
- 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 +6 -50
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +330 -210
- package/dist/root-options.d.ts +66 -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 +56 -1
- 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-symlink-policy.d.ts +13 -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 +1 -4
- package/dist/root.d.ts +3 -1
- package/dist/root.d.ts.map +1 -1
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +4 -4
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +2 -1
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +3 -1
- package/dist/temp-cleanup.d.ts.map +1 -1
- package/dist/temp-cleanup.js +3 -2
- package/dist/timing.d.ts +1 -0
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +25 -6
- package/dist/trash.js +2 -2
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +6 -26
- 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 +6 -0
- package/dist/write-file-handle.d.ts.map +1 -0
- package/dist/write-file-handle.js +25 -0
- package/docs/advanced.md +18 -4
- package/docs/archive.md +22 -7
- package/docs/atomic.md +7 -0
- package/docs/contributing.md +7 -0
- package/docs/copy.md +86 -0
- package/docs/durability.md +17 -2
- package/docs/errors.md +1 -1
- package/docs/local-roots.md +8 -1
- package/docs/native.md +14 -1
- package/docs/permissions.md +20 -10
- package/docs/positional-read.md +63 -0
- package/docs/root.md +166 -4
- package/docs/security-model.md +14 -0
- package/docs/timing.md +2 -0
- package/docs/types.md +18 -11
- package/docs/walk.md +54 -5
- package/docs/writing.md +20 -2
- package/package.json +13 -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,6 @@
|
|
|
1
|
+
import type { FileHandle } from "node:fs/promises";
|
|
2
|
+
export declare function writeAllToFile(target: FileHandle | number, data: string | Uint8Array, options?: {
|
|
3
|
+
encoding?: BufferEncoding;
|
|
4
|
+
assertBeforeMutation?: () => void;
|
|
5
|
+
}): Promise<void>;
|
|
6
|
+
//# 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,oBAAoB,CAAC,EAAE,MAAM,IAAI,CAAA;CAAO,GAC7E,OAAO,CAAC,IAAI,CAAC,CAmBf"}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
options.assertBeforeMutation?.();
|
|
10
|
+
const written = typeof target === "number"
|
|
11
|
+
? await new Promise((resolve, reject) => {
|
|
12
|
+
fs.write(target, buffer, offset, length, null, (error, bytesWritten) => {
|
|
13
|
+
if (error)
|
|
14
|
+
reject(error);
|
|
15
|
+
else
|
|
16
|
+
resolve(bytesWritten);
|
|
17
|
+
});
|
|
18
|
+
})
|
|
19
|
+
: (await target.write(buffer, offset, length, null)).bytesWritten;
|
|
20
|
+
if (written <= 0) {
|
|
21
|
+
throw new FsSafeError("helper-failed", "file write made no progress");
|
|
22
|
+
}
|
|
23
|
+
offset += written;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/docs/advanced.md
CHANGED
|
@@ -65,7 +65,8 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
|
|
|
65
65
|
| Export | Page | Notes |
|
|
66
66
|
|---|---|---|
|
|
67
67
|
| `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
|
-
| `
|
|
68
|
+
| `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. |
|
|
69
|
+
| `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
70
|
| `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
|
|
70
71
|
| `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
|
|
71
72
|
| `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
|
|
@@ -79,6 +80,19 @@ receipts remain numeric. Custom `ioFs` adapters must honor `{ bigint: true }` fo
|
|
|
79
80
|
Windows identities receive one re-inspection without reopening, then fail
|
|
80
81
|
validation if still unknown.
|
|
81
82
|
|
|
83
|
+
The explicit `symlinks` policy takes precedence over the existing `rejectSymlinks`
|
|
84
|
+
boolean. Without `symlinks`, `rejectSymlinks: false` retains its existing behavior
|
|
85
|
+
of following contained links, and omission still rejects all symlink components.
|
|
86
|
+
|
|
87
|
+
The bounded descriptor helpers cap their initial speculative allocation at
|
|
88
|
+
16 MiB plus the overflow-probe byte, even when a file reports a much larger
|
|
89
|
+
size. Regular files up to that size can return from one read without copying
|
|
90
|
+
chunks; larger files and short reads continue incrementally under the same
|
|
91
|
+
byte limit. Continuation buffers grow only after filling with actual bytes,
|
|
92
|
+
up to the byte budget plus its probe; file-size hints cannot force that growth.
|
|
93
|
+
Unknown-size inputs start with at most 64 KiB. This avoids per-chunk copies and
|
|
94
|
+
a final concatenation when a file exceeds the initial allocation.
|
|
95
|
+
|
|
82
96
|
The bounded descriptor helpers start at the descriptor's current offset and
|
|
83
97
|
leave ownership with the caller. They are intended for the second half of a
|
|
84
98
|
safe read: first open and validate the path using the boundary appropriate to
|
|
@@ -117,7 +131,7 @@ component is followed by another segment, both helpers throw
|
|
|
117
131
|
|---|---|---|
|
|
118
132
|
| `safeDirName`, `safePathSegmentHashed`, `resolveSafeInstallDir`, `assertCanonicalPathWithinBase` | [install-path.md](install-path.md) | Build install-target directories from caller-supplied identifiers. |
|
|
119
133
|
| `sanitizeUntrustedFileName` | [filename.md](filename.md) | Coerce an untrusted string into a safe filename. |
|
|
120
|
-
| `resolveHomeRelativePath` | – | Expand
|
|
134
|
+
| `resolveHomeRelativePath` | – | Expand a leading `~` before resolving `.` and `..`; tildes inside relative paths stay literal. |
|
|
121
135
|
|
|
122
136
|
### Temp targets and sibling-temp writes
|
|
123
137
|
|
|
@@ -139,7 +153,7 @@ component is followed by another segment, both helpers throw
|
|
|
139
153
|
| Export | Page | Notes |
|
|
140
154
|
|---|---|---|
|
|
141
155
|
| `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 `
|
|
156
|
+
| `withTimeout` | [timing.md](timing.md) | Wrap a promise with a timeout that raises `Error` by default, or an error supplied by `createError`. |
|
|
143
157
|
| `movePathToTrash`, `MovePathToTrashOptions` | – | Best-effort move to the platform trash. |
|
|
144
158
|
|
|
145
159
|
## Stability
|
|
@@ -149,5 +163,5 @@ Items in this surface can change shape between minor versions if a higher-level
|
|
|
149
163
|
## Related pages
|
|
150
164
|
|
|
151
165
|
- [Root API](root.md) — built on top of these helpers.
|
|
152
|
-
- [Errors](errors.md) —
|
|
166
|
+
- [Errors](errors.md) — shared filesystem error codes; generic timing helpers retain their documented error types.
|
|
153
167
|
- [Security model](security-model.md) — what the underlying boundary checks promise.
|
package/docs/archive.md
CHANGED
|
@@ -225,11 +225,13 @@ If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and thr
|
|
|
225
225
|
The destination merge is nontransactional: each file is published atomically,
|
|
226
226
|
but completed files and directories can remain when a later copy, post-copy
|
|
227
227
|
check, mode application, or deadline fails. This also applies to
|
|
228
|
-
`mergeExtractedTreeIntoDestination()`. Guarded `Root.copyIn()` owns
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
228
|
+
`mergeExtractedTreeIntoDestination()`. Guarded `Root.copyIn()` owns unpublished
|
|
229
|
+
stage cleanup and preserves completed publications. The archive merge never
|
|
230
|
+
acquires rollback authority over the current destination name. Replacing a
|
|
231
|
+
source path after publication rejects the merge with `path-mismatch` while
|
|
232
|
+
preserving the admitted bytes already published, or any later destination edit.
|
|
233
|
+
A failure before publication preserves a pre-existing file, and rejection does
|
|
234
|
+
not grant authority to delete a substituted file or alias. Failed extraction does not
|
|
233
235
|
restore overwritten contents. Active destination mutations and their guarded
|
|
234
236
|
cleanup still finish before rejection; no later destination mutation begins.
|
|
235
237
|
New directories whose finalization was never reached can retain their
|
|
@@ -555,8 +557,8 @@ await extractArchive({
|
|
|
555
557
|
## `readArchiveEntry`
|
|
556
558
|
|
|
557
559
|
`readArchiveEntry(archivePath, entryPath, { maxBytes, kind? })` reads one
|
|
558
|
-
regular-file entry into a bounded `Buffer` without extracting a tree. It
|
|
559
|
-
|
|
560
|
+
regular-file entry into a bounded `Buffer` without extracting a tree. It reads
|
|
561
|
+
the input through an identity-checked descriptor, rejects link, directory, and duplicate
|
|
560
562
|
entries, verifies ZIP CRC and declared size,
|
|
561
563
|
and throws `ArchiveLimitError` if the requested entry's output exceeds
|
|
562
564
|
`maxBytes`. ZIP output within that cap must match the declared uncompressed
|
|
@@ -571,6 +573,19 @@ limits. It does not apply payload budgets to unrequested members. ZIP
|
|
|
571
573
|
inputs retain the archive subpath's 256 MiB compressed-input ceiling.
|
|
572
574
|
With a native binding it uses the same Rust decoders as extraction, including
|
|
573
575
|
zstd and bzip2 TAR. Without native it retains the JS ZIP/TAR/gzip implementation.
|
|
576
|
+
Archive member reads retain their private in-memory input without a disk
|
|
577
|
+
snapshot. The native ZIP reader retains the private allocation and parsed directory across worker-thread
|
|
578
|
+
inspection and reading without an extra archive-byte copy.
|
|
579
|
+
Decompression still allocates its bounded output; Node receives that native
|
|
580
|
+
allocation without another copy where external buffers are supported.
|
|
581
|
+
Native TAR retains the fully admitted member offsets alongside the same input
|
|
582
|
+
allocation. Plain TAR copies only the selected payload range after full archive
|
|
583
|
+
validation. Gzip, zstd, and bzip2 replay bounded decompression and still validate
|
|
584
|
+
all framing, trailers, and physical padding before returning. The JavaScript
|
|
585
|
+
TAR/gzip fallback streams views of the private input into the shared WASM parser
|
|
586
|
+
for admission and replay; WASM transport and selected output still require copies.
|
|
587
|
+
Returned buffers own their bytes, so changing a result cannot modify an archive
|
|
588
|
+
reader or retain an unrelated part of the input through its backing ArrayBuffer.
|
|
574
589
|
|
|
575
590
|
Requested paths and effective member names use extraction's canonical pre-strip
|
|
576
591
|
identity: backslashes become `/`, and repeated separators and `.` components
|
package/docs/atomic.md
CHANGED
|
@@ -207,6 +207,13 @@ directory mode. Staged file modes are applied through their still-open handles.
|
|
|
207
207
|
If descriptor-bound mode application fails, the staged path is removed and the
|
|
208
208
|
move fails before publication. A transient staged-path cleanup failure retains
|
|
209
209
|
an identity-bound process-exit cleanup retry.
|
|
210
|
+
The staging entry's initially admitted identity is checked before publication
|
|
211
|
+
and cleanup; a later substituted entry is preserved. Regular files are admitted
|
|
212
|
+
through their new descriptor. Node provides no creation descriptor for directories
|
|
213
|
+
or symlinks, so their first identity comes from an immediate pathname observation.
|
|
214
|
+
Replacement before that observation remains a best-effort detection gap: use a
|
|
215
|
+
parent protected from concurrent untrusted mutation or OS isolation. A copy write
|
|
216
|
+
that makes no progress rejects instead of looping indefinitely.
|
|
210
217
|
On POSIX, staged directory modes are applied through no-follow directory
|
|
211
218
|
descriptors; on Windows, Node cannot portably open those descriptors and no
|
|
212
219
|
pathname `chmod` fallback is attempted, so directory modes remain subject to
|
package/docs/contributing.md
CHANGED
|
@@ -68,6 +68,13 @@ pnpm check
|
|
|
68
68
|
This runs the filesystem boundary checks, build, tests, and package
|
|
69
69
|
tarball/import validation.
|
|
70
70
|
|
|
71
|
+
### Method benchmarks
|
|
72
|
+
|
|
73
|
+
`pnpm benchmark:methods` measures the callable library surface against synthetic
|
|
74
|
+
fixtures and fails on uncovered exports or returned methods. See the
|
|
75
|
+
[benchmark guide](https://github.com/openclaw/fs-safe/tree/main/benchmarks) for native/fallback runs, per-call
|
|
76
|
+
timings, exclusions, and comparison methodology. Run `pnpm build` first.
|
|
77
|
+
|
|
71
78
|
### Real TAR producers
|
|
72
79
|
|
|
73
80
|
After installing the freshly packed root (and optionally its freshly built host
|
package/docs/copy.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
|
|
30
|
+
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 and XFS share file data rather than the whole directory metadata tree, so creating many small files still has a cost.
|
|
31
|
+
|
|
32
|
+
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.
|
|
33
|
+
|
|
34
|
+
### APFS permissions
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
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.
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
`TreeCloneBackend` is the `"apfs" | "btrfs" | "refs" | "xfs"` 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"`.
|
|
45
|
+
|
|
46
|
+
`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.
|
|
47
|
+
|
|
48
|
+
`createCloneSource(destination, { signal? })` creates an empty cloneable source. Its parent must already exist and the destination must be absent.
|
|
49
|
+
|
|
50
|
+
`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.
|
|
51
|
+
|
|
52
|
+
| `clone` policy | Behavior |
|
|
53
|
+
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
54
|
+
| `"auto"` (default) | Prefer native cloning; copy bytes when the binding or filesystem capability is unavailable, or cloning cannot cross the filesystem boundary. |
|
|
55
|
+
| `"always"` | Require native cloning. Unsupported operations fail without a byte-copy fallback. |
|
|
56
|
+
| `"never"` | Copy regular file bytes using reads and writes. No native cloning or copy-offload calls. Works without a native binding. |
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
`concurrency` accepts integers from 1 through 32 and bounds active file copies. ReFS and XFS 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.
|
|
61
|
+
|
|
62
|
+
On Windows, automatic byte copying uses the native binding when available to transfer data between the already-checked file handles in 1 MiB chunks. 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 wait for all admitted writes after cancellation or failure and restore directory timestamps only after their file copies finish.
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
XFS preserves regular-file and directory modes, timestamps, extended attributes, and ACLs. It rejects 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.
|
|
69
|
+
|
|
70
|
+
`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.
|
|
71
|
+
|
|
72
|
+
## Ownership and cancellation
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
## Platform tests and benchmarks
|
|
81
|
+
|
|
82
|
+
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, or XFS, 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 metadata tests require the `attr` and `acl` utilities.
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
86
|
+
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.
|
package/docs/durability.md
CHANGED
|
@@ -199,7 +199,7 @@ import { sha256File } from "@openclaw/fs-safe/durability";
|
|
|
199
199
|
const snapshot = await open(stagedArchive, "r");
|
|
200
200
|
try {
|
|
201
201
|
const before = await snapshot.stat();
|
|
202
|
-
const hash = await sha256File(snapshot);
|
|
202
|
+
const hash = await sha256File(snapshot, { maxBytes: before.size });
|
|
203
203
|
if (hash.bytes !== before.size || hash.digest !== manifest.sha256) {
|
|
204
204
|
throw new Error("staged backup does not match its manifest");
|
|
205
205
|
}
|
|
@@ -209,6 +209,21 @@ try {
|
|
|
209
209
|
```
|
|
210
210
|
|
|
211
211
|
The result is `{ bytes, digest }`, where `digest` is lowercase hexadecimal.
|
|
212
|
+
The optional `Sha256FileOptions` argument supports `maxBytes` and `signal`.
|
|
213
|
+
`maxBytes` accepts a non-negative safe integer (including zero), or
|
|
214
|
+
`Infinity` for no limit, which is also the default. Oversized files reject with
|
|
215
|
+
`FsSafeError("too-large")`; reads stay bounded to at most `maxBytes + 1` bytes,
|
|
216
|
+
even if the file grows after its initial size check. Hashing never silently
|
|
217
|
+
truncates to the limit.
|
|
218
|
+
|
|
219
|
+
Pass `signal` to cancel. A pre-aborted signal rejects before file I/O or native
|
|
220
|
+
loading. In-flight cancellation is cooperative between reads and rejects with
|
|
221
|
+
the signal's original reason only after the pending read or native task stops.
|
|
222
|
+
Callers may close their handle after awaiting rejection; do not close it while
|
|
223
|
+
the operation is pending. A signal can be shared by successive or concurrent
|
|
224
|
+
hashes. Neither mode provides a snapshot of concurrently modified contents;
|
|
225
|
+
callers requiring stable content must also fence identity and metadata.
|
|
226
|
+
|
|
212
227
|
The handle overload never closes the caller's descriptor and uses positioned
|
|
213
228
|
reads, so it does not alter the descriptor's current offset. The path overload
|
|
214
229
|
rejects symbolic links and non-regular files, compares lossless bigint identities
|
|
@@ -226,7 +241,7 @@ descriptor inspection rather than waiting for a writer.
|
|
|
226
241
|
When the optional binding is active, hashing runs as an async native task and
|
|
227
242
|
does not occupy the JavaScript event loop with digest updates. With native mode
|
|
228
243
|
`off`, or in `auto` when no binding loads, the fallback performs asynchronous
|
|
229
|
-
positioned reads in
|
|
244
|
+
positioned reads in chunks of up to 256 KiB but updates Node's `Hash` on the JavaScript
|
|
230
245
|
thread. Both paths stream constant-size buffers rather than loading the file
|
|
231
246
|
into memory. Native mode `require` keeps its usual fail-closed loader semantics.
|
|
232
247
|
|
package/docs/errors.md
CHANGED
|
@@ -132,7 +132,7 @@ type FsSafeErrorCode =
|
|
|
132
132
|
| `symlink` | Path component is a symlink, policy is `reject`. | Caller followed a symlink they shouldn't have, or `symlinks: "reject"` is set. |
|
|
133
133
|
| `timeout` | An operation with a wall-clock budget overran. | Secure file read or timed operation exceeded `timeoutMs`. |
|
|
134
134
|
| `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
|
|
135
|
-
| `unsupported-platform` |
|
|
135
|
+
| `unsupported-platform` | The platform or filesystem cannot perform the requested operation. | `createCloneSource` and `copyTree({ clone: "always" })` require native cloning support. The default `copyTree({ clone: "auto" })` selects portable byte copying when cloning is unavailable; unsupported source contents or metadata still fail. See [directory copying](copy.md) for backend limits and fallback behavior. |
|
|
136
136
|
|
|
137
137
|
Secret writes reject invalid `mode` / `dirMode` values with `invalid-path` before directory creation. Existing secret directories with a mode different from the requested `dirMode` report `insecure-permissions` without chmod; a created directory whose descriptor ownership no longer matches its initializing effective user reports `not-owned`.
|
|
138
138
|
|
package/docs/local-roots.md
CHANGED
|
@@ -71,6 +71,13 @@ component that does not exist: dangling symlinks, descendants of dangling
|
|
|
71
71
|
symlinks, and candidates whose existing ancestors cannot be canonicalized are
|
|
72
72
|
rejected rather than treated as safe missing paths.
|
|
73
73
|
|
|
74
|
+
Filesystem path inputs retain symlinks and parent components until boundary
|
|
75
|
+
resolution, including after home expansion. A followed `link/../file` resolves
|
|
76
|
+
the parent of the link's target; default reads reject the link instead of
|
|
77
|
+
normalizing it away. File URLs retain the URL parser's normal path semantics.
|
|
78
|
+
An existing non-directory component cannot be traversed further, including by
|
|
79
|
+
`..`; the helpers reject that input instead of selecting a different file.
|
|
80
|
+
|
|
74
81
|
## `readLocalFileFromRoots(options)`
|
|
75
82
|
|
|
76
83
|
The asynchronous helper opens the candidate through the matched [`Root`](root.md),
|
|
@@ -82,7 +89,7 @@ type ReadLocalFileFromRootsOptions = LocalRootsInputOptions & {
|
|
|
82
89
|
hardlinks?: "reject" | "allow";
|
|
83
90
|
maxBytes?: number;
|
|
84
91
|
nonBlockingRead?: boolean;
|
|
85
|
-
symlinks?: "reject" | "follow-within-root";
|
|
92
|
+
symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root";
|
|
86
93
|
};
|
|
87
94
|
|
|
88
95
|
type LocalRootsReadResult = ReadResult & {
|
package/docs/native.md
CHANGED
|
@@ -59,6 +59,19 @@ whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
|
|
|
59
59
|
|
|
60
60
|
## Archives
|
|
61
61
|
|
|
62
|
+
Native ZIP and TAR entry reads retain a private, unpooled input Buffer in
|
|
63
|
+
the async reader. ZIP reuses its parsed directory; TAR retains admitted member
|
|
64
|
+
offsets. TypeScript validates and selects the requested member before reading. The internal binding borrows that allocation: it must never be
|
|
65
|
+
mutated or detached while the reader or a read task exists. The public API only
|
|
66
|
+
accepts a pathname and owns this buffer exclusively. An N-API reference keeps
|
|
67
|
+
the bytes alive; a mutex serializes access to the retained ZIP cursor. Plain
|
|
68
|
+
TAR copies only the selected range after complete admission, while gzip, zstd,
|
|
69
|
+
and bzip2 replay bounded decompression and validate the full physical stream.
|
|
70
|
+
Concurrent TAR reads share immutable input and own separate decoder state.
|
|
71
|
+
Each output owns a new vector, which N-API transfers to Node without a second
|
|
72
|
+
payload copy on runtimes supporting external buffers. No entry-read path
|
|
73
|
+
requires temporary-file staging. Extraction retains its private staged input.
|
|
74
|
+
|
|
62
75
|
Rust streams ZIP and TAR payloads, including gzip, zstd, and bzip2. It first
|
|
63
76
|
returns a bounded manifest. TypeScript applies the shared path, filter, strip,
|
|
64
77
|
mode, and byte policies and returns an index-bound extraction plan. Rust then
|
|
@@ -165,7 +178,7 @@ remain TypeScript-owned. What changes is the syscall strength or availability:
|
|
|
165
178
|
| Zstd/bzip2 TAR | Supported. | Unsupported; typed `helper-unavailable`. |
|
|
166
179
|
| Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
|
|
167
180
|
| `rename-noreplace` | Atomic platform no-replace rename. | Unsupported; no emulation by check-then-rename. |
|
|
168
|
-
| Windows DACL read | Direct `GetSecurityInfo`; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. |
|
|
181
|
+
| Windows DACL read | Direct `GetSecurityInfo`; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. | Structured .NET owner/DACL inspection for coarse permission checks; the public raw ACE facts API remains native-only. |
|
|
169
182
|
| Windows private directory | Creation-time protected DACL. | Unsupported; no weaker pathname-only substitute. |
|
|
170
183
|
|
|
171
184
|
Use `off` in CI to keep the fallback contract exercised. Use `require` when a
|
package/docs/permissions.md
CHANGED
|
@@ -68,11 +68,18 @@ createIcaclsResetCommand(targetPath, { isDir, env });
|
|
|
68
68
|
resolveWindowsUserPrincipal(env);
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
The fallback Windows inspector
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
The fallback Windows inspector reads the owner and DACL together through one
|
|
72
|
+
built-in Windows PowerShell/.NET query. It returns canonical SIDs and numeric
|
|
73
|
+
access masks, so Unicode paths and account names do not pass through lossy
|
|
74
|
+
console display text. `inspectWindowsAcl()` uses the same query and returns
|
|
75
|
+
canonical SIDs in its `principal` fields, with normalized rights tokens.
|
|
76
|
+
The advanced options retain `currentUserSid` as an explicit classification
|
|
77
|
+
override and `principalTranslationFailed: true` as an immediate unverified
|
|
78
|
+
result. The optional `principalSids` translation cache is still accepted but
|
|
79
|
+
is no longer needed because the query returns SIDs directly.
|
|
80
|
+
The existing classifier assigns principals to trusted, world, or group;
|
|
81
|
+
trusted defaults include the current user, SYSTEM, and Administrators.
|
|
82
|
+
The built-in query has a fixed 30-second process deadline. A command failure or timeout returns an
|
|
76
83
|
unverified result (`source: "unknown"`) so callers fail closed. Advanced callers
|
|
77
84
|
that inject a custom `exec` implementation own that executor's deadline.
|
|
78
85
|
Failed owner and ACL inspections retain `error` text and an optional
|
|
@@ -86,15 +93,18 @@ characters, including a trailing `…` when truncated. Diagnostics do not copy
|
|
|
86
93
|
stdout or read target file contents. The separate `errorCause` retains the
|
|
87
94
|
original exception for restricted local diagnosis; do not serialize or expose
|
|
88
95
|
it as display text.
|
|
89
|
-
The parser
|
|
90
|
-
`icacls` output
|
|
96
|
+
The parser and remediation command builders remain on the advanced surface for
|
|
97
|
+
CLIs processing captured `icacls` output or presenting an explicit repair.
|
|
98
|
+
Runtime inspection does not parse that display text. A null DACL reports
|
|
99
|
+
unrestricted access; an empty DACL grants nothing. Inherit-only ACEs do not
|
|
100
|
+
apply to the inspected object, and deny ACEs never subtract coarse grants or
|
|
101
|
+
claim effective-access evaluation. Unsupported ACE layouts remain unverified.
|
|
91
102
|
|
|
92
103
|
When the native binding is available, `inspectPathPermissions()`
|
|
93
104
|
reads the owner and DACL directly with Windows security APIs. It classifies the
|
|
94
105
|
current user, LocalSystem, and built-in Administrators as trusted and reports
|
|
95
106
|
the world/group read/write facts consumed by secure reads. Descriptor forms it
|
|
96
|
-
cannot classify equivalently fall back to the
|
|
97
|
-
`icacls` path; `mode: "off"` exercises that fallback deterministically.
|
|
107
|
+
cannot classify equivalently fall back to the structured .NET query; `mode: "off"` exercises that fallback deterministically.
|
|
98
108
|
|
|
99
109
|
## Policy-free owner and DACL facts
|
|
100
110
|
|
|
@@ -164,7 +174,7 @@ This API is Windows-only and native-only; it fails closed with
|
|
|
164
174
|
or when the binding is unavailable. POSIX callers should create private
|
|
165
175
|
directories through their existing trusted-root creation policy rather than a
|
|
166
176
|
pathname-only compatibility shim. Existing Windows permission inspection still
|
|
167
|
-
retains its .NET
|
|
177
|
+
retains its structured .NET compatibility fallback.
|
|
168
178
|
|
|
169
179
|
Use `createIcaclsResetCommand()` when you need a structured command and argv pair. Use `formatIcaclsResetCommand()` when you only need a remediation string for a user-facing message.
|
|
170
180
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Positional reads
|
|
2
|
+
|
|
3
|
+
Use `readFileWindowFully()` and `readFileWindowFullySync()` to read a bounded
|
|
4
|
+
window from an already-open file. They fill a caller-owned `Buffer`, completing
|
|
5
|
+
short reads until the buffer is full or the file reaches EOF, and return the
|
|
6
|
+
number of bytes read. They never allocate a payload buffer, close the descriptor,
|
|
7
|
+
or change its current offset.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { root } from "@openclaw/fs-safe";
|
|
11
|
+
import { readFileWindowFully } from "@openclaw/fs-safe/advanced";
|
|
12
|
+
|
|
13
|
+
const workspace = await root("/srv/workspace");
|
|
14
|
+
await using opened = await workspace.open("large.log");
|
|
15
|
+
const buffer = Buffer.allocUnsafe(4096);
|
|
16
|
+
const bytesRead = await readFileWindowFully(opened.handle, buffer, 8192);
|
|
17
|
+
const window = buffer.subarray(0, bytesRead);
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Signatures
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
type ReadFileWindowOptions = { signal?: AbortSignal };
|
|
24
|
+
|
|
25
|
+
function readFileWindowFully(
|
|
26
|
+
handle: import("node:fs/promises").FileHandle,
|
|
27
|
+
buffer: Buffer,
|
|
28
|
+
position: number,
|
|
29
|
+
options?: ReadFileWindowOptions,
|
|
30
|
+
): Promise<number>;
|
|
31
|
+
|
|
32
|
+
function readFileWindowFullySync(
|
|
33
|
+
fd: number,
|
|
34
|
+
buffer: Buffer,
|
|
35
|
+
position: number,
|
|
36
|
+
): number;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`position` and the exclusive window end (`position + buffer.length`) must be
|
|
40
|
+
non-negative safe integers. Invalid ranges throw `RangeError` before reading.
|
|
41
|
+
A zero-length buffer returns zero without I/O. Reading at or beyond EOF also
|
|
42
|
+
returns zero. If EOF occurs within the window, only the returned prefix is
|
|
43
|
+
written; the remaining buffer bytes stay unchanged. Always slice by the returned
|
|
44
|
+
count before using an unsafe-allocated buffer.
|
|
45
|
+
|
|
46
|
+
These helpers use the caller's open descriptor directly. They do not establish
|
|
47
|
+
path containment, file identity, file-type admission, or a snapshot of concurrently
|
|
48
|
+
modified contents. Use [`Root.open()`](root.md#reads) to admit untrusted paths,
|
|
49
|
+
and keep the handle open and the buffer available until the operation settles.
|
|
50
|
+
Underlying I/O errors propagate unchanged.
|
|
51
|
+
|
|
52
|
+
## Cancellation
|
|
53
|
+
|
|
54
|
+
The async variant accepts `signal`. A pre-aborted signal rejects before I/O.
|
|
55
|
+
In-flight cancellation is checked after the pending read settles and before
|
|
56
|
+
another read starts, preserving the signal's reason. Bytes already read remain
|
|
57
|
+
in the buffer; cancellation does not roll them back. Once the promise settles,
|
|
58
|
+
the caller can reuse the buffer or close its handle without a hidden read still
|
|
59
|
+
running.
|
|
60
|
+
|
|
61
|
+
For a whole-file read that rejects files exceeding a byte limit, use the
|
|
62
|
+
[bounded descriptor readers](advanced.md#files-and-identity) instead. Positional
|
|
63
|
+
reads stop successfully at the requested window and do not probe for extra data.
|