@openclaw/fs-safe 0.5.6 → 0.6.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 +39 -0
- package/README.md +16 -10
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +1 -0
- package/dist/archive-crc32.d.ts +2 -0
- package/dist/archive-crc32.d.ts.map +1 -0
- package/dist/archive-crc32.js +14 -0
- package/dist/archive-kind.js +2 -2
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +7 -0
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +24 -5
- package/dist/archive-tar-meta.d.ts.map +1 -1
- package/dist/archive-tar-meta.js +53 -6
- package/dist/archive-tar-pax.d.ts +8 -0
- package/dist/archive-tar-pax.d.ts.map +1 -0
- package/dist/archive-tar-pax.js +100 -0
- package/dist/archive-tar-runtime.d.ts +1 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -1
- package/dist/archive-zip-admission.d.ts +7 -0
- package/dist/archive-zip-admission.d.ts.map +1 -0
- package/dist/archive-zip-admission.js +60 -0
- package/dist/archive-zip-count.d.ts +2 -0
- package/dist/archive-zip-count.d.ts.map +1 -0
- package/dist/archive-zip-count.js +140 -0
- package/dist/archive-zip-directory.d.ts +8 -0
- package/dist/archive-zip-directory.d.ts.map +1 -0
- package/dist/archive-zip-directory.js +224 -0
- package/dist/archive-zip-integrity.d.ts.map +1 -1
- package/dist/archive-zip-integrity.js +1 -14
- package/dist/archive-zip-names.d.ts +12 -0
- package/dist/archive-zip-names.d.ts.map +1 -0
- package/dist/archive-zip-names.js +99 -0
- package/dist/archive-zip-preflight.d.ts +1 -1
- package/dist/archive-zip-preflight.d.ts.map +1 -1
- package/dist/archive-zip-preflight.js +8 -145
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +32 -23
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +25 -19
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +19 -7
- package/dist/native-binding.d.ts +3 -0
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-operations.d.ts +4 -1
- package/dist/native-operations.d.ts.map +1 -1
- package/dist/native-operations.js +22 -6
- package/dist/native-pinned-write-windows.d.ts +8 -0
- package/dist/native-pinned-write-windows.d.ts.map +1 -0
- package/dist/native-pinned-write-windows.js +92 -0
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +136 -127
- package/dist/native-staged-file.d.ts +24 -0
- package/dist/native-staged-file.d.ts.map +1 -0
- package/dist/native-staged-file.js +337 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +4 -4
- package/dist/opened-realpath.d.ts +2 -0
- package/dist/opened-realpath.d.ts.map +1 -1
- package/dist/opened-realpath.js +12 -7
- package/dist/owner-dacl.d.ts.map +1 -1
- package/dist/owner-dacl.js +2 -1
- package/dist/permission-exec.d.ts +19 -0
- package/dist/permission-exec.d.ts.map +1 -1
- package/dist/permission-exec.js +57 -11
- package/dist/permissions-public.d.ts +1 -1
- package/dist/permissions-public.d.ts.map +1 -1
- package/dist/permissions-windows.d.ts +3 -0
- package/dist/permissions-windows.d.ts.map +1 -1
- package/dist/permissions-windows.js +16 -5
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/pinned-write.d.ts +6 -0
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +19 -29
- package/dist/private-directory.d.ts.map +1 -1
- package/dist/private-directory.js +3 -2
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +78 -72
- package/dist/root-paths.d.ts +11 -14
- package/dist/root-paths.d.ts.map +1 -1
- package/dist/root-paths.js +36 -27
- package/dist/root-write-verification.d.ts +11 -0
- package/dist/root-write-verification.d.ts.map +1 -0
- package/dist/root-write-verification.js +91 -0
- package/dist/secret-file.d.ts +1 -6
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +43 -117
- package/dist/secret-read-async.d.ts +1 -1
- package/dist/secret-read-async.d.ts.map +1 -1
- package/dist/secret-read-async.js +45 -67
- package/dist/secret-read-policy.d.ts +13 -0
- package/dist/secret-read-policy.d.ts.map +1 -0
- package/dist/secret-read-policy.js +28 -0
- package/dist/secret.d.ts +2 -1
- package/dist/secret.d.ts.map +1 -1
- package/dist/secret.js +2 -1
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +25 -16
- package/dist/staged-directory.d.ts +16 -0
- package/dist/staged-directory.d.ts.map +1 -0
- package/dist/staged-directory.js +60 -0
- package/dist/staged-file-types.d.ts +56 -0
- package/dist/staged-file-types.d.ts.map +1 -0
- package/dist/staged-file-types.js +1 -0
- package/dist/staged-file.d.ts +10 -0
- package/dist/staged-file.d.ts.map +1 -0
- package/dist/staged-file.js +15 -0
- package/dist/strict-file-identity.d.ts +6 -0
- package/dist/strict-file-identity.d.ts.map +1 -0
- package/dist/strict-file-identity.js +48 -0
- package/dist/windows-owner.d.ts +3 -0
- package/dist/windows-owner.d.ts.map +1 -1
- package/dist/windows-owner.js +10 -2
- package/docs/advanced.md +11 -1
- package/docs/archive.md +62 -15
- package/docs/config.md +7 -0
- package/docs/contributing.md +45 -7
- package/docs/durability.md +17 -4
- package/docs/errors.md +16 -1
- package/docs/index.md +3 -1
- package/docs/install.md +15 -7
- package/docs/migrating-to-0.5.md +7 -7
- package/docs/migrating-to-0.6.md +43 -0
- package/docs/native-helper.md +15 -5
- package/docs/native.md +40 -11
- package/docs/path-scope.md +28 -2
- package/docs/permissions.md +13 -2
- package/docs/root.md +1 -1
- package/docs/secret-file.md +8 -0
- package/docs/secure-file.md +17 -1
- package/docs/security-model.md +15 -1
- package/docs/sidecar-lock.md +11 -1
- package/docs/staged-file.md +178 -0
- package/docs/testing.md +1 -1
- package/docs/writing.md +19 -0
- package/package.json +15 -8
- package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
- package/dist/native/darwin-x64/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
- package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { FsSafeError } from "./errors.js";
|
|
2
|
+
export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
|
|
3
|
+
export function secretPathErrorCode(error) {
|
|
4
|
+
const code = error.code;
|
|
5
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "not-found" : "invalid-path";
|
|
6
|
+
}
|
|
7
|
+
export function secretReadError(code, action, label, resolvedPath, error) {
|
|
8
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
9
|
+
return new FsSafeError(code, `Failed to ${action} ${label} file at ${resolvedPath}: ${String(cause)}`, { cause });
|
|
10
|
+
}
|
|
11
|
+
export function assertSecretFilePreview(stat, label, resolvedPath, maxBytes, rejectHardlinks) {
|
|
12
|
+
if (!stat.isFile()) {
|
|
13
|
+
throw new FsSafeError("not-file", `${label} file at ${resolvedPath} must be a regular file.`, { cause: undefined });
|
|
14
|
+
}
|
|
15
|
+
if (rejectHardlinks && stat.nlink > 1n) {
|
|
16
|
+
throw new FsSafeError("hardlink", `${label} file at ${resolvedPath} must not be hardlinked.`, { cause: undefined });
|
|
17
|
+
}
|
|
18
|
+
if (stat.size > maxBytes) {
|
|
19
|
+
throw new FsSafeError("too-large", `${label} file at ${resolvedPath} exceeds ${maxBytes} bytes.`, { cause: undefined });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function trimSecretFileContent(raw, label, resolvedPath) {
|
|
23
|
+
const secret = raw.trim();
|
|
24
|
+
if (!secret) {
|
|
25
|
+
throw new FsSafeError("invalid-path", `${label} file at ${resolvedPath} is empty.`, { cause: undefined });
|
|
26
|
+
}
|
|
27
|
+
return secret;
|
|
28
|
+
}
|
package/dist/secret.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { createSecretFileAtomic,
|
|
1
|
+
export { createSecretFileAtomic, PRIVATE_SECRET_DIR_MODE, PRIVATE_SECRET_FILE_MODE, readSecretFileSync, tryReadSecretFileSync, writeSecretFileAtomic, } from "./secret-file.js";
|
|
2
|
+
export { DEFAULT_SECRET_FILE_MAX_BYTES, type SecretFileReadOptions } from "./secret-read-policy.js";
|
|
2
3
|
export { readSecretFile, tryReadSecretFile } from "./secret-read-async.js";
|
|
3
4
|
//# sourceMappingURL=secret.d.ts.map
|
package/dist/secret.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secret.d.ts","sourceRoot":"","sources":["../src/secret.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,
|
|
1
|
+
{"version":3,"file":"secret.d.ts","sourceRoot":"","sources":["../src/secret.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,uBAAuB,EACvB,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,EACrB,qBAAqB,GACtB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,6BAA6B,EAAE,KAAK,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AACpG,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC"}
|
package/dist/secret.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export { createSecretFileAtomic,
|
|
1
|
+
export { createSecretFileAtomic, PRIVATE_SECRET_DIR_MODE, PRIVATE_SECRET_FILE_MODE, readSecretFileSync, tryReadSecretFileSync, writeSecretFileAtomic, } from "./secret-file.js";
|
|
2
|
+
export { DEFAULT_SECRET_FILE_MAX_BYTES } from "./secret-read-policy.js";
|
|
2
3
|
export { readSecretFile, tryReadSecretFile } from "./secret-read-async.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAYrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAYrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAM1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAyLF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAc/B"}
|
package/dist/secure-file.js
CHANGED
|
@@ -4,10 +4,11 @@ import path from "node:path";
|
|
|
4
4
|
import { readFileHandleBounded } from "./bounded-read.js";
|
|
5
5
|
import { assertNoUnsafeDeviceReadPath } from "./device-path.js";
|
|
6
6
|
import { FsSafeError } from "./errors.js";
|
|
7
|
-
import { sameFileIdentity } from "./file-identity.js";
|
|
8
7
|
import { isWindowsDriveLetterPath, isWindowsNetworkPath } from "./local-file-access.js";
|
|
9
8
|
import { isPathInside, isSymlinkOpenError } from "./path.js";
|
|
9
|
+
import { formatPermissionErrorDetail } from "./permission-exec.js";
|
|
10
10
|
import { inspectPathPermissions, isGroupReadable, isGroupWritable, isWorldReadable, isWorldWritable, modeBits, } from "./permissions.js";
|
|
11
|
+
import { inspectFileIdentity } from "./strict-file-identity.js";
|
|
11
12
|
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
|
|
12
13
|
const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
13
14
|
function isAbsolutePathname(value) {
|
|
@@ -52,20 +53,18 @@ async function openSecureHandle(options) {
|
|
|
52
53
|
if (!openedStat.isFile()) {
|
|
53
54
|
throw new FsSafeError("not-file", `${label(options)} must be a file: ${options.filePath}`);
|
|
54
55
|
}
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
const openedIdentity = await inspectFileIdentity(() => handle.stat({ bigint: true }));
|
|
57
|
+
await inspectFileIdentity(async () => {
|
|
58
|
+
const pathStat = options.trust?.allowSymlink
|
|
59
|
+
? await fs.stat(options.filePath, { bigint: true })
|
|
60
|
+
: await fs.lstat(options.filePath, { bigint: true });
|
|
61
|
+
if (!options.trust?.allowSymlink && pathStat.isSymbolicLink()) {
|
|
62
|
+
throw new FsSafeError("symlink", `${label(options)} must not be a symlink: ${options.filePath}`);
|
|
63
|
+
}
|
|
64
|
+
return pathStat;
|
|
65
|
+
}, openedIdentity);
|
|
64
66
|
const realPath = await fs.realpath(options.filePath);
|
|
65
|
-
|
|
66
|
-
if (!sameFileIdentity(realStat, openedStat)) {
|
|
67
|
-
throw new FsSafeError("path-mismatch", `${label(options)} real path changed during open.`);
|
|
68
|
-
}
|
|
67
|
+
await inspectFileIdentity(() => fs.stat(realPath, { bigint: true }), openedIdentity);
|
|
69
68
|
if (options.io?.maxBytes !== undefined && openedStat.size > options.io.maxBytes) {
|
|
70
69
|
throw new FsSafeError("too-large", `${label(options)} exceeded maxBytes (${options.io.maxBytes}).`);
|
|
71
70
|
}
|
|
@@ -111,11 +110,21 @@ async function assertSecurePermissions(options, stat, realPath) {
|
|
|
111
110
|
const permissions = platform === "win32"
|
|
112
111
|
? await inspectPathPermissions(realPath, options.inject)
|
|
113
112
|
: inspectOpenedPermissions(stat, platform);
|
|
113
|
+
const reason = permissions.error ? `: ${formatPermissionErrorDetail(permissions.error)}` : "";
|
|
114
|
+
const diagnostics = {
|
|
115
|
+
...(permissions.errorCause !== undefined ? { cause: permissions.errorCause } : {}),
|
|
116
|
+
...(permissions.ownerError || permissions.errorDetail ? {
|
|
117
|
+
details: {
|
|
118
|
+
...(permissions.ownerError ? { ownerError: formatPermissionErrorDetail(permissions.ownerError) } : {}),
|
|
119
|
+
...permissions.errorDetail,
|
|
120
|
+
},
|
|
121
|
+
} : {}),
|
|
122
|
+
};
|
|
114
123
|
if (!permissions.ok) {
|
|
115
|
-
throw new FsSafeError("permission-unverified", `${label(options)} permissions could not be verified: ${realPath}
|
|
124
|
+
throw new FsSafeError("permission-unverified", `${label(options)} permissions could not be verified: ${realPath}${reason}`, diagnostics);
|
|
116
125
|
}
|
|
117
126
|
if (platform === "win32" && permissions.source === "unknown") {
|
|
118
|
-
throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}
|
|
127
|
+
throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}${reason || "."}`, diagnostics);
|
|
119
128
|
}
|
|
120
129
|
if (platform === "win32" && permissions.ownerTrusted !== true) {
|
|
121
130
|
throw new FsSafeError(permissions.ownerTrusted === false ? "not-owned" : "permission-unverified", `${label(options)} owner could not be trusted on Windows: ${realPath}`);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DirectoryReceipt } from "./directory-durability.js";
|
|
2
|
+
import type { FileIdentityStat } from "./file-identity.js";
|
|
3
|
+
import type { StagedFileReceipt } from "./staged-file-types.js";
|
|
4
|
+
type DirectorySnapshot = StagedFileReceipt["directory"];
|
|
5
|
+
export declare function exactIdentityMatches(expected: FileIdentityStat, actual: Readonly<{
|
|
6
|
+
dev: bigint;
|
|
7
|
+
ino: bigint;
|
|
8
|
+
}>): boolean;
|
|
9
|
+
export declare function describeStagedDirectory(fd: number, pathname: string): DirectorySnapshot;
|
|
10
|
+
export declare function assertStagedDirectoryCurrent(receipt: DirectorySnapshot): void;
|
|
11
|
+
export declare function openStagedDirectory(directory: string | DirectoryReceipt): {
|
|
12
|
+
fd: number;
|
|
13
|
+
receipt: DirectorySnapshot;
|
|
14
|
+
};
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=staged-directory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"staged-directory.d.ts","sourceRoot":"","sources":["../src/staged-directory.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAElE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAEhE,KAAK,iBAAiB,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;AAExD,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,QAAQ,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,GAC7C,OAAO,CAKT;AAED,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAYvF;AAED,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,iBAAiB,GAAG,IAAI,CAQ7E;AAED,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,GAAG;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,iBAAiB,CAAC;CAC5B,CA+BA"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { FsSafeError } from "./errors.js";
|
|
4
|
+
export function exactIdentityMatches(expected, actual) {
|
|
5
|
+
return ["dev", "ino"].every((key) => {
|
|
6
|
+
const value = expected[key];
|
|
7
|
+
return (typeof value === "bigint" || Number.isSafeInteger(value)) && BigInt(value) === actual[key];
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export function describeStagedDirectory(fd, pathname) {
|
|
11
|
+
const identity = fs.fstatSync(fd, { bigint: true });
|
|
12
|
+
if (!identity.isDirectory()) {
|
|
13
|
+
throw new FsSafeError("not-file", "staging parent must be a directory");
|
|
14
|
+
}
|
|
15
|
+
const receipt = Object.freeze({
|
|
16
|
+
path: path.resolve(pathname),
|
|
17
|
+
realPath: fs.realpathSync(pathname),
|
|
18
|
+
identity: Object.freeze({ dev: identity.dev, ino: identity.ino }),
|
|
19
|
+
});
|
|
20
|
+
assertStagedDirectoryCurrent(receipt);
|
|
21
|
+
return receipt;
|
|
22
|
+
}
|
|
23
|
+
export function assertStagedDirectoryCurrent(receipt) {
|
|
24
|
+
const current = fs.lstatSync(receipt.path, { bigint: true });
|
|
25
|
+
if (!current.isDirectory() || !exactIdentityMatches(receipt.identity, current) ||
|
|
26
|
+
fs.realpathSync(receipt.path) !== receipt.realPath) {
|
|
27
|
+
throw new FsSafeError("path-mismatch", "staging directory pathname changed");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function openStagedDirectory(directory) {
|
|
31
|
+
// Copy supplied facts before any asynchronous work; receipts are not authority.
|
|
32
|
+
const pathname = path.resolve(typeof directory === "string" ? directory : directory.path);
|
|
33
|
+
const expected = typeof directory === "string" ? undefined : {
|
|
34
|
+
realPath: directory.realPath, dev: directory.identity.dev, ino: directory.identity.ino,
|
|
35
|
+
};
|
|
36
|
+
const before = fs.lstatSync(pathname, { bigint: true });
|
|
37
|
+
if (!before.isDirectory()) {
|
|
38
|
+
throw new FsSafeError("not-file", "staging parent must be a real directory");
|
|
39
|
+
}
|
|
40
|
+
if (expected && (!exactIdentityMatches(expected, before) || fs.realpathSync(pathname) !== expected.realPath)) {
|
|
41
|
+
throw new FsSafeError("path-mismatch", "stale staging directory receipt");
|
|
42
|
+
}
|
|
43
|
+
const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
44
|
+
try {
|
|
45
|
+
const receipt = describeStagedDirectory(fd, pathname);
|
|
46
|
+
if (!exactIdentityMatches(before, receipt.identity)) {
|
|
47
|
+
throw new FsSafeError("path-mismatch", "staging directory changed while opening");
|
|
48
|
+
}
|
|
49
|
+
return { fd, receipt };
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
try {
|
|
53
|
+
fs.closeSync(fd);
|
|
54
|
+
}
|
|
55
|
+
catch (closeError) {
|
|
56
|
+
throw new AggregateError([error, closeError], "staging directory admission and close failed");
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type StagedFileReceipt = Readonly<{
|
|
2
|
+
directory: Readonly<{
|
|
3
|
+
path: string;
|
|
4
|
+
realPath: string;
|
|
5
|
+
identity: Readonly<{
|
|
6
|
+
dev: bigint;
|
|
7
|
+
ino: bigint;
|
|
8
|
+
}>;
|
|
9
|
+
}>;
|
|
10
|
+
temporaryBasename: string;
|
|
11
|
+
/** Preparation-time metadata of the private stage, not a final-file fingerprint. */
|
|
12
|
+
identity: Readonly<{
|
|
13
|
+
dev: bigint;
|
|
14
|
+
ino: bigint;
|
|
15
|
+
mode: number;
|
|
16
|
+
nlink: bigint;
|
|
17
|
+
size: bigint;
|
|
18
|
+
uid: number;
|
|
19
|
+
gid: number;
|
|
20
|
+
mtimeNs: bigint;
|
|
21
|
+
ctimeNs: bigint;
|
|
22
|
+
}>;
|
|
23
|
+
}>;
|
|
24
|
+
export type PublishedFileReceipt = Readonly<{
|
|
25
|
+
status: "published";
|
|
26
|
+
staged: StagedFileReceipt;
|
|
27
|
+
basename: string;
|
|
28
|
+
overwrite: boolean;
|
|
29
|
+
}>;
|
|
30
|
+
export type StagedFilePublication = Readonly<{
|
|
31
|
+
status: "not-published";
|
|
32
|
+
}> | PublishedFileReceipt | Readonly<{
|
|
33
|
+
status: "indeterminate";
|
|
34
|
+
basename: string;
|
|
35
|
+
overwrite: boolean;
|
|
36
|
+
}>;
|
|
37
|
+
export type StagedFileCleanupReceipt = Readonly<{
|
|
38
|
+
temporaryBasename: string;
|
|
39
|
+
publication: StagedFilePublication;
|
|
40
|
+
status: "removed" | "name-absent" | "preserved" | "failed" | "not-needed";
|
|
41
|
+
resources: "closed" | "close-failed";
|
|
42
|
+
}>;
|
|
43
|
+
export type StagedFileFailureDetails = Readonly<{
|
|
44
|
+
phase: "prepare" | "publish" | "cleanup";
|
|
45
|
+
publication: StagedFilePublication;
|
|
46
|
+
cleanup?: StagedFileCleanupReceipt;
|
|
47
|
+
}>;
|
|
48
|
+
export interface StagedFile extends AsyncDisposable {
|
|
49
|
+
readonly receipt: StagedFileReceipt;
|
|
50
|
+
assertCurrent(): Promise<void>;
|
|
51
|
+
publish(basename: string, options: {
|
|
52
|
+
overwrite: boolean;
|
|
53
|
+
}): Promise<PublishedFileReceipt>;
|
|
54
|
+
cleanup(): Promise<StagedFileCleanupReceipt>;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=staged-file-types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"staged-file-types.d.ts","sourceRoot":"","sources":["../src/staged-file-types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IACvC,SAAS,EAAE,QAAQ,CAAC;QAClB,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,QAAQ,CAAC;YAAE,GAAG,EAAE,MAAM,CAAC;YAAC,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KAClD,CAAC,CAAC;IACH,iBAAiB,EAAE,MAAM,CAAC;IAC1B,oFAAoF;IACpF,QAAQ,EAAE,QAAQ,CAAC;QACjB,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC,CAAC;CACJ,CAAC,CAAC;AAEH,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C,MAAM,EAAE,WAAW,CAAC;IACpB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;CACpB,CAAC,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAC7B,QAAQ,CAAC;IAAE,MAAM,EAAE,eAAe,CAAA;CAAE,CAAC,GACrC,oBAAoB,GACpB,QAAQ,CAAC;IAAE,MAAM,EAAE,eAAe,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAAC,CAAC;AAEhF,MAAM,MAAM,wBAAwB,GAAG,QAAQ,CAAC;IAC9C,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,qBAAqB,CAAC;IACnC,MAAM,EAAE,SAAS,GAAG,aAAa,GAAG,WAAW,GAAG,QAAQ,GAAG,YAAY,CAAC;IAC1E,SAAS,EAAE,QAAQ,GAAG,cAAc,CAAC;CACtC,CAAC,CAAC;AAEH,MAAM,MAAM,wBAAwB,GAAG,QAAQ,CAAC;IAC9C,KAAK,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;IACzC,WAAW,EAAE,qBAAqB,CAAC;IACnC,OAAO,CAAC,EAAE,wBAAwB,CAAC;CACpC,CAAC,CAAC;AAEH,MAAM,WAAW,UAAW,SAAQ,eAAe;IACjD,QAAQ,CAAC,OAAO,EAAE,iBAAiB,CAAC;IACpC,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAC1F,OAAO,IAAI,OAAO,CAAC,wBAAwB,CAAC,CAAC;CAC9C"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DirectoryReceipt } from "./directory-durability.js";
|
|
2
|
+
import type { StagedFile } from "./staged-file-types.js";
|
|
3
|
+
export type { PublishedFileReceipt, StagedFile, StagedFileCleanupReceipt, StagedFileFailureDetails, StagedFilePublication, StagedFileReceipt, } from "./staged-file-types.js";
|
|
4
|
+
export declare function stageFileInDirectory(options: {
|
|
5
|
+
directory: string | DirectoryReceipt;
|
|
6
|
+
content: string | Uint8Array;
|
|
7
|
+
/** Published mode; the unpublished stage stays at 0600. Defaults to 0600. */
|
|
8
|
+
mode?: number;
|
|
9
|
+
}): Promise<StagedFile>;
|
|
10
|
+
//# sourceMappingURL=staged-file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"staged-file.d.ts","sourceRoot":"","sources":["../src/staged-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAKlE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAEzD,YAAY,EACV,oBAAoB,EAAE,UAAU,EAAE,wBAAwB,EAAE,wBAAwB,EACpF,qBAAqB,EAAE,iBAAiB,GACzC,MAAM,wBAAwB,CAAC;AAEhC,wBAAsB,oBAAoB,CAAC,OAAO,EAAE;IAClD,SAAS,EAAE,MAAM,GAAG,gBAAgB,CAAC;IACrC,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,UAAU,CAAC,CAUtB"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FsSafeError } from "./errors.js";
|
|
2
|
+
import { assertNativeStaging, createNativeStage } from "./native-staged-file.js";
|
|
3
|
+
import { requireNativeBinding } from "./native.js";
|
|
4
|
+
import { openStagedDirectory } from "./staged-directory.js";
|
|
5
|
+
export async function stageFileInDirectory(options) {
|
|
6
|
+
if (process.platform !== "linux" && process.platform !== "darwin") {
|
|
7
|
+
throw new FsSafeError("unsupported-platform", "retained-directory staging requires Linux or macOS");
|
|
8
|
+
}
|
|
9
|
+
const binding = requireNativeBinding();
|
|
10
|
+
assertNativeStaging(binding);
|
|
11
|
+
const input = { kind: "buffer", data: Buffer.from(options.content) };
|
|
12
|
+
const mode = options.mode ?? 0o600;
|
|
13
|
+
const parent = openStagedDirectory(options.directory);
|
|
14
|
+
return await createNativeStage(binding, parent.fd, parent.receipt, input, mode);
|
|
15
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BigIntStats } from "node:fs";
|
|
2
|
+
type ExactFileIdentity = Pick<BigIntStats, "dev" | "ino">;
|
|
3
|
+
export declare function inspectFileIdentity<T extends ExactFileIdentity>(inspect: () => Promise<T>, expected?: ExactFileIdentity, platform?: NodeJS.Platform): Promise<T>;
|
|
4
|
+
export declare function inspectFileIdentitySync<T extends ExactFileIdentity>(inspect: () => T, expected?: ExactFileIdentity, platform?: NodeJS.Platform): T;
|
|
5
|
+
export {};
|
|
6
|
+
//# sourceMappingURL=strict-file-identity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"strict-file-identity.d.ts","sourceRoot":"","sources":["../src/strict-file-identity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAG3C,KAAK,iBAAiB,GAAG,IAAI,CAAC,WAAW,EAAE,KAAK,GAAG,KAAK,CAAC,CAAC;AA6B1D,wBAAsB,mBAAmB,CAAC,CAAC,SAAS,iBAAiB,EACnE,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACzB,QAAQ,CAAC,EAAE,iBAAiB,EAC5B,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED,wBAAgB,uBAAuB,CAAC,CAAC,SAAS,iBAAiB,EACjE,OAAO,EAAE,MAAM,CAAC,EAChB,QAAQ,CAAC,EAAE,iBAAiB,EAC5B,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,CAAC,CAOH"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { FsSafeError } from "./errors.js";
|
|
2
|
+
function identityMismatch() {
|
|
3
|
+
return new FsSafeError("path-mismatch", "file identity changed or could not be verified");
|
|
4
|
+
}
|
|
5
|
+
function identityCheck(expected, platform) {
|
|
6
|
+
const known = {};
|
|
7
|
+
const check = (stat) => {
|
|
8
|
+
let complete = true;
|
|
9
|
+
for (const field of ["dev", "ino"]) {
|
|
10
|
+
const value = stat[field];
|
|
11
|
+
// Numeric receipts cannot recover identity bits already lost to rounding.
|
|
12
|
+
if (typeof value !== "bigint")
|
|
13
|
+
throw identityMismatch();
|
|
14
|
+
if (platform === "win32" && value === 0n) {
|
|
15
|
+
complete = false;
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
if (known[field] !== undefined && known[field] !== value)
|
|
19
|
+
throw identityMismatch();
|
|
20
|
+
known[field] = value;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return complete;
|
|
24
|
+
};
|
|
25
|
+
if (expected && !check(expected))
|
|
26
|
+
throw identityMismatch();
|
|
27
|
+
return check;
|
|
28
|
+
}
|
|
29
|
+
// Retry only unknown Windows identities, retaining every known component so a
|
|
30
|
+
// later observation cannot erase a definite mismatch. Never reopen the file.
|
|
31
|
+
export async function inspectFileIdentity(inspect, expected, platform = process.platform) {
|
|
32
|
+
const check = identityCheck(expected, platform);
|
|
33
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
34
|
+
const stat = await inspect();
|
|
35
|
+
if (check(stat))
|
|
36
|
+
return stat;
|
|
37
|
+
}
|
|
38
|
+
throw identityMismatch();
|
|
39
|
+
}
|
|
40
|
+
export function inspectFileIdentitySync(inspect, expected, platform = process.platform) {
|
|
41
|
+
const check = identityCheck(expected, platform);
|
|
42
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
43
|
+
const stat = inspect();
|
|
44
|
+
if (check(stat))
|
|
45
|
+
return stat;
|
|
46
|
+
}
|
|
47
|
+
throw identityMismatch();
|
|
48
|
+
}
|
package/dist/windows-owner.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PermissionCommandFailure } from "./permission-exec.js";
|
|
1
2
|
export type WindowsOwnerExec = (command: string, args: string[]) => Promise<{
|
|
2
3
|
stdout: string;
|
|
3
4
|
stderr: string;
|
|
@@ -10,6 +11,8 @@ export type WindowsOwnerSummary = {
|
|
|
10
11
|
remote?: boolean;
|
|
11
12
|
trusted?: boolean;
|
|
12
13
|
error?: string;
|
|
14
|
+
errorDetail?: PermissionCommandFailure;
|
|
15
|
+
errorCause?: unknown;
|
|
13
16
|
};
|
|
14
17
|
export declare function resolveWindowsPrincipalSids(params: {
|
|
15
18
|
principals: string[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"windows-owner.d.ts","sourceRoot":"","sources":["../src/windows-owner.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"windows-owner.d.ts","sourceRoot":"","sources":["../src/windows-owner.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,sBAAsB,CAAC;AAG9B,MAAM,MAAM,gBAAgB,GAAG,CAC7B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,KACX,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEjD,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,wBAAwB,CAAC;IACvC,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AA4DF,wBAAsB,2BAA2B,CAAC,MAAM,EAAE;IACxD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAyBlC;AAED,wBAAsB,4BAA4B,CAAC,MAAM,EAAE;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAWzB;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAkD/B"}
|
package/dist/windows-owner.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatPermissionErrorDetail, getPermissionCommandFailure, } from "./permission-exec.js";
|
|
1
2
|
import { resolveWindowsSystemCommand } from "./windows-command.js";
|
|
2
3
|
const SID_RE = /^\*?s-\d+-\d+(-\d+)+$/i;
|
|
3
4
|
const TRUSTED_OWNER_SIDS = new Set(["s-1-5-18", "s-1-5-32-544"]);
|
|
@@ -83,8 +84,11 @@ export async function resolveWindowsCurrentUserSid(params) {
|
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
export async function inspectWindowsOwner(params) {
|
|
87
|
+
let command = "";
|
|
88
|
+
let startedAt = performance.now();
|
|
86
89
|
try {
|
|
87
|
-
|
|
90
|
+
command = resolveWindowsSystemCommand(String.raw `WindowsPowerShell\v1.0\powershell.exe`, params.env);
|
|
91
|
+
startedAt = performance.now();
|
|
88
92
|
const { stdout } = await params.exec(command, [
|
|
89
93
|
"-NoLogo",
|
|
90
94
|
"-NoProfile",
|
|
@@ -113,6 +117,10 @@ export async function inspectWindowsOwner(params) {
|
|
|
113
117
|
};
|
|
114
118
|
}
|
|
115
119
|
catch (err) {
|
|
116
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
error: formatPermissionErrorDetail(String(err)),
|
|
122
|
+
errorDetail: getPermissionCommandFailure(err, command, performance.now() - startedAt),
|
|
123
|
+
errorCause: err,
|
|
124
|
+
};
|
|
117
125
|
}
|
|
118
126
|
}
|
package/docs/advanced.md
CHANGED
|
@@ -26,13 +26,22 @@ The exports group into a handful of themes. Each documented helper has its own p
|
|
|
26
26
|
| Export | Page | Notes |
|
|
27
27
|
|---|---|---|
|
|
28
28
|
| `pathScope`, `PathScope`, `PathScopeOptions`, `PathScopeResolveOptions` | [path-scope.md](path-scope.md) | Absolute-path boundary helper with `Result`-shaped returns. |
|
|
29
|
-
| `ensureDirectoryWithinRoot` |
|
|
29
|
+
| `ensureDirectoryWithinRoot` | [path-scope.md](path-scope.md#ensuredir-rel-options) | Create a directory while enforcing the root boundary; same result contract as `pathScope().ensureDir()`. |
|
|
30
30
|
| `resolvePathWithinRoot`, `resolvePathsWithinRoot` | – | Resolve one or many relative paths against a trusted root. |
|
|
31
31
|
| `resolveExistingPathsWithinRoot`, `resolveStrictExistingPathsWithinRoot` | – | Same, but require the targets to exist. |
|
|
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
35
|
|
|
36
|
+
`ensureDirectoryWithinRoot({ rootDir, requestedPath, scopeLabel, defaultDirName?, mode? })`
|
|
37
|
+
returns `{ ok: true, path }` or `{ ok: false, error: string, diagnostic?: FsSafeError }`.
|
|
38
|
+
It does not throw filesystem failures: operational failures carry a
|
|
39
|
+
`helper-failed` diagnostic with the original `cause` and a bounded, escaped
|
|
40
|
+
message naming the native code/syscall when available. Policy failures omit
|
|
41
|
+
`diagnostic`. Missing parents already created before a failure remain in place.
|
|
42
|
+
The diagnostic contract is specific to directory preparation, not the other
|
|
43
|
+
root-path result helpers.
|
|
44
|
+
|
|
36
45
|
### Absolute path helpers
|
|
37
46
|
|
|
38
47
|
| Export | Page | Notes |
|
|
@@ -107,6 +116,7 @@ component is followed by another segment, both helpers throw
|
|
|
107
116
|
|
|
108
117
|
| Export | Page | Notes |
|
|
109
118
|
|---|---|---|
|
|
119
|
+
| `stageFileInDirectory`, `StagedFile`, `StagedFileReceipt`, `PublishedFileReceipt`, `StagedFilePublication`, `StagedFileCleanupReceipt`, `StagedFileFailureDetails` | [staged-file.md](staged-file.md) | Native-required Linux/macOS lifecycle retaining the original directory for abort cleanup. |
|
|
110
120
|
| `tempFile`, `withTempFile`, `TempFile`, `buildRandomTempFilePath`, `sanitizeTempFileName` | [temp.md](temp.md) | One-file temp primitive; prefer `tempWorkspace` from `@openclaw/fs-safe/temp` for the stable surface. |
|
|
111
121
|
| `writeSiblingTempFile`, `writeViaSiblingTempPath`, `WriteSiblingTempFileOptions`, `WriteSiblingTempFileResult` | – | Sibling-temp write building block used by `replaceFileAtomic`. |
|
|
112
122
|
|
package/docs/archive.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Archive extraction
|
|
2
2
|
|
|
3
|
-
`@openclaw/fs-safe/archive` extracts ZIP and TAR archives behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets. When the
|
|
3
|
+
`@openclaw/fs-safe/archive` extracts ZIP and TAR archives behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets. When the native binding is available for the current platform, Rust streams ZIP, TAR, gzip, zstd, and bzip2 while TypeScript remains the sole policy owner; every accepted output is created fd-relative in a private staging root. Extraction then merges through the same safe-open boundary used by direct writes — a symlinked entry can't trick the merge into following an out-of-tree path.
|
|
4
4
|
|
|
5
5
|
The guarded JavaScript fallback uses optional runtime dependencies: `jszip` for
|
|
6
6
|
ZIP and `tar` for TAR/gzip. The native path does not use those packages. Installs
|
|
@@ -72,8 +72,19 @@ accepted-entry plan back to Rust. Rust only performs decompression and the
|
|
|
72
72
|
fd-relative `mkdirBeneath`/exclusive-open writes. This keeps policy identical
|
|
73
73
|
between native and JavaScript paths rather than reimplementing it in Rust.
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
`stripComponents` removes leading nonempty, non-`.` path components after
|
|
78
|
+
normalizing separators. For example, `./pkg/hello.txt` with
|
|
79
|
+
`stripComponents: 1` extracts to `hello.txt` on both backends. Entries with no
|
|
80
|
+
remaining components are skipped before the filter callback, but still count
|
|
81
|
+
toward `maxEntries` and undergo traversal validation. JavaScript TAR extraction
|
|
82
|
+
passes node-tar this accepted output path with its own stripping disabled, so
|
|
83
|
+
depth checks, collision checks, writes, and mode application agree.
|
|
84
|
+
|
|
85
|
+
An `entryFilter` sees the validated effective archive path **before stripping**
|
|
86
|
+
(including a local PAX `path` override), entry kind, and declared size.
|
|
87
|
+
Returning `"skip"` rejects the whole archive unless `onFiltered` is
|
|
77
88
|
explicitly `"skip-entry"`. Path traversal and archive-wide entry-count checks
|
|
78
89
|
still apply to skipped entries.
|
|
79
90
|
|
|
@@ -149,22 +160,58 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and
|
|
|
149
160
|
## What it defends against
|
|
150
161
|
|
|
151
162
|
- **Path traversal:** entries with `..`, absolute paths, NUL bytes, or Windows drive-relative segments such as `C:secret` and `nested/C:secret` are rejected (`ArchiveSecurityError`). On Windows, path segments containing `:` are also rejected as alternate data stream names before either backend writes to the filesystem.
|
|
152
|
-
- **Symlink/hardlink entries:** rejected by default. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
|
|
163
|
+
- **Symlink/hardlink entries:** rejected by default, including ZIP entries whose Unix mode says symlink while their name ends in a slash or their DOS directory bit is set. An explicit `entryFilter` with `onFiltered: "skip-entry"` can omit these entries. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
|
|
153
164
|
- **Ambiguous output names:** duplicate names and distinct names that collide after `stripComponents`, case normalization, or Unicode normalization are rejected instead of relying on backend- or volume-specific overwrite order.
|
|
154
165
|
- **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. Destination symlink swaps are checked with the selected platform mechanism; non-Linux routes retain the best-effort race window documented in the [security model](security-model.md#containment-guarantees-by-platform).
|
|
155
166
|
- **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk.
|
|
156
167
|
- **Corrupt ZIP payloads:** streamed output must match both the central-directory CRC and declared uncompressed size before it can leave private staging.
|
|
157
168
|
- **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget. Extraction is aborted on overrun.
|
|
158
|
-
- **Metadata bombs:** a
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
- **Metadata bombs:** a streaming pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before either TAR implementation buffers them. It understands octal and base-256 fixed sizes and validates bounded local PAX bodies before using their size overrides for member framing. Original archive bytes remain unchanged.
|
|
170
|
+
|
|
171
|
+
### Bounded local PAX support
|
|
172
|
+
|
|
173
|
+
Extraction and single-entry reads accept one nonempty local POSIX `x` header
|
|
174
|
+
(USTAR or GNU header format) immediately before one regular/contiguous file,
|
|
175
|
+
directory, symlink, or hardlink. `path`, `linkpath`, and `size` override that
|
|
176
|
+
member only. Effective paths still pass traversal validation before stripping,
|
|
177
|
+
then the output paths pass depth and collision checks. The filter receives the
|
|
178
|
+
effective pre-strip path, followed by link policy checks. PAX never permits link
|
|
179
|
+
creation. Effective sizes drive framing, filters, and the existing output-byte
|
|
180
|
+
budgets; `maxEntries` still counts members, not their metadata headers.
|
|
181
|
+
|
|
182
|
+
Records must have exact byte lengths, ASCII keys, a final newline, and no
|
|
183
|
+
duplicate keys, embedded newlines, or unconsumed bytes. Structural `path` and
|
|
184
|
+
`linkpath` values and ownership names must be nonempty printable ASCII. A PAX
|
|
185
|
+
member's raw name, USTAR prefix, and raw link target must also be printable
|
|
186
|
+
ASCII; raw link targets must be present only on links, even when overridden.
|
|
187
|
+
Unicode
|
|
188
|
+
PAX structural text is deliberately unsupported because the underlying parsers
|
|
189
|
+
do not agree when UTF-8 is split across input chunks. `size`, `uid`, and `gid`
|
|
190
|
+
must be canonical unsigned decimal safe integers (zero is valid; signs, leading
|
|
191
|
+
zeros, fractions, and exponents are not). Padded member sizes must also fit the
|
|
192
|
+
safe integer range. Raw and effective directory/link sizes must both be zero;
|
|
193
|
+
non-directory paths ending with a separator and `linkpath` on non-links are
|
|
194
|
+
rejected rather than allowing parser-specific type or framing changes.
|
|
195
|
+
|
|
196
|
+
The descriptive allowlist is `mtime`, `atime`, `ctime` (signed decimal seconds
|
|
197
|
+
with optional fractional digits, within JavaScript's Date range), `uid`, `gid`,
|
|
198
|
+
`uname`, and `gname`. These attributes are accepted but not restored to the
|
|
199
|
+
destination. `LIBARCHIVE.xattr.*` and `SCHILY.xattr.*` with nonempty ASCII
|
|
200
|
+
alphanumeric/dot/underscore/hyphen suffixes are also accepted as inert metadata,
|
|
201
|
+
never restored as extended attributes. Their values are byte-counted and may
|
|
202
|
+
contain NUL or non-UTF8 bytes, including macOS provenance metadata; embedded
|
|
203
|
+
newlines are rejected because they can disrupt downstream record parsing.
|
|
204
|
+
|
|
205
|
+
Global `g`, old `X`, old GNU `N`, empty/dangling/repeated local headers, mixed
|
|
206
|
+
PAX/GNU extension chains, unknown keys, charset declarations, ACL extensions,
|
|
207
|
+
and all sparse extensions (including `GNU.sparse.*`, `SCHILY.filetype`,
|
|
208
|
+
`SCHILY.realsize`, and `SCHILY.size`) fail closed with
|
|
209
|
+
`ArchiveFormatError("archive-header-invalid")`. Standalone GNU long-name `L`
|
|
210
|
+
and long-link `K` support is unchanged. GNU sparse extension blocks are still
|
|
211
|
+
metered in 512-byte units before rejection, preserving metadata-limit errors
|
|
212
|
+
for excessive chains. The per-body `maxMetaEntryBytes` limit bounds PAX storage
|
|
213
|
+
and duplicate-key state; one local header per member prevents local metadata
|
|
214
|
+
chains without introducing a new limit or changing defaults.
|
|
168
215
|
|
|
169
216
|
## `resolveArchiveKind`
|
|
170
217
|
|
|
@@ -257,7 +304,7 @@ import {
|
|
|
257
304
|
|
|
258
305
|
- `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths, including alternate data stream names on Windows.
|
|
259
306
|
- `normalizeArchiveEntryPath(raw)` — converts backslashes in the entry path to forward slashes.
|
|
260
|
-
- `stripArchivePath(entryPath, n)` — strip the leading N
|
|
307
|
+
- `stripArchivePath(entryPath, n)` — normalize separators, drop empty and `.` components, then strip the leading N components, returning `null` if none remain.
|
|
261
308
|
- `resolveArchiveOutputPath({ destDir, entryPath })` — combines the entry path with the destination, after validation.
|
|
262
309
|
- `isWindowsDrivePath(value)` — detects drive-relative segments such as `C:secret` or `nested/C:secret` that should be rejected.
|
|
263
310
|
|
package/docs/config.md
CHANGED
|
@@ -78,6 +78,13 @@ configureFsSafeLocks({
|
|
|
78
78
|
});
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
These defaults apply to both `acquireFileLock()` / `withFileLock()` and
|
|
82
|
+
`acquireFileLockSync()` / `withFileLockSync()`. Each acquisition resolves
|
|
83
|
+
`retry`, `staleMs`, `staleRecovery`, and `timeoutMs` from the per-call option
|
|
84
|
+
first, then the process configuration, then the package default. Explicit zero
|
|
85
|
+
values are preserved. A per-call `retry` object replaces the configured object
|
|
86
|
+
as a whole; omitted retry fields use package defaults, not configured fields.
|
|
87
|
+
|
|
81
88
|
Individual lock calls can override any default. Switching the global stale
|
|
82
89
|
recovery mode does not provide the application-owned liveness proof required
|
|
83
90
|
by `shouldRemoveStaleLock`.
|