@openclaw/fs-safe 0.2.7 → 0.3.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 +10 -0
- package/README.md +12 -1
- package/dist/deny-mutations.d.ts +11 -0
- package/dist/deny-mutations.d.ts.map +1 -0
- package/dist/deny-mutations.js +102 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +51 -4
- package/dist/opened-realpath.d.ts +3 -0
- package/dist/opened-realpath.d.ts.map +1 -0
- package/dist/opened-realpath.js +79 -0
- package/dist/read-opened-file.d.ts +18 -0
- package/dist/read-opened-file.d.ts.map +1 -0
- package/dist/read-opened-file.js +15 -0
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +23 -3
- package/dist/root-impl.d.ts +18 -15
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +60 -100
- package/dist/root.d.ts +1 -1
- package/dist/root.d.ts.map +1 -1
- package/docs/errors.md +2 -0
- package/docs/root.md +13 -4
- package/docs/security-model.md +4 -0
- package/docs/types.md +21 -9
- package/docs/writing.md +19 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 - 2026-05-21
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Add opt-in `denyMutations` policies with exact `paths` and subtree `prefixes` so callers can protect application-sensitive files from root write, copy, move, remove, mkdir, and writable-open operations. (#20; thanks @amknight)
|
|
8
|
+
|
|
9
|
+
### Security and Correctness
|
|
10
|
+
|
|
11
|
+
- Retry async JSON reads (`readJson`, `readJsonIfExists`, `tryReadJson`) up to five attempts with 50ms exponential backoff when the file is rotated mid-read by an atomic rename, and tag the underlying race as `FsSafeError("path-mismatch")` so callers can distinguish transient swaps from corruption. (#19; thanks @yetval)
|
|
12
|
+
|
|
3
13
|
## 0.2.7 - 2026-05-20
|
|
4
14
|
|
|
5
15
|
### Security and Correctness
|
package/README.md
CHANGED
|
@@ -155,7 +155,18 @@ await using opened = await fs.openWritable("logs/current.log", { writeMode: "app
|
|
|
155
155
|
}
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
`nonBlockingRead` is the only I/O scheduling knob in `RootDefaults`; it applies to read/open operations because it changes how file descriptors are opened. Filesystem safety policy remains explicit through `hardlinks` and `
|
|
158
|
+
`nonBlockingRead` is the only I/O scheduling knob in `RootDefaults`; it applies to read/open operations because it changes how file descriptors are opened. Filesystem safety policy remains explicit through `hardlinks`, `symlinks`, and `denyMutations`.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const locked = await root("/srv/workspace", {
|
|
162
|
+
denyMutations: {
|
|
163
|
+
paths: ["/srv/workspace/.env"],
|
|
164
|
+
prefixes: ["/srv/workspace/.ssh"],
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await locked.write(".env", "token"); // FsSafeError code "denied-path"
|
|
169
|
+
```
|
|
159
170
|
|
|
160
171
|
`stat()`, `exists()`, and `list()` are boundary-checked, but they cannot pin a later operation to the same filesystem object. Use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()` for operations that must be race-resistant at the point of use.
|
|
161
172
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type DenyMutationPolicy = {
|
|
2
|
+
paths?: readonly string[];
|
|
3
|
+
prefixes?: readonly string[];
|
|
4
|
+
};
|
|
5
|
+
type DenyMutationCheckOptions = {
|
|
6
|
+
protectAncestors?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function assertMutationNotDenied(filePath: string, policy: DenyMutationPolicy | undefined, options?: DenyMutationCheckOptions): Promise<void>;
|
|
9
|
+
export declare function mergeDenyMutationPolicies(defaultPolicy: DenyMutationPolicy | undefined, callPolicy: DenyMutationPolicy | undefined): DenyMutationPolicy | undefined;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=deny-mutations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deny-mutations.d.ts","sourceRoot":"","sources":["../src/deny-mutations.ts"],"names":[],"mappings":"AAmDA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B,CAAC;AAEF,KAAK,wBAAwB,GAAG;IAC9B,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAqBF,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,kBAAkB,GAAG,SAAS,EACtC,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,IAAI,CAAC,CAiCf;AAED,wBAAgB,yBAAyB,CACvC,aAAa,EAAE,kBAAkB,GAAG,SAAS,EAC7C,UAAU,EAAE,kBAAkB,GAAG,SAAS,GACzC,kBAAkB,GAAG,SAAS,CAWhC"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { FsSafeError } from "./errors.js";
|
|
4
|
+
import { assertNoNulPathInput, isNotFoundPathError, isPathInside } from "./path.js";
|
|
5
|
+
async function pathExists(filePath) {
|
|
6
|
+
try {
|
|
7
|
+
await fs.lstat(filePath);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
if (!isNotFoundPathError(err)) {
|
|
12
|
+
throw err;
|
|
13
|
+
}
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function resolvePathViaExistingAncestor(targetPath) {
|
|
18
|
+
const normalized = path.resolve(targetPath);
|
|
19
|
+
let cursor = normalized;
|
|
20
|
+
const missingSuffix = [];
|
|
21
|
+
while (path.dirname(cursor) !== cursor && !(await pathExists(cursor))) {
|
|
22
|
+
missingSuffix.unshift(path.basename(cursor));
|
|
23
|
+
cursor = path.dirname(cursor);
|
|
24
|
+
}
|
|
25
|
+
if (!(await pathExists(cursor))) {
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const resolvedAncestor = path.resolve(await fs.realpath(cursor));
|
|
30
|
+
return missingSuffix.length === 0
|
|
31
|
+
? resolvedAncestor
|
|
32
|
+
: path.resolve(resolvedAncestor, ...missingSuffix);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function comparablePaths(rawPath) {
|
|
39
|
+
assertNoNulPathInput(rawPath, "path contains a NUL byte");
|
|
40
|
+
const resolved = path.resolve(rawPath);
|
|
41
|
+
return new Set([resolved, await resolvePathViaExistingAncestor(resolved)]);
|
|
42
|
+
}
|
|
43
|
+
function isSamePath(left, right) {
|
|
44
|
+
return isPathInside(left, right) && isPathInside(right, left);
|
|
45
|
+
}
|
|
46
|
+
function hasPolicyEntries(policy) {
|
|
47
|
+
return Boolean(policy?.paths?.length || policy?.prefixes?.length);
|
|
48
|
+
}
|
|
49
|
+
function policyPathEntries(entries) {
|
|
50
|
+
const paths = [];
|
|
51
|
+
for (const entry of entries ?? []) {
|
|
52
|
+
if (entry.length === 0) {
|
|
53
|
+
throw new FsSafeError("invalid-path", "deny mutation paths must be non-empty");
|
|
54
|
+
}
|
|
55
|
+
assertNoNulPathInput(entry, "deny mutation path contains a NUL byte");
|
|
56
|
+
if (!path.isAbsolute(entry)) {
|
|
57
|
+
throw new FsSafeError("invalid-path", "deny mutation paths must be absolute");
|
|
58
|
+
}
|
|
59
|
+
paths.push(entry);
|
|
60
|
+
}
|
|
61
|
+
return paths;
|
|
62
|
+
}
|
|
63
|
+
export async function assertMutationNotDenied(filePath, policy, options = {}) {
|
|
64
|
+
if (!hasPolicyEntries(policy)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const targetPaths = await comparablePaths(filePath);
|
|
68
|
+
for (const deniedPath of policyPathEntries(policy.paths)) {
|
|
69
|
+
const deniedPaths = await comparablePaths(deniedPath);
|
|
70
|
+
for (const target of targetPaths) {
|
|
71
|
+
for (const denied of deniedPaths) {
|
|
72
|
+
if (isSamePath(denied, target) ||
|
|
73
|
+
(options.protectAncestors === true && isPathInside(target, denied))) {
|
|
74
|
+
throw new FsSafeError("denied-path", "path is denied by denyMutations policy");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const deniedPrefix of policyPathEntries(policy.prefixes)) {
|
|
80
|
+
const deniedPaths = await comparablePaths(deniedPrefix);
|
|
81
|
+
for (const target of targetPaths) {
|
|
82
|
+
for (const denied of deniedPaths) {
|
|
83
|
+
if (isPathInside(denied, target) ||
|
|
84
|
+
(options.protectAncestors === true && isPathInside(target, denied))) {
|
|
85
|
+
throw new FsSafeError("denied-path", "path is denied by denyMutations policy");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function mergeDenyMutationPolicies(defaultPolicy, callPolicy) {
|
|
92
|
+
if (!defaultPolicy) {
|
|
93
|
+
return callPolicy;
|
|
94
|
+
}
|
|
95
|
+
if (!callPolicy) {
|
|
96
|
+
return defaultPolicy;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
paths: [...(defaultPolicy.paths ?? []), ...(callPolicy.paths ?? [])],
|
|
100
|
+
prefixes: [...(defaultPolicy.prefixes ?? []), ...(callPolicy.prefixes ?? [])],
|
|
101
|
+
};
|
|
102
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FsSafeErrorCode = "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" | "unsupported-platform";
|
|
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";
|
|
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,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,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/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 HardlinkPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, 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 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,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,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,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,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/json.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../src/json.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../src/json.ts"],"names":[],"mappings":"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"}
|
package/dist/json.js
CHANGED
|
@@ -1,10 +1,49 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import fsSync from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { FsSafeError } from "./errors.js";
|
|
4
5
|
import { stringifyJsonDocument } from "./json-stringify.js";
|
|
5
|
-
import { readRegularFile, readRegularFileSync } from "./regular-file.js";
|
|
6
|
+
import { readRegularFile, readRegularFileSync, statRegularFile } from "./regular-file.js";
|
|
6
7
|
import { openRootFileSync } from "./root-file.js";
|
|
7
8
|
import { writeTextAtomic } from "./text-atomic.js";
|
|
9
|
+
const READ_RETRY_MAX_ATTEMPTS = 5;
|
|
10
|
+
const READ_RETRY_BASE_DELAY_MS = 50;
|
|
11
|
+
function isRetryableReadError(err, options) {
|
|
12
|
+
if (err instanceof FsSafeError && err.code === "path-mismatch") {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
if (options.retryOpenRaceErrors !== true) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const code = getErrorCode(err);
|
|
19
|
+
return code === "ENOENT" || code === "EPERM";
|
|
20
|
+
}
|
|
21
|
+
function sleep(ms) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
async function readRegularFileWithRetry(filePath, options = {}) {
|
|
25
|
+
let lastErr;
|
|
26
|
+
for (let attempt = 0; attempt < READ_RETRY_MAX_ATTEMPTS; attempt++) {
|
|
27
|
+
try {
|
|
28
|
+
return (await readRegularFile({ filePath })).buffer;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
lastErr = err;
|
|
32
|
+
if (!isRetryableReadError(err, options) || attempt === READ_RETRY_MAX_ATTEMPTS - 1) {
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
await sleep(READ_RETRY_BASE_DELAY_MS * Math.pow(2, attempt));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw lastErr;
|
|
39
|
+
}
|
|
40
|
+
async function readRegularFileIfExistsWithRetry(filePath) {
|
|
41
|
+
const initial = await statRegularFile(filePath);
|
|
42
|
+
if (initial.missing) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return await readRegularFileWithRetry(filePath, { retryOpenRaceErrors: true });
|
|
46
|
+
}
|
|
8
47
|
const JSON_FILE_MODE = 0o600;
|
|
9
48
|
const JSON_DIR_MODE = 0o700;
|
|
10
49
|
const SUPPORTS_SYNC_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants;
|
|
@@ -200,7 +239,11 @@ export function readRootJsonObjectSync(options) {
|
|
|
200
239
|
}
|
|
201
240
|
export async function tryReadJson(filePath) {
|
|
202
241
|
try {
|
|
203
|
-
const
|
|
242
|
+
const buffer = await readRegularFileIfExistsWithRetry(filePath);
|
|
243
|
+
if (buffer === null) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
const raw = buffer.toString("utf8");
|
|
204
247
|
return JSON.parse(raw);
|
|
205
248
|
}
|
|
206
249
|
catch {
|
|
@@ -210,7 +253,7 @@ export async function tryReadJson(filePath) {
|
|
|
210
253
|
export async function readJson(filePath) {
|
|
211
254
|
let raw;
|
|
212
255
|
try {
|
|
213
|
-
raw = (await
|
|
256
|
+
raw = (await readRegularFileWithRetry(filePath, { retryOpenRaceErrors: true })).toString("utf8");
|
|
214
257
|
}
|
|
215
258
|
catch (err) {
|
|
216
259
|
throw new JsonFileReadError(filePath, "read", err);
|
|
@@ -225,7 +268,11 @@ export async function readJson(filePath) {
|
|
|
225
268
|
export async function readJsonIfExists(filePath) {
|
|
226
269
|
let raw;
|
|
227
270
|
try {
|
|
228
|
-
|
|
271
|
+
const buffer = await readRegularFileIfExistsWithRetry(filePath);
|
|
272
|
+
if (buffer === null) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
raw = buffer.toString("utf8");
|
|
229
276
|
}
|
|
230
277
|
catch (err) {
|
|
231
278
|
if (getErrorCode(err) === "ENOENT") {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"opened-realpath.d.ts","sourceRoot":"","sources":["../src/opened-realpath.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMnD,wBAAsB,kCAAkC,CACtD,MAAM,EAAE,UAAU,EAClB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,CAAC,CAoCjB"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { FsSafeError } from "./errors.js";
|
|
4
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
5
|
+
import { isNotFoundPathError } from "./path.js";
|
|
6
|
+
export async function resolveOpenedFileRealPathForHandle(handle, ioPath) {
|
|
7
|
+
const handleStat = await handle.stat();
|
|
8
|
+
const fdCandidates = process.platform === "linux"
|
|
9
|
+
? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
|
|
10
|
+
: process.platform === "win32"
|
|
11
|
+
? []
|
|
12
|
+
: [`/dev/fd/${handle.fd}`];
|
|
13
|
+
for (const fdPath of fdCandidates) {
|
|
14
|
+
try {
|
|
15
|
+
const fdRealPath = await fs.realpath(fdPath);
|
|
16
|
+
const fdRealStat = await fs.stat(fdRealPath);
|
|
17
|
+
if (sameFileIdentity(handleStat, fdRealStat)) {
|
|
18
|
+
return fdRealPath;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// try next fd path
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const ioRealPath = await fs.realpath(ioPath);
|
|
27
|
+
const ioRealStat = await fs.stat(ioRealPath);
|
|
28
|
+
if (sameFileIdentity(handleStat, ioRealStat)) {
|
|
29
|
+
return ioRealPath;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (!isNotFoundPathError(err)) {
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const parentResolved = await resolveOpenedFileRealPathFromParent(handleStat, ioPath);
|
|
38
|
+
if (parentResolved) {
|
|
39
|
+
return parentResolved;
|
|
40
|
+
}
|
|
41
|
+
throw new FsSafeError("path-mismatch", "unable to resolve opened file path");
|
|
42
|
+
}
|
|
43
|
+
async function resolveOpenedFileRealPathFromParent(handleStat, ioPath) {
|
|
44
|
+
let parentReal;
|
|
45
|
+
try {
|
|
46
|
+
parentReal = await fs.realpath(path.dirname(ioPath));
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (isNotFoundPathError(err)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = await fs.readdir(parentReal);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (isNotFoundPathError(err)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
for (const entry of entries.toSorted()) {
|
|
65
|
+
const candidatePath = path.join(parentReal, entry);
|
|
66
|
+
try {
|
|
67
|
+
const candidateStat = await fs.lstat(candidatePath);
|
|
68
|
+
if (candidateStat.isFile() && sameFileIdentity(handleStat, candidateStat)) {
|
|
69
|
+
return await fs.realpath(candidatePath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (!isNotFoundPathError(err)) {
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import type { FileHandle } from "node:fs/promises";
|
|
3
|
+
export type ReadResult = {
|
|
4
|
+
buffer: Buffer;
|
|
5
|
+
realPath: string;
|
|
6
|
+
stat: Stats;
|
|
7
|
+
};
|
|
8
|
+
type OpenedFile = {
|
|
9
|
+
handle: FileHandle;
|
|
10
|
+
realPath: string;
|
|
11
|
+
stat: Stats;
|
|
12
|
+
};
|
|
13
|
+
export declare function readOpenedFileSafely(params: {
|
|
14
|
+
opened: OpenedFile;
|
|
15
|
+
maxBytes?: number;
|
|
16
|
+
}): Promise<ReadResult>;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=read-opened-file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-opened-file.d.ts","sourceRoot":"","sources":["../src/read-opened-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAGnD,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;CACb,CAAC;AAEF,KAAK,UAAU,GAAG;IAChB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;CACb,CAAC;AAEF,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,UAAU,CAAC,CAmBtB"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FsSafeError } from "./errors.js";
|
|
2
|
+
export async function readOpenedFileSafely(params) {
|
|
3
|
+
if (params.maxBytes !== undefined && params.opened.stat.size > params.maxBytes) {
|
|
4
|
+
throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${params.opened.stat.size})`);
|
|
5
|
+
}
|
|
6
|
+
const buffer = await params.opened.handle.readFile();
|
|
7
|
+
if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) {
|
|
8
|
+
throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${buffer.byteLength})`);
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
buffer,
|
|
12
|
+
realPath: params.opened.realPath,
|
|
13
|
+
stat: params.opened.stat,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"regular-file.d.ts","sourceRoot":"","sources":["../src/regular-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,MAAM,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"regular-file.d.ts","sourceRoot":"","sources":["../src/regular-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,MAAM,MAAM,SAAS,CAAC;AAS7B,MAAM,MAAM,qBAAqB,GAAG;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC;AAExF,KAAK,8BAA8B,GAAG,IAAI,CACxC,OAAO,MAAM,CAAC,SAAS,EACvB,UAAU,GAAG,SAAS,GAAG,UAAU,CACpC,GACC,OAAO,CAAC,IAAI,CAAC,OAAO,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;AAEvD,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC,CAAC;AAEF,wBAAgB,6BAA6B,CAC3C,SAAS,GAAE,8BAAiD,GAC3D,MAAM,CAQR;AA2DD,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CActF;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,qBAAqB,CAc3E;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC,CAiD3C;AA6CD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG;IACpF,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,KAAK,CAAC;CACb,CAoBA;AAmBD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDxF;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,wBAAwB,GAAG,IAAI,CA2D7E"}
|
package/dist/regular-file.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fsSync from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { FsSafeError } from "./errors.js";
|
|
4
5
|
import { sameFileIdentity } from "./file-identity.js";
|
|
5
6
|
import { isNotFoundPathError } from "./path.js";
|
|
6
7
|
import { assertNoSymlinkParents, assertNoSymlinkParentsSync } from "./symlink-parents.js";
|
|
@@ -95,12 +96,31 @@ export async function readRegularFile(params) {
|
|
|
95
96
|
if (params.maxBytes !== undefined && result.stat.size > params.maxBytes) {
|
|
96
97
|
throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
|
|
97
98
|
}
|
|
98
|
-
|
|
99
|
+
let handle;
|
|
100
|
+
try {
|
|
101
|
+
handle = await fs.open(params.filePath, resolveRegularFileReadFlags());
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
if (isNotFoundPathError(err)) {
|
|
105
|
+
throw new FsSafeError("path-mismatch", `File changed during read: ${params.filePath}`);
|
|
106
|
+
}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
99
109
|
try {
|
|
100
110
|
const stat = await handle.stat();
|
|
111
|
+
let pathStat;
|
|
112
|
+
try {
|
|
113
|
+
pathStat = await fs.lstat(params.filePath);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (isNotFoundPathError(err)) {
|
|
117
|
+
throw new FsSafeError("path-mismatch", `File changed during read: ${params.filePath}`);
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
101
121
|
verifyStableReadTarget({
|
|
102
122
|
filePath: params.filePath,
|
|
103
|
-
pathStat
|
|
123
|
+
pathStat,
|
|
104
124
|
postOpenStat: stat,
|
|
105
125
|
preOpenStat: result.stat,
|
|
106
126
|
});
|
|
@@ -126,7 +146,7 @@ function verifyStableReadTarget(params) {
|
|
|
126
146
|
}
|
|
127
147
|
if (!sameFileIdentity(params.preOpenStat, params.postOpenStat) ||
|
|
128
148
|
!sameFileIdentity(params.pathStat, params.postOpenStat)) {
|
|
129
|
-
throw new
|
|
149
|
+
throw new FsSafeError("path-mismatch", `File changed during read: ${params.filePath}`);
|
|
130
150
|
}
|
|
131
151
|
}
|
|
132
152
|
function readOpenedRegularFileSync(params) {
|
package/dist/root-impl.d.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import type { Stats } from "node:fs";
|
|
2
2
|
import type { FileHandle } from "node:fs/promises";
|
|
3
|
+
import { type DenyMutationPolicy } from "./deny-mutations.js";
|
|
4
|
+
import { type ReadResult } from "./read-opened-file.js";
|
|
3
5
|
import type { DirEntry, PathStat } from "./types.js";
|
|
6
|
+
export type { DenyMutationPolicy } from "./deny-mutations.js";
|
|
7
|
+
export { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
|
|
8
|
+
export type { ReadResult } from "./read-opened-file.js";
|
|
4
9
|
export type OpenResult = {
|
|
5
10
|
handle: FileHandle;
|
|
6
11
|
realPath: string;
|
|
7
12
|
stat: Stats;
|
|
8
13
|
[Symbol.asyncDispose](): Promise<void>;
|
|
9
14
|
};
|
|
10
|
-
export type ReadResult = {
|
|
11
|
-
buffer: Buffer;
|
|
12
|
-
realPath: string;
|
|
13
|
-
stat: Stats;
|
|
14
|
-
};
|
|
15
15
|
export type RootOptions = {
|
|
16
16
|
rootDir: string;
|
|
17
17
|
defaults?: RootDefaults;
|
|
@@ -24,19 +24,20 @@ export type RootDefaults = {
|
|
|
24
24
|
maxBytes?: number;
|
|
25
25
|
mkdir?: boolean;
|
|
26
26
|
mode?: number;
|
|
27
|
+
denyMutations?: DenyMutationPolicy;
|
|
27
28
|
nonBlockingRead?: boolean;
|
|
28
29
|
symlinks?: SymlinkPolicy;
|
|
29
30
|
};
|
|
30
31
|
export type RootReadOptions = Pick<RootDefaults, "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks">;
|
|
31
32
|
export type RootOpenOptions = Omit<RootReadOptions, "maxBytes">;
|
|
32
|
-
export type RootWriteOptions = Pick<RootDefaults, "mkdir" | "mode"> & {
|
|
33
|
+
export type RootWriteOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
|
|
33
34
|
encoding?: BufferEncoding;
|
|
34
35
|
overwrite?: boolean;
|
|
35
36
|
};
|
|
36
|
-
export type RootOpenWritableOptions = Pick<RootDefaults, "mkdir" | "mode"> & {
|
|
37
|
+
export type RootOpenWritableOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
|
|
37
38
|
writeMode?: WritableOpenMode;
|
|
38
39
|
};
|
|
39
|
-
export type RootCopyOptions = Pick<RootDefaults, "maxBytes" | "mkdir" | "mode"> & {
|
|
40
|
+
export type RootCopyOptions = Pick<RootDefaults, "denyMutations" | "maxBytes" | "mkdir" | "mode"> & {
|
|
40
41
|
sourceHardlinks?: HardlinkPolicy;
|
|
41
42
|
};
|
|
42
43
|
export type RootWriteJsonOptions = RootWriteOptions & {
|
|
@@ -49,6 +50,11 @@ export type RootCreateJsonOptions = Omit<RootWriteJsonOptions, "overwrite">;
|
|
|
49
50
|
export type RootAppendOptions = RootWriteOptions & {
|
|
50
51
|
prependNewlineIfNeeded?: boolean;
|
|
51
52
|
};
|
|
53
|
+
export type RootMoveOptions = Pick<RootDefaults, "denyMutations"> & {
|
|
54
|
+
overwrite?: boolean;
|
|
55
|
+
};
|
|
56
|
+
export type RootRemoveOptions = Pick<RootDefaults, "denyMutations">;
|
|
57
|
+
export type RootMkdirOptions = Pick<RootDefaults, "denyMutations">;
|
|
52
58
|
export declare const DEFAULT_ROOT_MAX_BYTES: number;
|
|
53
59
|
export interface Root {
|
|
54
60
|
readonly rootDir: string;
|
|
@@ -69,9 +75,9 @@ export interface Root {
|
|
|
69
75
|
reader(options?: RootReadOptions): (filePath: string) => Promise<Buffer>;
|
|
70
76
|
openWritable(relativePath: string, options?: RootOpenWritableOptions): Promise<WritableOpenResult>;
|
|
71
77
|
append(relativePath: string, data: string | Buffer, options?: RootAppendOptions): Promise<void>;
|
|
72
|
-
remove(relativePath: string): Promise<void>;
|
|
73
|
-
mkdir(relativePath: string): Promise<void>;
|
|
74
|
-
ensureRoot(): Promise<void>;
|
|
78
|
+
remove(relativePath: string, options?: RootRemoveOptions): Promise<void>;
|
|
79
|
+
mkdir(relativePath: string, options?: RootMkdirOptions): Promise<void>;
|
|
80
|
+
ensureRoot(options?: RootMkdirOptions): Promise<void>;
|
|
75
81
|
write(relativePath: string, data: string | Buffer, options?: RootWriteOptions): Promise<void>;
|
|
76
82
|
create(relativePath: string, data: string | Buffer, options?: RootCreateOptions): Promise<void>;
|
|
77
83
|
writeJson(relativePath: string, data: unknown, options?: RootWriteJsonOptions): Promise<void>;
|
|
@@ -85,9 +91,7 @@ export interface Root {
|
|
|
85
91
|
list(relativePath: string, options: {
|
|
86
92
|
withFileTypes: true;
|
|
87
93
|
}): Promise<DirEntry[]>;
|
|
88
|
-
move(fromRelative: string, toRelative: string, options?:
|
|
89
|
-
overwrite?: boolean;
|
|
90
|
-
}): Promise<void>;
|
|
94
|
+
move(fromRelative: string, toRelative: string, options?: RootMoveOptions): Promise<void>;
|
|
91
95
|
}
|
|
92
96
|
export declare function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
|
|
93
97
|
export declare function readLocalFileSafely(params: {
|
|
@@ -104,5 +108,4 @@ export type WritableOpenResult = {
|
|
|
104
108
|
stat: Stats;
|
|
105
109
|
[Symbol.asyncDispose](): Promise<void>;
|
|
106
110
|
};
|
|
107
|
-
export declare function resolveOpenedFileRealPathForHandle(handle: FileHandle, ioPath: string): Promise<string>;
|
|
108
111
|
//# sourceMappingURL=root-impl.d.ts.map
|
package/dist/root-impl.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"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"}
|
package/dist/root-impl.js
CHANGED
|
@@ -9,11 +9,14 @@ import { FsSafeError } from "./errors.js";
|
|
|
9
9
|
import { sameFileIdentity } from "./file-identity.js";
|
|
10
10
|
import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
|
|
11
11
|
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
12
|
+
import { assertMutationNotDenied, mergeDenyMutationPolicies, } from "./deny-mutations.js";
|
|
13
|
+
import { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
|
|
12
14
|
import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js";
|
|
13
15
|
import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js";
|
|
14
16
|
import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
|
|
15
17
|
import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
|
|
16
18
|
import { assertNoNulPathInput, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
|
|
19
|
+
import { readOpenedFileSafely } from "./read-opened-file.js";
|
|
17
20
|
import { helperReaddir, helperStat, runPinnedHelper, } from "./pinned-helper.js";
|
|
18
21
|
import { pathStatFromStats } from "./path-stat.js";
|
|
19
22
|
import { resolveRootPath } from "./root-path.js";
|
|
@@ -23,6 +26,7 @@ import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
|
23
26
|
import { stringifyJsonDocument } from "./json-stringify.js";
|
|
24
27
|
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
25
28
|
import { serializePathWrite } from "./write-queue.js";
|
|
29
|
+
export { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
|
|
26
30
|
function logWarn(message) {
|
|
27
31
|
if (process.env.FS_SAFE_DEBUG_WARNINGS === "1") {
|
|
28
32
|
console.warn(message);
|
|
@@ -216,6 +220,7 @@ class RootHandle {
|
|
|
216
220
|
mkdir: this.defaults.mkdir,
|
|
217
221
|
mode: this.defaults.mode,
|
|
218
222
|
...options,
|
|
223
|
+
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
219
224
|
append: writeMode === "append",
|
|
220
225
|
truncateExisting: writeMode === "replace",
|
|
221
226
|
});
|
|
@@ -227,18 +232,29 @@ class RootHandle {
|
|
|
227
232
|
mkdir: this.defaults.mkdir,
|
|
228
233
|
mode: this.defaults.mode,
|
|
229
234
|
...options,
|
|
235
|
+
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
230
236
|
});
|
|
231
237
|
}
|
|
232
|
-
async remove(relativePath) {
|
|
238
|
+
async remove(relativePath, options = {}) {
|
|
233
239
|
assertValidRootRelativePath(relativePath);
|
|
234
|
-
await removePathInRoot(this.context,
|
|
240
|
+
await removePathInRoot(this.context, {
|
|
241
|
+
relativePath,
|
|
242
|
+
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
243
|
+
});
|
|
235
244
|
}
|
|
236
|
-
async mkdir(relativePath) {
|
|
245
|
+
async mkdir(relativePath, options = {}) {
|
|
237
246
|
assertValidRootRelativePath(relativePath);
|
|
238
|
-
await mkdirPathInRoot(this.context, {
|
|
247
|
+
await mkdirPathInRoot(this.context, {
|
|
248
|
+
relativePath,
|
|
249
|
+
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
250
|
+
});
|
|
239
251
|
}
|
|
240
|
-
async ensureRoot() {
|
|
241
|
-
await mkdirPathInRoot(this.context, {
|
|
252
|
+
async ensureRoot(options = {}) {
|
|
253
|
+
await mkdirPathInRoot(this.context, {
|
|
254
|
+
relativePath: "",
|
|
255
|
+
allowRoot: true,
|
|
256
|
+
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
257
|
+
});
|
|
242
258
|
}
|
|
243
259
|
async write(relativePath, data, options = {}) {
|
|
244
260
|
await writeFileInRoot(this.context, {
|
|
@@ -247,6 +263,7 @@ class RootHandle {
|
|
|
247
263
|
mkdir: this.defaults.mkdir,
|
|
248
264
|
mode: this.defaults.mode,
|
|
249
265
|
...options,
|
|
266
|
+
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
250
267
|
});
|
|
251
268
|
}
|
|
252
269
|
async create(relativePath, data, options = {}) {
|
|
@@ -256,6 +273,7 @@ class RootHandle {
|
|
|
256
273
|
mkdir: this.defaults.mkdir,
|
|
257
274
|
mode: this.defaults.mode,
|
|
258
275
|
...options,
|
|
276
|
+
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
259
277
|
overwrite: false,
|
|
260
278
|
});
|
|
261
279
|
}
|
|
@@ -278,6 +296,7 @@ class RootHandle {
|
|
|
278
296
|
mkdir: this.defaults.mkdir,
|
|
279
297
|
mode: this.defaults.mode,
|
|
280
298
|
...options,
|
|
299
|
+
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
281
300
|
});
|
|
282
301
|
}
|
|
283
302
|
async exists(relativePath) {
|
|
@@ -321,6 +340,12 @@ class RootHandle {
|
|
|
321
340
|
async move(fromRelative, toRelative, options = {}) {
|
|
322
341
|
assertValidRootRelativePath(fromRelative);
|
|
323
342
|
assertValidRootRelativePath(toRelative);
|
|
343
|
+
const denyMutations = mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations);
|
|
344
|
+
await assertMoveMutationAllowed(this.context, {
|
|
345
|
+
fromRelative,
|
|
346
|
+
toRelative,
|
|
347
|
+
denyMutations,
|
|
348
|
+
});
|
|
324
349
|
try {
|
|
325
350
|
await runPinnedHelper("rename", this.rootReal, {
|
|
326
351
|
from: fromRelative,
|
|
@@ -332,6 +357,7 @@ class RootHandle {
|
|
|
332
357
|
if (canFallbackFromPythonError(error)) {
|
|
333
358
|
await movePathFallback(this.context, {
|
|
334
359
|
fromRelative,
|
|
360
|
+
denyMutations,
|
|
335
361
|
overwrite: options.overwrite ?? false,
|
|
336
362
|
toRelative,
|
|
337
363
|
});
|
|
@@ -413,20 +439,6 @@ export async function openLocalFileSafely(params) {
|
|
|
413
439
|
assertNoNulPathInput(params.filePath, "file path contains a NUL byte");
|
|
414
440
|
return await openVerifiedLocalFile(params.filePath);
|
|
415
441
|
}
|
|
416
|
-
async function readOpenedFileSafely(params) {
|
|
417
|
-
if (params.maxBytes !== undefined && params.opened.stat.size > params.maxBytes) {
|
|
418
|
-
throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${params.opened.stat.size})`);
|
|
419
|
-
}
|
|
420
|
-
const buffer = await params.opened.handle.readFile();
|
|
421
|
-
if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) {
|
|
422
|
-
throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${buffer.byteLength})`);
|
|
423
|
-
}
|
|
424
|
-
return {
|
|
425
|
-
buffer,
|
|
426
|
-
realPath: params.opened.realPath,
|
|
427
|
-
stat: params.opened.stat,
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
442
|
function emitWriteBoundaryWarning(reason) {
|
|
431
443
|
logWarn(`security: fs-safe write boundary warning (${reason})`);
|
|
432
444
|
}
|
|
@@ -467,82 +479,9 @@ async function verifyAtomicWriteResult(params) {
|
|
|
467
479
|
await opened.handle.close().catch(() => { });
|
|
468
480
|
}
|
|
469
481
|
}
|
|
470
|
-
export async function resolveOpenedFileRealPathForHandle(handle, ioPath) {
|
|
471
|
-
const handleStat = await handle.stat();
|
|
472
|
-
const fdCandidates = process.platform === "linux"
|
|
473
|
-
? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
|
|
474
|
-
: process.platform === "win32"
|
|
475
|
-
? []
|
|
476
|
-
: [`/dev/fd/${handle.fd}`];
|
|
477
|
-
for (const fdPath of fdCandidates) {
|
|
478
|
-
try {
|
|
479
|
-
const fdRealPath = await fs.realpath(fdPath);
|
|
480
|
-
const fdRealStat = await fs.stat(fdRealPath);
|
|
481
|
-
if (sameFileIdentity(handleStat, fdRealStat)) {
|
|
482
|
-
return fdRealPath;
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
catch {
|
|
486
|
-
// try next fd path
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
try {
|
|
490
|
-
const ioRealPath = await fs.realpath(ioPath);
|
|
491
|
-
const ioRealStat = await fs.stat(ioRealPath);
|
|
492
|
-
if (sameFileIdentity(handleStat, ioRealStat)) {
|
|
493
|
-
return ioRealPath;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
catch (err) {
|
|
497
|
-
if (!isNotFoundPathError(err)) {
|
|
498
|
-
throw err;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
const parentResolved = await resolveOpenedFileRealPathFromParent(handleStat, ioPath);
|
|
502
|
-
if (parentResolved) {
|
|
503
|
-
return parentResolved;
|
|
504
|
-
}
|
|
505
|
-
throw new FsSafeError("path-mismatch", "unable to resolve opened file path");
|
|
506
|
-
}
|
|
507
|
-
async function resolveOpenedFileRealPathFromParent(handleStat, ioPath) {
|
|
508
|
-
let parentReal;
|
|
509
|
-
try {
|
|
510
|
-
parentReal = await fs.realpath(path.dirname(ioPath));
|
|
511
|
-
}
|
|
512
|
-
catch (err) {
|
|
513
|
-
if (isNotFoundPathError(err)) {
|
|
514
|
-
return null;
|
|
515
|
-
}
|
|
516
|
-
throw err;
|
|
517
|
-
}
|
|
518
|
-
let entries;
|
|
519
|
-
try {
|
|
520
|
-
entries = await fs.readdir(parentReal);
|
|
521
|
-
}
|
|
522
|
-
catch (err) {
|
|
523
|
-
if (isNotFoundPathError(err)) {
|
|
524
|
-
return null;
|
|
525
|
-
}
|
|
526
|
-
throw err;
|
|
527
|
-
}
|
|
528
|
-
for (const entry of entries.toSorted()) {
|
|
529
|
-
const candidatePath = path.join(parentReal, entry);
|
|
530
|
-
try {
|
|
531
|
-
const candidateStat = await fs.lstat(candidatePath);
|
|
532
|
-
if (candidateStat.isFile() && sameFileIdentity(handleStat, candidateStat)) {
|
|
533
|
-
return await fs.realpath(candidatePath);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
catch (err) {
|
|
537
|
-
if (!isNotFoundPathError(err)) {
|
|
538
|
-
throw err;
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
return null;
|
|
543
|
-
}
|
|
544
482
|
async function openWritableFileInRoot(root, params) {
|
|
545
483
|
const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath);
|
|
484
|
+
await assertMutationNotDenied(resolved, params.denyMutations);
|
|
546
485
|
try {
|
|
547
486
|
await assertNoPathAliasEscape({
|
|
548
487
|
absolutePath: resolved,
|
|
@@ -669,6 +608,7 @@ async function appendFileInRoot(root, params) {
|
|
|
669
608
|
relativePath: params.relativePath,
|
|
670
609
|
mkdir: params.mkdir,
|
|
671
610
|
mode: params.mode,
|
|
611
|
+
denyMutations: params.denyMutations,
|
|
672
612
|
truncateExisting: false,
|
|
673
613
|
append: true,
|
|
674
614
|
});
|
|
@@ -696,8 +636,8 @@ async function appendFileInRoot(root, params) {
|
|
|
696
636
|
await target.handle.close().catch(() => { });
|
|
697
637
|
}
|
|
698
638
|
}
|
|
699
|
-
async function removePathInRoot(root,
|
|
700
|
-
const resolved = await resolvePinnedRemovePathInRoot(root, relativePath);
|
|
639
|
+
async function removePathInRoot(root, params) {
|
|
640
|
+
const resolved = await resolvePinnedRemovePathInRoot(root, params.relativePath, params.denyMutations);
|
|
701
641
|
if (process.platform === "win32") {
|
|
702
642
|
await removePathFallback(resolved);
|
|
703
643
|
return;
|
|
@@ -745,7 +685,7 @@ async function writeFileInRoot(root, params) {
|
|
|
745
685
|
});
|
|
746
686
|
return;
|
|
747
687
|
}
|
|
748
|
-
const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode);
|
|
688
|
+
const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
|
|
749
689
|
await serializePathWrite(pinned.targetPath, async () => {
|
|
750
690
|
let identity;
|
|
751
691
|
try {
|
|
@@ -801,7 +741,7 @@ async function copyFileInRoot(root, params) {
|
|
|
801
741
|
});
|
|
802
742
|
return;
|
|
803
743
|
}
|
|
804
|
-
const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode);
|
|
744
|
+
const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
|
|
805
745
|
await serializePathWrite(pinned.targetPath, async () => {
|
|
806
746
|
let identity;
|
|
807
747
|
try {
|
|
@@ -845,8 +785,9 @@ async function copyFileInRoot(root, params) {
|
|
|
845
785
|
await source.handle.close().catch(() => { });
|
|
846
786
|
}
|
|
847
787
|
}
|
|
848
|
-
async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode) {
|
|
788
|
+
async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode, denyMutations) {
|
|
849
789
|
const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath);
|
|
790
|
+
await assertMutationNotDenied(resolved, denyMutations);
|
|
850
791
|
try {
|
|
851
792
|
await assertNoPathAliasEscape({
|
|
852
793
|
absolutePath: resolved,
|
|
@@ -903,12 +844,16 @@ async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode)
|
|
|
903
844
|
async function resolvePinnedPathInRoot(root, params) {
|
|
904
845
|
return await resolvePinnedOperationPathInRoot(root, {
|
|
905
846
|
allowRoot: params.allowRoot,
|
|
847
|
+
denyMutations: params.denyMutations,
|
|
848
|
+
protectDenyMutationAncestors: false,
|
|
906
849
|
relativePath: params.relativePath,
|
|
907
850
|
policy: PATH_ALIAS_POLICIES.strict,
|
|
908
851
|
});
|
|
909
852
|
}
|
|
910
|
-
async function resolvePinnedRemovePathInRoot(root, relativePath) {
|
|
853
|
+
async function resolvePinnedRemovePathInRoot(root, relativePath, denyMutations) {
|
|
911
854
|
return await resolvePinnedOperationPathInRoot(root, {
|
|
855
|
+
denyMutations,
|
|
856
|
+
protectDenyMutationAncestors: true,
|
|
912
857
|
relativePath,
|
|
913
858
|
policy: PATH_ALIAS_POLICIES.unlinkTarget,
|
|
914
859
|
});
|
|
@@ -920,6 +865,7 @@ async function resolvePinnedOperationPathInRoot(root, params) {
|
|
|
920
865
|
});
|
|
921
866
|
const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
|
|
922
867
|
if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
|
|
868
|
+
await assertMutationNotDenied(resolved.canonicalPath, params.denyMutations);
|
|
923
869
|
return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
|
|
924
870
|
}
|
|
925
871
|
const firstSegment = relativeResolved.split(path.sep)[0];
|
|
@@ -933,6 +879,9 @@ async function resolvePinnedOperationPathInRoot(root, params) {
|
|
|
933
879
|
if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
|
|
934
880
|
throw new FsSafeError("outside-workspace", "file is outside workspace root");
|
|
935
881
|
}
|
|
882
|
+
await assertMutationNotDenied(resolved.canonicalPath, params.denyMutations, {
|
|
883
|
+
protectAncestors: params.protectDenyMutationAncestors,
|
|
884
|
+
});
|
|
936
885
|
return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
|
|
937
886
|
}
|
|
938
887
|
async function resolvePinnedRootPathInRoot(root, params) {
|
|
@@ -1010,13 +959,21 @@ async function listPathFallback(root, relativePath, withFileTypes) {
|
|
|
1010
959
|
throw error;
|
|
1011
960
|
}
|
|
1012
961
|
}
|
|
962
|
+
async function assertMoveMutationAllowed(root, params) {
|
|
963
|
+
const source = await resolvePathInRoot(root, params.fromRelative);
|
|
964
|
+
await assertMutationNotDenied(source.resolved, params.denyMutations, { protectAncestors: true });
|
|
965
|
+
const target = await resolvePathInRoot(root, params.toRelative);
|
|
966
|
+
await assertMutationNotDenied(target.resolved, params.denyMutations, { protectAncestors: true });
|
|
967
|
+
}
|
|
1013
968
|
async function movePathFallback(root, params) {
|
|
1014
969
|
const source = await resolvePathInRoot(root, params.fromRelative);
|
|
970
|
+
await assertMutationNotDenied(source.resolved, params.denyMutations, { protectAncestors: true });
|
|
1015
971
|
await resolvePinnedRootPathInRoot(root, {
|
|
1016
972
|
relativePath: params.fromRelative,
|
|
1017
973
|
policy: PATH_ALIAS_POLICIES.strict,
|
|
1018
974
|
});
|
|
1019
975
|
const target = await resolvePathInRoot(root, params.toRelative);
|
|
976
|
+
await assertMutationNotDenied(target.resolved, params.denyMutations, { protectAncestors: true });
|
|
1020
977
|
await resolvePinnedRootPathInRoot(root, {
|
|
1021
978
|
relativePath: params.toRelative,
|
|
1022
979
|
policy: PATH_ALIAS_POLICIES.unlinkTarget,
|
|
@@ -1100,6 +1057,7 @@ async function writeFileFallback(root, params) {
|
|
|
1100
1057
|
relativePath: params.relativePath,
|
|
1101
1058
|
mkdir: params.mkdir,
|
|
1102
1059
|
mode: params.mode,
|
|
1060
|
+
denyMutations: params.denyMutations,
|
|
1103
1061
|
truncateExisting: false,
|
|
1104
1062
|
});
|
|
1105
1063
|
const destinationPath = target.realPath;
|
|
@@ -1145,6 +1103,7 @@ async function writeFileFallback(root, params) {
|
|
|
1145
1103
|
}
|
|
1146
1104
|
async function writeMissingFileFallback(root, params) {
|
|
1147
1105
|
const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath);
|
|
1106
|
+
await assertMutationNotDenied(resolved, params.denyMutations);
|
|
1148
1107
|
try {
|
|
1149
1108
|
await assertNoPathAliasEscape({
|
|
1150
1109
|
absolutePath: resolved,
|
|
@@ -1218,6 +1177,7 @@ async function copyFileFallback(root, params, source) {
|
|
|
1218
1177
|
relativePath: params.relativePath,
|
|
1219
1178
|
mkdir: params.mkdir,
|
|
1220
1179
|
mode: params.mode,
|
|
1180
|
+
denyMutations: params.denyMutations,
|
|
1221
1181
|
truncateExisting: false,
|
|
1222
1182
|
});
|
|
1223
1183
|
const destinationPath = target.realPath;
|
package/dist/root.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { DEFAULT_ROOT_MAX_BYTES, openLocalFileSafely, readLocalFileSafely, resolveOpenedFileRealPathForHandle, root, type HardlinkPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root-impl.js";
|
|
1
|
+
export { DEFAULT_ROOT_MAX_BYTES, openLocalFileSafely, readLocalFileSafely, resolveOpenedFileRealPathForHandle, 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-impl.js";
|
|
2
2
|
//# sourceMappingURL=root.d.ts.map
|
package/dist/root.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"root.d.ts","sourceRoot":"","sources":["../src/root.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,kCAAkC,EAClC,IAAI,EACJ,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,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,gBAAgB,CAAC"}
|
|
1
|
+
{"version":3,"file":"root.d.ts","sourceRoot":"","sources":["../src/root.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,kCAAkC,EAClC,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,gBAAgB,CAAC"}
|
package/docs/errors.md
CHANGED
|
@@ -30,6 +30,7 @@ class FsSafeError extends Error {
|
|
|
30
30
|
```ts
|
|
31
31
|
type FsSafeErrorCode =
|
|
32
32
|
| "already-exists"
|
|
33
|
+
| "denied-path"
|
|
33
34
|
| "hardlink"
|
|
34
35
|
| "helper-failed"
|
|
35
36
|
| "helper-unavailable"
|
|
@@ -55,6 +56,7 @@ type FsSafeErrorCode =
|
|
|
55
56
|
| Code | When it fires | Common causes |
|
|
56
57
|
|---|---|---|
|
|
57
58
|
| `already-exists` | `create()`, `createJson()`, `move({ overwrite: false })`. | Target file or directory already at the destination. |
|
|
59
|
+
| `denied-path` | A root mutation matched `denyMutations.paths` or `denyMutations.prefixes`. | Caller configured application-sensitive paths that must not be written, removed, moved, or created. |
|
|
58
60
|
| `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. |
|
|
59
61
|
| `helper-failed` | Internal POSIX helper failed after startup. | Inspect `cause`; retrying may be unsafe if the operation may have partially completed. |
|
|
60
62
|
| `helper-unavailable` | Persistent Python helper was disabled or could not be spawned. | `FS_SAFE_PYTHON_MODE=off`, Python missing in PATH, restricted sandbox. `auto` falls back where possible; `require` fails closed. |
|
package/docs/root.md
CHANGED
|
@@ -19,17 +19,23 @@ function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
|
|
|
19
19
|
|
|
20
20
|
type RootDefaults = {
|
|
21
21
|
hardlinks?: "reject" | "allow"; // refuse files with nlink > 1 on read; defaults to "reject"
|
|
22
|
+
denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
|
|
22
23
|
maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
|
|
23
24
|
mkdir?: boolean; // create missing parent dirs on write/openWritable/append
|
|
24
25
|
mode?: number; // file mode applied to new writes; per-call override available
|
|
25
26
|
nonBlockingRead?: boolean; // schedule reads on a worker; useful for large files
|
|
26
27
|
symlinks?: "reject" | "follow-within-root"; // policy when a path component is a symlink
|
|
27
28
|
};
|
|
29
|
+
|
|
30
|
+
type DenyMutationPolicy = {
|
|
31
|
+
paths?: readonly string[];
|
|
32
|
+
prefixes?: readonly string[];
|
|
33
|
+
};
|
|
28
34
|
```
|
|
29
35
|
|
|
30
36
|
`root()` resolves the directory through the real filesystem. A symlinked input becomes the canonical path; a non-existent root throws `FsSafeError` with code `not-found`, and malformed or non-directory roots throw `invalid-path`.
|
|
31
37
|
|
|
32
|
-
`defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only.
|
|
38
|
+
`defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only, except `denyMutations`: root and per-call deny entries are merged so a call cannot clear a root-level deny.
|
|
33
39
|
|
|
34
40
|
## The `Root` interface
|
|
35
41
|
|
|
@@ -69,9 +75,9 @@ fs.append(rel, data, options?) // append text/buffer; respects mkdir d
|
|
|
69
75
|
fs.copyIn(rel, sourceAbsPath, options?) // copy from outside the root, atomically, with size cap
|
|
70
76
|
fs.openWritable(rel, options?) // FileHandle for streaming writes; supports await using
|
|
71
77
|
fs.move(from, to, options?) // rename within the root; defaults to no clobber
|
|
72
|
-
fs.remove(rel)
|
|
73
|
-
fs.mkdir(rel)
|
|
74
|
-
fs.ensureRoot()
|
|
78
|
+
fs.remove(rel, options?) // unlink file or rmdir empty directory
|
|
79
|
+
fs.mkdir(rel, options?) // mkdir -p (creates missing parents)
|
|
80
|
+
fs.ensureRoot(options?) // accepts "" / "." as the root itself
|
|
75
81
|
```
|
|
76
82
|
|
|
77
83
|
`write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
|
|
@@ -80,6 +86,8 @@ fs.ensureRoot() // accepts "" / "." as the root itself
|
|
|
80
86
|
|
|
81
87
|
`openWritable` opens a writable file with options `mode?: number` and `writeMode?: "replace" | "append" | "update"`. `replace` truncates existing files and is the default; `update` keeps existing contents. Use it for streaming output. Prefer `await using` for cleanup.
|
|
82
88
|
|
|
89
|
+
All mutation methods accept `denyMutations?: { paths?: string[]; prefixes?: string[] }`. Entries must be absolute paths. `paths` blocks those exact paths; `prefixes` blocks those paths and their descendants. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a symlinked ancestor to a denied location is still denied. Denied mutations throw `FsSafeError` with code `denied-path`. Use this for caller-specific sensitive paths, not as a replacement for the root boundary, symlink, or hardlink checks.
|
|
90
|
+
|
|
83
91
|
### Inspection (advisory)
|
|
84
92
|
|
|
85
93
|
```ts
|
|
@@ -132,6 +140,7 @@ Every method throws `FsSafeError` with a `code`. Branch on `err.code`, not messa
|
|
|
132
140
|
| `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). |
|
|
133
141
|
| `not-file` | A read or copy targeted a non-regular file (directory, FIFO, socket, …). |
|
|
134
142
|
| `already-exists` | `create()` or `move()` without `overwrite` hit an existing target. |
|
|
143
|
+
| `denied-path` | A mutation target matched `denyMutations.paths` or `denyMutations.prefixes`. |
|
|
135
144
|
| `symlink` | A path component is a symlink, and the call's `symlinks` policy is `reject`. |
|
|
136
145
|
| `hardlink` | The target's `nlink > 1` and `hardlinks` policy is `reject`. |
|
|
137
146
|
| `path-mismatch` | Post-open identity check failed — the opened fd does not match the resolved path. |
|
package/docs/security-model.md
CHANGED
|
@@ -50,6 +50,10 @@ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink >
|
|
|
50
50
|
|
|
51
51
|
`resolve()`, `exists()`, `stat()`, and `list()` are explicitly **not** race-resistant — they answer a question and return. To act on a path with race resistance, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. They re-pin the path identity at the point of use.
|
|
52
52
|
|
|
53
|
+
### Denied mutations
|
|
54
|
+
|
|
55
|
+
`denyMutations` is an opt-in application policy for `root()` mutation methods. It blocks exact absolute paths with `paths` and whole subtrees with `prefixes`, merging root defaults with per-call entries so a call cannot clear root-level denies. This is not an OS permission boundary: code with access to `node:fs`, a shell, or another process with the same filesystem privileges can bypass it.
|
|
56
|
+
|
|
53
57
|
### Atomic writes
|
|
54
58
|
|
|
55
59
|
`replaceFileAtomic` writes to a sibling temp file in the destination directory, optionally `fsync`s it, optionally `fsync`s the parent directory after rename, and atomically renames over the destination. On failure mid-write, the destination is either the old contents (rename never happened) or the new contents (rename succeeded). There is no half-written intermediate state visible at the destination path.
|
package/docs/types.md
CHANGED
|
@@ -82,6 +82,7 @@ type ReadResult = {
|
|
|
82
82
|
|
|
83
83
|
```ts
|
|
84
84
|
type RootDefaults = {
|
|
85
|
+
denyMutations?: DenyMutationPolicy;
|
|
85
86
|
hardlinks?: "reject" | "allow";
|
|
86
87
|
maxBytes?: number;
|
|
87
88
|
mkdir?: boolean;
|
|
@@ -90,26 +91,31 @@ type RootDefaults = {
|
|
|
90
91
|
symlinks?: "reject" | "follow-within-root";
|
|
91
92
|
};
|
|
92
93
|
|
|
94
|
+
type DenyMutationPolicy = {
|
|
95
|
+
paths?: readonly string[];
|
|
96
|
+
prefixes?: readonly string[];
|
|
97
|
+
};
|
|
98
|
+
|
|
93
99
|
type RootOptions = {
|
|
94
100
|
rootDir: string;
|
|
95
101
|
defaults?: RootDefaults;
|
|
96
102
|
};
|
|
97
103
|
```
|
|
98
104
|
|
|
99
|
-
`RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these.
|
|
105
|
+
`RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these. `denyMutations` is the exception: root and per-call deny entries are merged.
|
|
100
106
|
|
|
101
107
|
## `RootReadOptions` / `RootWriteOptions` / `RootCopyOptions`
|
|
102
108
|
|
|
103
109
|
```ts
|
|
104
110
|
type RootReadOptions = Pick<RootDefaults, "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks">;
|
|
105
|
-
type RootWriteOptions = Pick<RootDefaults, "mkdir" | "mode"> & {
|
|
111
|
+
type RootWriteOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
|
|
106
112
|
encoding?: BufferEncoding;
|
|
107
113
|
overwrite?: boolean;
|
|
108
114
|
};
|
|
109
|
-
type RootCopyOptions = Pick<RootDefaults, "maxBytes" | "mkdir" | "mode"> & {
|
|
115
|
+
type RootCopyOptions = Pick<RootDefaults, "denyMutations" | "maxBytes" | "mkdir" | "mode"> & {
|
|
110
116
|
sourceHardlinks?: "reject" | "allow";
|
|
111
117
|
};
|
|
112
|
-
type RootOpenWritableOptions = Pick<RootDefaults, "mkdir" | "mode"> & {
|
|
118
|
+
type RootOpenWritableOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
|
|
113
119
|
writeMode?: "replace" | "append" | "update";
|
|
114
120
|
};
|
|
115
121
|
type RootWriteJsonOptions = RootWriteOptions & {
|
|
@@ -120,6 +126,11 @@ type RootWriteJsonOptions = RootWriteOptions & {
|
|
|
120
126
|
type RootAppendOptions = RootWriteOptions & {
|
|
121
127
|
prependNewlineIfNeeded?: boolean;
|
|
122
128
|
};
|
|
129
|
+
type RootMoveOptions = Pick<RootDefaults, "denyMutations"> & {
|
|
130
|
+
overwrite?: boolean;
|
|
131
|
+
};
|
|
132
|
+
type RootRemoveOptions = Pick<RootDefaults, "denyMutations">;
|
|
133
|
+
type RootMkdirOptions = Pick<RootDefaults, "denyMutations">;
|
|
123
134
|
```
|
|
124
135
|
|
|
125
136
|
Per-method option shapes. Each picks the `RootDefaults` keys that apply, plus method-specific extras.
|
|
@@ -137,11 +148,12 @@ The two policy unions you'll see throughout. `"reject"` is conservative; `"follo
|
|
|
137
148
|
|
|
138
149
|
```ts
|
|
139
150
|
type FsSafeErrorCode =
|
|
140
|
-
| "already-exists" | "
|
|
141
|
-
| "
|
|
142
|
-
| "not-
|
|
143
|
-
| "
|
|
144
|
-
| "
|
|
151
|
+
| "already-exists" | "denied-path" | "hardlink" | "helper-failed"
|
|
152
|
+
| "helper-unavailable" | "insecure-permissions" | "invalid-path"
|
|
153
|
+
| "not-empty" | "not-file" | "not-found" | "not-owned"
|
|
154
|
+
| "not-removable" | "outside-workspace" | "path-alias"
|
|
155
|
+
| "path-mismatch" | "permission-unverified" | "symlink"
|
|
156
|
+
| "timeout" | "too-large" | "unsupported-platform";
|
|
145
157
|
```
|
|
146
158
|
|
|
147
159
|
Closed union you switch on. See the [Errors](errors.md) reference for what each one means.
|
package/docs/writing.md
CHANGED
|
@@ -24,6 +24,24 @@ await fs.mkdir("snapshots/2026/05");
|
|
|
24
24
|
|
|
25
25
|
A failure at any point either leaves the destination at its previous contents or surfaces an `FsSafeError` — never a partially-written file at the destination path.
|
|
26
26
|
|
|
27
|
+
## Denying mutations
|
|
28
|
+
|
|
29
|
+
All mutation verbs accept `denyMutations?: DenyMutationPolicy`, either as a root default or per-call option:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const fs = await root("/srv/workspace", {
|
|
33
|
+
denyMutations: {
|
|
34
|
+
paths: ["/srv/workspace/.env"],
|
|
35
|
+
prefixes: ["/srv/workspace/.ssh"],
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await fs.write(".env", "x"); // throws FsSafeError code "denied-path"
|
|
40
|
+
await fs.remove(".ssh/id_rsa"); // throws FsSafeError code "denied-path"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`paths` blocks exact absolute paths. `prefixes` blocks absolute paths and everything below them. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a mutation through a symlinked ancestor to a denied path is still denied. Root-level and per-call policies are additive; per-call policy can add denies, but cannot clear root defaults.
|
|
44
|
+
|
|
27
45
|
## Write verbs
|
|
28
46
|
|
|
29
47
|
### `fs.write(rel, data, options?)`
|
|
@@ -35,7 +53,7 @@ await fs.write("state/last-run.json", JSON.stringify(run));
|
|
|
35
53
|
await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });
|
|
36
54
|
```
|
|
37
55
|
|
|
38
|
-
`data` accepts `string | Buffer`. `options` are `{ encoding?: BufferEncoding; mkdir?: boolean; mode?: number; overwrite?: boolean }`. `mode` sets the file's POSIX mode; if omitted, falls back to the `mode` from `RootDefaults` and then to umask. `overwrite` defaults to `true`; set it to `false` for the same no-clobber behavior as `create()`.
|
|
56
|
+
`data` accepts `string | Buffer`. `options` are `{ denyMutations?: DenyMutationPolicy; encoding?: BufferEncoding; mkdir?: boolean; mode?: number; overwrite?: boolean }`. `mode` sets the file's POSIX mode; if omitted, falls back to the `mode` from `RootDefaults` and then to umask. `overwrite` defaults to `true`; set it to `false` for the same no-clobber behavior as `create()`.
|
|
39
57
|
|
|
40
58
|
### `fs.create(rel, data, options?)`
|
|
41
59
|
|