@openclaw/fs-safe 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +6 -4
- package/dist/advanced.d.ts +1 -0
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +1 -0
- package/dist/device-path.d.ts +13 -0
- package/dist/device-path.d.ts.map +1 -0
- package/dist/device-path.js +112 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/file-identity.d.ts +1 -0
- package/dist/file-identity.d.ts.map +1 -1
- package/dist/file-identity.js +10 -2
- package/dist/fsync.d.ts +2 -0
- package/dist/fsync.d.ts.map +1 -0
- package/dist/fsync.js +21 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/move-path.d.ts +3 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +13 -1
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +1 -0
- package/dist/pinned-open.d.ts.map +1 -1
- package/dist/pinned-open.js +7 -0
- package/dist/pinned-write.d.ts +9 -1
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +80 -4
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +3 -0
- package/dist/root-impl.d.ts +4 -1
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +53 -47
- package/dist/root.d.ts +1 -1
- package/dist/root.d.ts.map +1 -1
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +2 -0
- package/dist/test-hooks.d.ts +1 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/walk.d.ts +13 -2
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +29 -6
- package/docs/contributing.md +1 -1
- package/docs/errors.md +3 -0
- package/docs/install.md +3 -3
- package/docs/path.md +13 -0
- package/docs/reading.md +6 -4
- package/docs/root.md +2 -1
- package/docs/security-model.md +4 -2
- package/docs/types.md +2 -1
- package/docs/walk.md +11 -1
- package/docs/writing.md +23 -3
- package/package.json +23 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1 - 2026-07-01
|
|
4
|
+
|
|
5
|
+
### Security and Correctness
|
|
6
|
+
|
|
7
|
+
- Update the optional TAR extractor to reject NUL-terminated PAX values, invalid negative entry sizes, and explosive decompression payloads.
|
|
8
|
+
|
|
9
|
+
### Compatibility
|
|
10
|
+
|
|
11
|
+
- Add explicit `renameIdentity: "verify-content-with-lock"` writes for rclone-style FUSE mounts whose inode identity changes across rename, while keeping strict identity verification as the default and failing closed on stale cooperative locks. (#32, #33; thanks @jlautman)
|
|
12
|
+
|
|
13
|
+
## 0.4.0 - 2026-06-17
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
- Report unreadable walk roots and subdirectories through `failedDirs` from `walkDirectory()` and `walkDirectorySync()`, preserving readable results while letting destructive reconciliation distinguish incomplete scans from empty directories. (#29; thanks @amknight)
|
|
18
|
+
|
|
19
|
+
### Compatibility
|
|
20
|
+
|
|
21
|
+
- Require Node.js 22 or newer for the npm package and docs, matching the maintained CI matrix.
|
|
22
|
+
|
|
23
|
+
### Security and Correctness
|
|
24
|
+
|
|
25
|
+
- Fall back to the existing copy/remove move path when Windows denies directory renames with `EPERM`, preserving skill updates while watched files are locked. (#27; thanks @liuxingwei0601)
|
|
26
|
+
- Sync `root.append` file handles before close and sync the parent directory when append creates a file, preserving append-mode concurrency while improving durability. (#21; thanks @KumarAnandSingh)
|
|
27
|
+
- Reject known unsafe device and process-fd read paths before opening files, including `/dev/zero`, `/dev/random`, `/dev/fd/*`, `/proc/*/fd/*`, and Windows reserved device names.
|
|
28
|
+
|
|
3
29
|
## 0.3.0 - 2026-05-21
|
|
4
30
|
|
|
5
31
|
### Features
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# 🛡️ @openclaw/fs-safe
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+
|
|
3
5
|
[](https://www.npmjs.com/package/@openclaw/fs-safe)
|
|
4
6
|
[](https://github.com/openclaw/fs-safe/actions/workflows/ci.yml)
|
|
5
7
|
[](https://nodejs.org)
|
|
@@ -55,7 +57,7 @@ This is a **library-level guardrail**, not OS-level isolation. It does not repla
|
|
|
55
57
|
pnpm add @openclaw/fs-safe
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
Node
|
|
60
|
+
Node 22 or newer. Core root/path/json/temp helpers avoid framework dependencies. Archive helpers use optional `jszip` and `tar` dependencies for ZIP/TAR support; installs that omit optional dependencies can still use every non-archive subpath.
|
|
59
61
|
|
|
60
62
|
On POSIX, `root()` uses one process-global persistent Python helper for the
|
|
61
63
|
fd-relative operations Node does not expose ergonomically (`renameat`,
|
|
@@ -369,7 +371,7 @@ for (const file of scan.entries) {
|
|
|
369
371
|
}
|
|
370
372
|
```
|
|
371
373
|
|
|
372
|
-
Check `scan.truncated` before treating the result as complete.
|
|
374
|
+
Check `scan.truncated` before treating the result as complete, and `scan.failedDirs` to tell an incomplete scan (a directory that could not be read) from an empty one before pruning state from the listing.
|
|
373
375
|
|
|
374
376
|
## Archive extraction
|
|
375
377
|
|
|
@@ -439,12 +441,12 @@ if (err instanceof FsSafeError) {
|
|
|
439
441
|
}
|
|
440
442
|
```
|
|
441
443
|
|
|
442
|
-
Current `FsSafeErrorCode` values are `already-exists`, `hardlink`, `helper-failed`, `helper-unavailable`, `invalid-path`, `insecure-permissions`, `not-empty`, `not-file`, `not-found`, `not-owned`, `not-removable`, `outside-workspace`, `path-alias`, `path-mismatch`, `permission-unverified`, `symlink`, `timeout`, `too-large`, and `unsupported-platform`.
|
|
444
|
+
Current `FsSafeErrorCode` values are `already-exists`, `denied-path`, `device-path`, `hardlink`, `helper-failed`, `helper-unavailable`, `invalid-path`, `insecure-permissions`, `not-empty`, `not-file`, `not-found`, `not-owned`, `not-removable`, `outside-workspace`, `path-alias`, `path-mismatch`, `permission-unverified`, `symlink`, `timeout`, `too-large`, and `unsupported-platform`.
|
|
443
445
|
|
|
444
446
|
## Safety model
|
|
445
447
|
|
|
446
448
|
- root-bounded APIs resolve paths against a configured root and reject canonical escapes
|
|
447
|
-
- reads open with `O_NOFOLLOW` where available, then verify fd identity matches the path identity before returning the buffer or handle
|
|
449
|
+
- reads reject known unsafe device paths, open with `O_NOFOLLOW` where available, then verify fd identity matches the path identity before returning the buffer or handle
|
|
448
450
|
- writes use pinned parent-directory helpers and atomic replacement on POSIX, with verified post-write identity
|
|
449
451
|
- `remove`, `mkdir`, `move`, `stat`, `list`, and parent-fd writes use one persistent fd-relative Python helper on POSIX, with Node fallbacks when the helper is disabled or unavailable
|
|
450
452
|
- archive extraction stages into a private directory and merges through the same boundary checks used by direct writes
|
package/dist/advanced.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { createAsyncLock } from "./async-lock.js";
|
|
2
|
+
export { assertNoUnsafeDeviceReadPath, isUnsafeDeviceReadPath, matchUnsafeDeviceReadPath, type UnsafeDeviceReadPathMatch, type UnsafeDeviceReadPathOptions, type UnsafeDeviceReadPathReason, } from "./device-path.js";
|
|
2
3
|
export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, ensureAbsoluteDirectory, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, type AbsolutePathSymlinkPolicy, type EnsureAbsoluteDirectoryOptions, type EnsureAbsoluteDirectoryResult, type ResolvedAbsolutePath, type ResolvedWritableAbsolutePath, } from "./absolute-path.js";
|
|
3
4
|
export { sameFileIdentity, type FileIdentityStat } from "./file-identity.js";
|
|
4
5
|
export { sanitizeUntrustedFileName } from "./filename.js";
|
package/dist/advanced.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"advanced.d.ts","sourceRoot":"","sources":["../src/advanced.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"advanced.d.ts","sourceRoot":"","sources":["../src/advanced.ts"],"names":[],"mappings":"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"}
|
package/dist/advanced.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// public subpaths; prefer root/json/store/temp/archive unless you are building a
|
|
3
3
|
// higher-level primitive.
|
|
4
4
|
export { createAsyncLock } from "./async-lock.js";
|
|
5
|
+
export { assertNoUnsafeDeviceReadPath, isUnsafeDeviceReadPath, matchUnsafeDeviceReadPath, } from "./device-path.js";
|
|
5
6
|
export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, ensureAbsoluteDirectory, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, } from "./absolute-path.js";
|
|
6
7
|
export { sameFileIdentity } from "./file-identity.js";
|
|
7
8
|
export { sanitizeUntrustedFileName } from "./filename.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type UnsafeDeviceReadPathReason = "posix-device" | "posix-fd" | "windows-device";
|
|
2
|
+
export type UnsafeDeviceReadPathMatch = {
|
|
3
|
+
path: string;
|
|
4
|
+
reason: UnsafeDeviceReadPathReason;
|
|
5
|
+
};
|
|
6
|
+
export type UnsafeDeviceReadPathOptions = {
|
|
7
|
+
cwd?: string;
|
|
8
|
+
platform?: NodeJS.Platform;
|
|
9
|
+
};
|
|
10
|
+
export declare function matchUnsafeDeviceReadPath(filePath: string, options?: UnsafeDeviceReadPathOptions): UnsafeDeviceReadPathMatch | undefined;
|
|
11
|
+
export declare function isUnsafeDeviceReadPath(filePath: string, options?: UnsafeDeviceReadPathOptions): boolean;
|
|
12
|
+
export declare function assertNoUnsafeDeviceReadPath(filePath: string, options?: UnsafeDeviceReadPathOptions): void;
|
|
13
|
+
//# sourceMappingURL=device-path.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-path.d.ts","sourceRoot":"","sources":["../src/device-path.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,0BAA0B,GAClC,cAAc,GACd,UAAU,GACV,gBAAgB,CAAC;AAErB,MAAM,MAAM,yBAAyB,GAAG;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,0BAA0B,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;CAC5B,CAAC;AAqGF,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,2BAAgC,GACxC,yBAAyB,GAAG,SAAS,CAWvC;AAED,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,2BAA2B,GACpC,OAAO,CAET;AAED,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,2BAA2B,GACpC,IAAI,CAON"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { FsSafeError } from "./errors.js";
|
|
3
|
+
import { trySafeFileURLToPath } from "./local-file-access.js";
|
|
4
|
+
const POSIX_BLOCKED_DEVICE_PATHS = new Set([
|
|
5
|
+
"/dev/zero",
|
|
6
|
+
"/dev/random",
|
|
7
|
+
"/dev/urandom",
|
|
8
|
+
"/dev/full",
|
|
9
|
+
"/dev/stdin",
|
|
10
|
+
"/dev/stdout",
|
|
11
|
+
"/dev/stderr",
|
|
12
|
+
"/dev/tty",
|
|
13
|
+
"/dev/console",
|
|
14
|
+
]);
|
|
15
|
+
const WINDOWS_RESERVED_DEVICE_NAMES = new Set([
|
|
16
|
+
"CON",
|
|
17
|
+
"PRN",
|
|
18
|
+
"AUX",
|
|
19
|
+
"NUL",
|
|
20
|
+
"CLOCK$",
|
|
21
|
+
"CONIN$",
|
|
22
|
+
"CONOUT$",
|
|
23
|
+
"COM1",
|
|
24
|
+
"COM2",
|
|
25
|
+
"COM3",
|
|
26
|
+
"COM4",
|
|
27
|
+
"COM5",
|
|
28
|
+
"COM6",
|
|
29
|
+
"COM7",
|
|
30
|
+
"COM8",
|
|
31
|
+
"COM9",
|
|
32
|
+
"COM¹",
|
|
33
|
+
"COM²",
|
|
34
|
+
"COM³",
|
|
35
|
+
"LPT1",
|
|
36
|
+
"LPT2",
|
|
37
|
+
"LPT3",
|
|
38
|
+
"LPT4",
|
|
39
|
+
"LPT5",
|
|
40
|
+
"LPT6",
|
|
41
|
+
"LPT7",
|
|
42
|
+
"LPT8",
|
|
43
|
+
"LPT9",
|
|
44
|
+
"LPT¹",
|
|
45
|
+
"LPT²",
|
|
46
|
+
"LPT³",
|
|
47
|
+
]);
|
|
48
|
+
function candidateReadPaths(filePath) {
|
|
49
|
+
if (!filePath.startsWith("file://")) {
|
|
50
|
+
return [filePath];
|
|
51
|
+
}
|
|
52
|
+
const parsed = trySafeFileURLToPath(filePath);
|
|
53
|
+
return parsed === undefined ? [filePath] : [filePath, parsed];
|
|
54
|
+
}
|
|
55
|
+
function normalizePosixPath(filePath, cwd) {
|
|
56
|
+
if (path.posix.isAbsolute(filePath)) {
|
|
57
|
+
return path.posix.normalize(filePath);
|
|
58
|
+
}
|
|
59
|
+
const base = cwd && path.posix.isAbsolute(cwd) ? cwd : process.cwd();
|
|
60
|
+
return path.posix.resolve(base, filePath);
|
|
61
|
+
}
|
|
62
|
+
function matchPosixDeviceReadPath(filePath, cwd) {
|
|
63
|
+
const normalized = normalizePosixPath(filePath, cwd);
|
|
64
|
+
if (POSIX_BLOCKED_DEVICE_PATHS.has(normalized)) {
|
|
65
|
+
return { path: normalized, reason: "posix-device" };
|
|
66
|
+
}
|
|
67
|
+
if (normalized === "/dev/fd" || normalized.startsWith("/dev/fd/")) {
|
|
68
|
+
return { path: normalized, reason: "posix-fd" };
|
|
69
|
+
}
|
|
70
|
+
if (/^\/proc\/(?:self|thread-self|\d+)\/fd(?:\/|$)/.test(normalized)) {
|
|
71
|
+
return { path: normalized, reason: "posix-fd" };
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
function normalizeWindowsDeviceBaseName(filePath) {
|
|
76
|
+
const normalized = filePath.replace(/\//g, "\\").replace(/[\\]+$/g, "");
|
|
77
|
+
const lastSegment = normalized.split("\\").filter(Boolean).at(-1) ?? normalized;
|
|
78
|
+
const withoutStream = lastSegment.split(":")[0] ?? lastSegment;
|
|
79
|
+
const withoutTrailingIgnoredChars = withoutStream.replace(/[ .]+$/g, "");
|
|
80
|
+
return (withoutTrailingIgnoredChars.split(".")[0] ?? withoutTrailingIgnoredChars).toUpperCase();
|
|
81
|
+
}
|
|
82
|
+
function matchWindowsDeviceReadPath(filePath) {
|
|
83
|
+
const normalized = filePath.replace(/\//g, "\\");
|
|
84
|
+
if (/^\\\\\.\\/.test(normalized) || /^\\\\\?\\GLOBALROOT\\Device\\/i.test(normalized)) {
|
|
85
|
+
return { path: normalized, reason: "windows-device" };
|
|
86
|
+
}
|
|
87
|
+
const baseName = normalizeWindowsDeviceBaseName(filePath);
|
|
88
|
+
if (WINDOWS_RESERVED_DEVICE_NAMES.has(baseName)) {
|
|
89
|
+
return { path: normalized, reason: "windows-device" };
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
export function matchUnsafeDeviceReadPath(filePath, options = {}) {
|
|
94
|
+
const platform = options.platform ?? process.platform;
|
|
95
|
+
for (const candidate of candidateReadPaths(filePath)) {
|
|
96
|
+
const match = platform === "win32"
|
|
97
|
+
? matchWindowsDeviceReadPath(candidate)
|
|
98
|
+
: matchPosixDeviceReadPath(candidate, options.cwd);
|
|
99
|
+
if (match) {
|
|
100
|
+
return match;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
export function isUnsafeDeviceReadPath(filePath, options) {
|
|
106
|
+
return matchUnsafeDeviceReadPath(filePath, options) !== undefined;
|
|
107
|
+
}
|
|
108
|
+
export function assertNoUnsafeDeviceReadPath(filePath, options) {
|
|
109
|
+
if (matchUnsafeDeviceReadPath(filePath, options)) {
|
|
110
|
+
throw new FsSafeError("device-path", `file reads from unsafe device paths are not allowed: ${filePath}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FsSafeErrorCode = "already-exists" | "denied-path" | "hardlink" | "helper-failed" | "helper-unavailable" | "invalid-path" | "insecure-permissions" | "not-empty" | "not-file" | "not-found" | "not-owned" | "not-removable" | "outside-workspace" | "path-alias" | "path-mismatch" | "permission-unverified" | "symlink" | "timeout" | "too-large" | "unsupported-platform";
|
|
1
|
+
export type FsSafeErrorCode = "already-exists" | "denied-path" | "device-path" | "hardlink" | "helper-failed" | "helper-unavailable" | "invalid-path" | "insecure-permissions" | "not-empty" | "not-file" | "not-found" | "not-owned" | "not-removable" | "outside-workspace" | "path-alias" | "path-mismatch" | "permission-unverified" | "symlink" | "timeout" | "too-large" | "unsupported-platform";
|
|
2
2
|
export type FsSafeErrorCategory = "policy" | "operational";
|
|
3
3
|
export declare function categorizeFsSafeError(code: FsSafeErrorCode): FsSafeErrorCategory;
|
|
4
4
|
export declare class FsSafeError extends Error {
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GACvB,gBAAgB,GAChB,aAAa,GACb,UAAU,GACV,eAAe,GACf,oBAAoB,GACpB,cAAc,GACd,sBAAsB,GACtB,WAAW,GACX,UAAU,GACV,WAAW,GACX,WAAW,GACX,eAAe,GACf,mBAAmB,GACnB,YAAY,GACZ,eAAe,GACf,uBAAuB,GACvB,SAAS,GACT,SAAS,GACT,WAAW,GACX,sBAAsB,CAAC;AAE3B,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,aAAa,CAAC;AAU3D,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,eAAe,GAAG,mBAAmB,CAEhF;AAED,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAC;gBAE3B,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAMtF"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GACvB,gBAAgB,GAChB,aAAa,GACb,aAAa,GACb,UAAU,GACV,eAAe,GACf,oBAAoB,GACpB,cAAc,GACd,sBAAsB,GACtB,WAAW,GACX,UAAU,GACV,WAAW,GACX,WAAW,GACX,eAAe,GACf,mBAAmB,GACnB,YAAY,GACZ,eAAe,GACf,uBAAuB,GACvB,SAAS,GACT,SAAS,GACT,WAAW,GACX,sBAAsB,CAAC;AAE3B,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,aAAa,CAAC;AAU3D,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,eAAe,GAAG,mBAAmB,CAEhF;AAED,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAC;gBAE3B,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAMtF"}
|
package/dist/file-identity.d.ts
CHANGED
|
@@ -2,5 +2,6 @@ export type FileIdentityStat = {
|
|
|
2
2
|
dev: number | bigint;
|
|
3
3
|
ino: number | bigint;
|
|
4
4
|
};
|
|
5
|
+
export declare function sha256Hex(data: string | Buffer, encoding?: BufferEncoding): string;
|
|
5
6
|
export declare function sameFileIdentity(left: FileIdentityStat, right: FileIdentityStat, platform?: NodeJS.Platform): boolean;
|
|
6
7
|
//# sourceMappingURL=file-identity.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-identity.d.ts","sourceRoot":"","sources":["../src/file-identity.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"file-identity.d.ts","sourceRoot":"","sources":["../src/file-identity.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;CACtB,CAAC;AAUF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,QAAQ,CAAC,EAAE,cAAc,GAAG,MAAM,CAGlF;AAED,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,gBAAgB,EACtB,KAAK,EAAE,gBAAgB,EACvB,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAWT"}
|
package/dist/file-identity.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
function isZero(value) {
|
|
2
3
|
return value === 0 || value === 0n;
|
|
3
4
|
}
|
|
5
|
+
function sameStatValue(left, right) {
|
|
6
|
+
return typeof left === typeof right ? left === right : BigInt(left) === BigInt(right);
|
|
7
|
+
}
|
|
8
|
+
export function sha256Hex(data, encoding) {
|
|
9
|
+
const buffer = typeof data === "string" ? Buffer.from(data, encoding ?? "utf8") : data;
|
|
10
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
11
|
+
}
|
|
4
12
|
export function sameFileIdentity(left, right, platform = process.platform) {
|
|
5
|
-
if (left.ino
|
|
13
|
+
if (!sameStatValue(left.ino, right.ino)) {
|
|
6
14
|
return false;
|
|
7
15
|
}
|
|
8
16
|
// On Windows, path-based stat calls can report dev=0 while fd-based stat
|
|
9
17
|
// reports a real volume serial; treat either-side dev=0 as "unknown device".
|
|
10
|
-
if (left.dev
|
|
18
|
+
if (sameStatValue(left.dev, right.dev)) {
|
|
11
19
|
return true;
|
|
12
20
|
}
|
|
13
21
|
return platform === "win32" && (isZero(left.dev) || isZero(right.dev));
|
package/dist/fsync.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fsync.d.ts","sourceRoot":"","sources":["../src/fsync.ts"],"names":[],"mappings":"AAIA,wBAAsB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAiB5E"}
|
package/dist/fsync.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fsSync from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
export async function syncDirectoryBestEffort(dirPath) {
|
|
4
|
+
if (process.platform === "win32") {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
let handle;
|
|
8
|
+
try {
|
|
9
|
+
const flags = fsSync.constants.O_RDONLY |
|
|
10
|
+
("O_DIRECTORY" in fsSync.constants ? fsSync.constants.O_DIRECTORY : 0) |
|
|
11
|
+
("O_NOFOLLOW" in fsSync.constants ? fsSync.constants.O_NOFOLLOW : 0);
|
|
12
|
+
handle = await fs.open(dirPath, flags);
|
|
13
|
+
await handle.sync();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Some filesystems reject directory handles; keep the write usable there.
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
await handle?.close().catch(() => undefined);
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { FsSafeError, categorizeFsSafeError, type FsSafeErrorCategory, type FsSafeErrorCode, } from "./errors.js";
|
|
2
|
-
export { DEFAULT_ROOT_MAX_BYTES, root, type DenyMutationPolicy, type HardlinkPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root.js";
|
|
2
|
+
export { DEFAULT_ROOT_MAX_BYTES, root, type DenyMutationPolicy, type HardlinkPolicy, type RenameIdentityPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root.js";
|
|
3
3
|
export { configureFsSafePython, getFsSafePythonConfig, type FsSafePythonConfig, type FsSafePythonMode, } from "./pinned-python-config.js";
|
|
4
4
|
export { writeExternalFileWithinRoot, type ExternalFileWriteOptions, type ExternalFileWriteResult, } from "./output.js";
|
|
5
5
|
export { configureFsSafeLocks, getFsSafeLockConfig, type FsSafeLockConfig, } from "./lock-config.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,sBAAsB,EACtB,IAAI,EACJ,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,2BAA2B,EAC3B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,GAC7B,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,sBAAsB,EACtB,IAAI,EACJ,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,oBAAoB,EACzB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,2BAA2B,EAC3B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,GAC7B,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC"}
|
package/dist/move-path.d.ts
CHANGED
|
@@ -3,5 +3,8 @@ export type MovePathWithCopyFallbackOptions = {
|
|
|
3
3
|
sourceHardlinks?: "allow" | "reject";
|
|
4
4
|
to: string;
|
|
5
5
|
};
|
|
6
|
+
type MoveCopyFallbackReason = "cross-device" | "windows-rename-denied";
|
|
7
|
+
export declare function moveCopyFallbackReasonForRenameError(error: unknown, platform?: NodeJS.Platform): MoveCopyFallbackReason | undefined;
|
|
6
8
|
export declare function movePathWithCopyFallback(options: MovePathWithCopyFallbackOptions): Promise<void>;
|
|
9
|
+
export {};
|
|
7
10
|
//# sourceMappingURL=move-path.d.ts.map
|
package/dist/move-path.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"move-path.d.ts","sourceRoot":"","sources":["../src/move-path.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,+BAA+B,GAAG;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IACrC,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;
|
|
1
|
+
{"version":3,"file":"move-path.d.ts","sourceRoot":"","sources":["../src/move-path.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,+BAA+B,GAAG;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IACrC,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,KAAK,sBAAsB,GAAG,cAAc,GAAG,uBAAuB,CAAC;AAEvE,wBAAgB,oCAAoC,CAClD,KAAK,EAAE,OAAO,EACd,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,sBAAsB,GAAG,SAAS,CASpC;AAqPD,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,IAAI,CAAC,CAyBf"}
|
package/dist/move-path.js
CHANGED
|
@@ -3,6 +3,16 @@ import { constants as fsConstants } from "node:fs";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { guardedRename } from "./guarded-mutation.js";
|
|
6
|
+
export function moveCopyFallbackReasonForRenameError(error, platform = process.platform) {
|
|
7
|
+
const code = error?.code;
|
|
8
|
+
if (code === "EXDEV") {
|
|
9
|
+
return "cross-device";
|
|
10
|
+
}
|
|
11
|
+
if (code === "EPERM" && platform === "win32") {
|
|
12
|
+
return "windows-rename-denied";
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
6
16
|
function entryIdentity(stat) {
|
|
7
17
|
return {
|
|
8
18
|
ctimeMs: stat.ctimeMs,
|
|
@@ -179,12 +189,14 @@ async function cleanupCopiedEntry(sourcePath, manifest) {
|
|
|
179
189
|
return "removed";
|
|
180
190
|
}
|
|
181
191
|
export async function movePathWithCopyFallback(options) {
|
|
192
|
+
let fallbackReason;
|
|
182
193
|
try {
|
|
183
194
|
await guardedRename({ from: options.from, to: options.to });
|
|
184
195
|
return;
|
|
185
196
|
}
|
|
186
197
|
catch (error) {
|
|
187
|
-
|
|
198
|
+
fallbackReason = moveCopyFallbackReasonForRenameError(error);
|
|
199
|
+
if (!fallbackReason) {
|
|
188
200
|
throw error;
|
|
189
201
|
}
|
|
190
202
|
}
|
package/dist/path.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
export { assertNoUnsafeDeviceReadPath, isUnsafeDeviceReadPath, matchUnsafeDeviceReadPath, type UnsafeDeviceReadPathMatch, type UnsafeDeviceReadPathOptions, type UnsafeDeviceReadPathReason, } from "./device-path.js";
|
|
2
3
|
export declare function normalizeWindowsPathForComparison(input: string): string;
|
|
3
4
|
export declare function isNodeError(value: unknown): value is NodeJS.ErrnoException;
|
|
4
5
|
export declare function hasNodeErrorCode(value: unknown, code: string): boolean;
|
package/dist/path.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAMzB,OAAO,EACL,4BAA4B,EAC5B,sBAAsB,EACtB,yBAAyB,EACzB,KAAK,yBAAyB,EAC9B,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,GAChC,MAAM,kBAAkB,CAAC;AAM1B,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASvE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,cAAc,CAI1E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtE;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,SAA6B,GAAG,IAAI,CAIjG;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE3D;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE1D;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CA4BlE;AAED,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAElE;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAa/F;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,IAAI,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAChE,OAAO,CAUT;AAED,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,EAAE,CAAC,KAAK,GAAG,IAAI,CAMhE;AAED,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,EAAE,CAuBpE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAOrF"}
|
package/dist/path.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { FsSafeError } from "./errors.js";
|
|
4
4
|
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
|
|
5
|
+
export { assertNoUnsafeDeviceReadPath, isUnsafeDeviceReadPath, matchUnsafeDeviceReadPath, } from "./device-path.js";
|
|
5
6
|
const NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]);
|
|
6
7
|
const SYMLINK_OPEN_CODES = new Set(["ELOOP", "EINVAL", "ENOTSUP"]);
|
|
7
8
|
const POSIX_SEPARATOR_CHAR_CODE = 0x2f;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pinned-open.d.ts","sourceRoot":"","sources":["../src/pinned-open.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"pinned-open.d.ts","sourceRoot":"","sources":["../src/pinned-open.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAIzB,MAAM,MAAM,2BAA2B,GAAG,MAAM,GAAG,YAAY,GAAG,IAAI,CAAC;AAEvE,MAAM,MAAM,oBAAoB,GAC5B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAA;CAAE,GACtD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,2BAA2B,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAExE,MAAM,MAAM,yBAAyB,GAAG,MAAM,GAAG,WAAW,CAAC;AAE7D,MAAM,MAAM,gBAAgB,GAAG,IAAI,CACjC,OAAO,EAAE,EACT,WAAW,GAAG,WAAW,GAAG,cAAc,GAAG,UAAU,GAAG,WAAW,GAAG,WAAW,CACpF,CAAC;AAYF,wBAAgB,kBAAkB,CAAC,MAAM,EAAE;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,yBAAyB,CAAC;IACxC,IAAI,CAAC,EAAE,gBAAgB,CAAC;CACzB,GAAG,oBAAoB,CAiEvB"}
|
package/dist/pinned-open.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { isUnsafeDeviceReadPath } from "./device-path.js";
|
|
2
3
|
import { sameFileIdentity as hasSameFileIdentity } from "./file-identity.js";
|
|
3
4
|
function isExpectedPathError(error) {
|
|
4
5
|
const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
|
|
@@ -14,6 +15,9 @@ export function openPinnedFileSync(params) {
|
|
|
14
15
|
(typeof ioFs.constants.O_NOFOLLOW === "number" ? ioFs.constants.O_NOFOLLOW : 0);
|
|
15
16
|
let fd = null;
|
|
16
17
|
try {
|
|
18
|
+
if (isUnsafeDeviceReadPath(params.filePath)) {
|
|
19
|
+
return { ok: false, reason: "validation" };
|
|
20
|
+
}
|
|
17
21
|
if (params.rejectPathSymlink) {
|
|
18
22
|
const candidateStat = ioFs.lstatSync(params.filePath);
|
|
19
23
|
if (candidateStat.isSymbolicLink()) {
|
|
@@ -21,6 +25,9 @@ export function openPinnedFileSync(params) {
|
|
|
21
25
|
}
|
|
22
26
|
}
|
|
23
27
|
const realPath = params.resolvedPath ?? ioFs.realpathSync(params.filePath);
|
|
28
|
+
if (isUnsafeDeviceReadPath(realPath)) {
|
|
29
|
+
return { ok: false, reason: "validation" };
|
|
30
|
+
}
|
|
24
31
|
const preOpenStat = ioFs.lstatSync(realPath);
|
|
25
32
|
if (!isAllowedType(preOpenStat, allowedType)) {
|
|
26
33
|
return { ok: false, reason: "validation" };
|
package/dist/pinned-write.d.ts
CHANGED
|
@@ -8,7 +8,9 @@ type PinnedWriteInput = {
|
|
|
8
8
|
kind: "stream";
|
|
9
9
|
stream: Readable;
|
|
10
10
|
};
|
|
11
|
-
|
|
11
|
+
type RenameIdentityMismatchPolicy = "throw" | "verify-content";
|
|
12
|
+
export type RenameIdentityPolicy = "strict" | "verify-content-with-lock";
|
|
13
|
+
type PinnedWriteParams = {
|
|
12
14
|
rootPath: string;
|
|
13
15
|
relativeParentPath: string;
|
|
14
16
|
basename: string;
|
|
@@ -18,6 +20,12 @@ export declare function runPinnedWriteHelper(params: {
|
|
|
18
20
|
maxBytes?: number;
|
|
19
21
|
input: PinnedWriteInput;
|
|
20
22
|
rootIdentity?: FileIdentityStat;
|
|
23
|
+
onRenameIdentityMismatch?: RenameIdentityMismatchPolicy;
|
|
24
|
+
};
|
|
25
|
+
export declare function runPinnedWriteHelper(params: PinnedWriteParams): Promise<FileIdentityStat>;
|
|
26
|
+
export declare function runPinnedWriteWithRenamePolicy(params: PinnedWriteParams & {
|
|
27
|
+
targetPath: string;
|
|
28
|
+
renameIdentity?: RenameIdentityPolicy;
|
|
21
29
|
}): Promise<FileIdentityStat>;
|
|
22
30
|
export declare function runPinnedCopyHelper(params: {
|
|
23
31
|
rootPath: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAI5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAa3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AA6EzC,KAAK,4BAA4B,GAAG,OAAO,GAAG,gBAAgB,CAAC;AAE/D,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,0BAA0B,CAAC;AAEzE,KAAK,iBAAiB,GAAG;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;IACxB,YAAY,CAAC,EAAE,gBAAgB,CAAC;IAChC,wBAAwB,CAAC,EAAE,4BAA4B,CAAC;CACzD,CAAC;AAEF,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAkD/F;AAED,wBAAsB,8BAA8B,CAClD,MAAM,EAAE,iBAAiB,GAAG;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,oBAAoB,CAAC;CACvC,GACA,OAAO,CAAC,gBAAgB,CAAC,CA2B3B;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,gBAAgB,CAAC;IACjC,YAAY,CAAC,EAAE,gBAAgB,CAAC;CACjC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAqB5B"}
|
package/dist/pinned-write.js
CHANGED
|
@@ -4,11 +4,14 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
|
|
6
6
|
import { FsSafeError } from "./errors.js";
|
|
7
|
-
import {
|
|
7
|
+
import { syncDirectoryBestEffort } from "./fsync.js";
|
|
8
|
+
import { sameFileIdentity, sha256Hex } from "./file-identity.js";
|
|
8
9
|
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
9
10
|
import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
|
|
10
11
|
import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
|
|
11
12
|
import { assertPinnedPythonOperationAvailable, runPinnedPythonOperation, validatePinnedOperationPayload, } from "./pinned-python.js";
|
|
13
|
+
import { withSidecarLock } from "./sidecar-lock.js";
|
|
14
|
+
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
12
15
|
function byteLength(input, encoding) {
|
|
13
16
|
return typeof input === "string"
|
|
14
17
|
? Buffer.byteLength(input, encoding ?? "utf8")
|
|
@@ -66,6 +69,12 @@ export async function runPinnedWriteHelper(params) {
|
|
|
66
69
|
validatePinnedOperationPayload({
|
|
67
70
|
relativeParentPath: params.relativeParentPath,
|
|
68
71
|
});
|
|
72
|
+
// The Python helper deliberately enforces the strict post-rename inode
|
|
73
|
+
// contract. The explicit compatibility policy therefore uses the guarded
|
|
74
|
+
// Node fallback, where content verification can replace that one check.
|
|
75
|
+
if (params.onRenameIdentityMismatch === "verify-content") {
|
|
76
|
+
return await runPinnedWriteFallback(params);
|
|
77
|
+
}
|
|
69
78
|
if (getFsSafePythonConfig().mode === "off") {
|
|
70
79
|
return await runPinnedWriteFallback(params);
|
|
71
80
|
}
|
|
@@ -80,8 +89,11 @@ export async function runPinnedWriteHelper(params) {
|
|
|
80
89
|
throw error;
|
|
81
90
|
}
|
|
82
91
|
}
|
|
92
|
+
const input = params.input.kind === "stream"
|
|
93
|
+
? { kind: "buffer", data: Buffer.from(await inputToBase64(params.input, params.maxBytes), "base64") }
|
|
94
|
+
: params.input;
|
|
83
95
|
const payload = {
|
|
84
|
-
base64: await inputToBase64(
|
|
96
|
+
base64: await inputToBase64(input, params.maxBytes),
|
|
85
97
|
basename: params.basename,
|
|
86
98
|
maxBytes: params.maxBytes ?? -1,
|
|
87
99
|
mkdir: params.mkdir,
|
|
@@ -99,11 +111,32 @@ export async function runPinnedWriteHelper(params) {
|
|
|
99
111
|
}
|
|
100
112
|
catch (error) {
|
|
101
113
|
if (canFallbackFromPythonError(error)) {
|
|
102
|
-
return await runPinnedWriteFallback(params);
|
|
114
|
+
return await runPinnedWriteFallback({ ...params, input });
|
|
103
115
|
}
|
|
104
116
|
throw error;
|
|
105
117
|
}
|
|
106
118
|
}
|
|
119
|
+
export async function runPinnedWriteWithRenamePolicy(params) {
|
|
120
|
+
const { targetPath, renameIdentity, ...writeParams } = params;
|
|
121
|
+
if (renameIdentity !== "verify-content-with-lock") {
|
|
122
|
+
return await runPinnedWriteHelper(writeParams);
|
|
123
|
+
}
|
|
124
|
+
const relativeTargetPath = writeParams.relativeParentPath
|
|
125
|
+
? `${writeParams.relativeParentPath}/${writeParams.basename}`
|
|
126
|
+
: writeParams.basename;
|
|
127
|
+
const lockPath = path.join(writeParams.rootPath, `.fs-safe-write-${sha256Hex(relativeTargetPath)}.lock`);
|
|
128
|
+
return await withSidecarLock(writeParams.rootPath, {
|
|
129
|
+
managerKey: `fs-safe.write:${targetPath}`,
|
|
130
|
+
lockPath,
|
|
131
|
+
staleMs: 30_000,
|
|
132
|
+
timeoutMs: 5_000,
|
|
133
|
+
payload: () => ({ pid: process.pid, createdAt: new Date().toISOString() }),
|
|
134
|
+
retry: { retries: 5, minTimeout: 100, maxTimeout: 2_000, factor: 2 },
|
|
135
|
+
}, async () => await runPinnedWriteHelper({
|
|
136
|
+
...writeParams,
|
|
137
|
+
onRenameIdentityMismatch: "verify-content",
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
107
140
|
export async function runPinnedCopyHelper(params) {
|
|
108
141
|
assertSafeBasename(params.basename);
|
|
109
142
|
validatePinnedOperationPayload({
|
|
@@ -159,7 +192,10 @@ async function runPinnedWriteFallback(params) {
|
|
|
159
192
|
else {
|
|
160
193
|
await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
|
|
161
194
|
}
|
|
195
|
+
await handle.sync();
|
|
162
196
|
const stat = await handle.stat();
|
|
197
|
+
await handle.close().catch(() => undefined);
|
|
198
|
+
await syncDirectoryBestEffort(parentPath);
|
|
163
199
|
created = false;
|
|
164
200
|
return { dev: stat.dev, ino: stat.ino };
|
|
165
201
|
}
|
|
@@ -201,15 +237,55 @@ async function runPinnedWriteFallback(params) {
|
|
|
201
237
|
throw new FsSafeError("path-mismatch", "fallback temp path changed during write");
|
|
202
238
|
}
|
|
203
239
|
const expectedTempStat = tempStat;
|
|
240
|
+
await handle.sync();
|
|
204
241
|
await handle.close().catch(() => undefined);
|
|
205
242
|
handle = undefined;
|
|
206
243
|
await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
207
244
|
await fs.rename(tempPath, targetPath);
|
|
208
245
|
renamed = true;
|
|
246
|
+
await getFsSafeTestHooks()?.afterPinnedWriteFallbackRename?.(targetPath);
|
|
247
|
+
await syncDirectoryBestEffort(parentPath);
|
|
209
248
|
targetStat = await fs.lstat(targetPath);
|
|
210
|
-
if (targetStat.isSymbolicLink()
|
|
249
|
+
if (targetStat.isSymbolicLink()) {
|
|
211
250
|
throw new FsSafeError("path-mismatch", "fallback target changed during write");
|
|
212
251
|
}
|
|
252
|
+
if (!sameFileIdentity(targetStat, expectedTempStat)) {
|
|
253
|
+
// On filesystems like rclone FUSE, rename(2) can give the destination a
|
|
254
|
+
// different inode from the source temp fd even with zero concurrency. The
|
|
255
|
+
// caller must ensure mutual exclusion before passing "verify-content";
|
|
256
|
+
// fall back to a content hash for this rename-boundary check only.
|
|
257
|
+
if (params.onRenameIdentityMismatch !== "verify-content") {
|
|
258
|
+
throw new FsSafeError("path-mismatch", "fallback target changed during write");
|
|
259
|
+
}
|
|
260
|
+
if (params.input.kind !== "buffer") {
|
|
261
|
+
throw new FsSafeError("path-mismatch", "fallback target changed during write");
|
|
262
|
+
}
|
|
263
|
+
const expectedHash = sha256Hex(params.input.data, params.input.encoding);
|
|
264
|
+
const readFlags = fsSync.constants.O_RDONLY |
|
|
265
|
+
(process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants
|
|
266
|
+
? fsSync.constants.O_NOFOLLOW
|
|
267
|
+
: 0);
|
|
268
|
+
const readHandle = await fs.open(targetPath, readFlags);
|
|
269
|
+
let actualHash;
|
|
270
|
+
let readHandleStat;
|
|
271
|
+
try {
|
|
272
|
+
// Capture fd-based identity before reading — this is stable across all
|
|
273
|
+
// subsequent lookups (on FUSE and locally), unlike the lstat-based
|
|
274
|
+
// targetStat that triggered this fallback.
|
|
275
|
+
readHandleStat = await readHandle.stat();
|
|
276
|
+
actualHash = sha256Hex(await readHandle.readFile());
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
await readHandle.close().catch(() => undefined);
|
|
280
|
+
}
|
|
281
|
+
if (actualHash !== expectedHash) {
|
|
282
|
+
throw new FsSafeError("path-mismatch", "fallback target changed during write");
|
|
283
|
+
}
|
|
284
|
+
// Replace the unreliable lstat-based targetStat with the fd-based stat so
|
|
285
|
+
// the returned identity is consistent with what subsequent verifications
|
|
286
|
+
// (e.g. verifyAtomicWriteResult) will obtain by opening the same file.
|
|
287
|
+
targetStat = readHandleStat;
|
|
288
|
+
}
|
|
213
289
|
});
|
|
214
290
|
}
|
|
215
291
|
catch (error) {
|