@openclaw/fs-safe 0.2.6 → 0.2.7
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 +7 -0
- package/README.md +9 -6
- package/dist/archive-deadline.d.ts +9 -0
- package/dist/archive-deadline.d.ts.map +1 -0
- package/dist/archive-deadline.js +67 -0
- package/dist/archive-file-io.d.ts +9 -0
- package/dist/archive-file-io.d.ts.map +1 -0
- package/dist/archive-file-io.js +11 -0
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +43 -3
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +213 -105
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +5 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +1 -0
- package/dist/pinned-write.js +3 -9
- package/dist/root-paths.d.ts.map +1 -1
- package/dist/root-paths.js +16 -5
- package/dist/secret-file.d.ts +1 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +23 -3
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +9 -3
- package/dist/symlink-parents.js +2 -2
- package/docs/python-helper.md +3 -3
- package/docs/root.md +6 -5
- package/docs/secret-file.md +3 -2
- package/docs/timing.md +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.7 - 2026-05-20
|
|
4
|
+
|
|
5
|
+
### Security and Correctness
|
|
6
|
+
|
|
7
|
+
- Restore best-effort Node write fallbacks when the Python helper is disabled or unavailable, preserving the `mode: "off"` contract while documenting the weaker POSIX same-UID race resistance compared with fd-relative helper commits.
|
|
8
|
+
- Harden DeepSec-reported archive staging and input pinning, secret and queue hardlink rejection, absolute output file validation, sidecar lock read failures, ClawSweeper dispatch trust checks, and scoped path defaults.
|
|
9
|
+
|
|
3
10
|
## 0.2.6 - 2026-05-17
|
|
4
11
|
|
|
5
12
|
### Security and Correctness
|
package/README.md
CHANGED
|
@@ -66,15 +66,18 @@ before first use when you need a strict environment policy:
|
|
|
66
66
|
import { configureFsSafePython } from "@openclaw/fs-safe";
|
|
67
67
|
|
|
68
68
|
configureFsSafePython({ mode: "auto" }); // default: use helper, fall back if unavailable
|
|
69
|
-
configureFsSafePython({ mode: "off" }); // never spawn Python;
|
|
69
|
+
configureFsSafePython({ mode: "off" }); // never spawn Python; use best-effort Node fallbacks
|
|
70
70
|
configureFsSafePython({ mode: "require" }); // fail closed if helper cannot start
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
Equivalent env vars: `FS_SAFE_PYTHON_MODE=auto|off|require` and
|
|
74
|
-
`FS_SAFE_PYTHON=/path/to/python3`.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
`FS_SAFE_PYTHON=/path/to/python3`. Without Python, `fs-safe` keeps lexical and
|
|
75
|
+
canonical root checks, no-follow opens, atomic temp+rename writes, and
|
|
76
|
+
post-write identity verification. What you lose is the strongest POSIX
|
|
77
|
+
fd-relative protection against a same-process-user racer swapping parent
|
|
78
|
+
directories between validation and mutation. Windows already uses the Node
|
|
79
|
+
fallback path. See the [Python helper policy](docs/python-helper.md) for
|
|
80
|
+
deployment guidance.
|
|
78
81
|
|
|
79
82
|
## Quick start
|
|
80
83
|
|
|
@@ -432,7 +435,7 @@ Current `FsSafeErrorCode` values are `already-exists`, `hardlink`, `helper-faile
|
|
|
432
435
|
- root-bounded APIs resolve paths against a configured root and reject canonical escapes
|
|
433
436
|
- reads open with `O_NOFOLLOW` where available, then verify fd identity matches the path identity before returning the buffer or handle
|
|
434
437
|
- writes use pinned parent-directory helpers and atomic replacement on POSIX, with verified post-write identity
|
|
435
|
-
- `remove`, `mkdir`, `move`, `stat`, `list`, and parent-fd writes use one persistent fd-relative Python helper on POSIX
|
|
438
|
+
- `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
|
|
436
439
|
- archive extraction stages into a private directory and merges through the same boundary checks used by direct writes
|
|
437
440
|
|
|
438
441
|
## Limitations
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ExtractionDeadline = {
|
|
2
|
+
signal: AbortSignal;
|
|
3
|
+
check: () => void;
|
|
4
|
+
dispose: () => void;
|
|
5
|
+
};
|
|
6
|
+
export declare function createPipelineTimeoutError(err: unknown, deadline: ExtractionDeadline): unknown;
|
|
7
|
+
export declare function waitForDeadline<T>(promise: Promise<T>, deadline: ExtractionDeadline): Promise<T>;
|
|
8
|
+
export declare function withExtractionDeadline<T>(timeoutMs: number, label: string, run: (deadline: ExtractionDeadline) => Promise<T>): Promise<T>;
|
|
9
|
+
//# sourceMappingURL=archive-deadline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"archive-deadline.d.ts","sourceRoot":"","sources":["../src/archive-deadline.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AAWF,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE,kBAAkB,GAC3B,OAAO,CAST;AAED,wBAAsB,eAAe,CAAC,CAAC,EACrC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,QAAQ,EAAE,kBAAkB,GAC3B,OAAO,CAAC,CAAC,CAAC,CAgBZ;AA4BD,wBAAsB,sBAAsB,CAAC,CAAC,EAC5C,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,OAAO,CAAC,CAAC,CAAC,GAChD,OAAO,CAAC,CAAC,CAAC,CAQZ"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function signalReason(signal, fallback) {
|
|
2
|
+
const reason = signal.reason;
|
|
3
|
+
return reason instanceof Error ? reason : fallback ?? new Error(String(reason));
|
|
4
|
+
}
|
|
5
|
+
function deadlineReason(deadline) {
|
|
6
|
+
return signalReason(deadline.signal);
|
|
7
|
+
}
|
|
8
|
+
export function createPipelineTimeoutError(err, deadline) {
|
|
9
|
+
if (deadline.signal.aborted &&
|
|
10
|
+
err instanceof Error &&
|
|
11
|
+
(err.name === "AbortError" || err.message === "The operation was aborted")) {
|
|
12
|
+
return deadlineReason(deadline);
|
|
13
|
+
}
|
|
14
|
+
return err;
|
|
15
|
+
}
|
|
16
|
+
export async function waitForDeadline(promise, deadline) {
|
|
17
|
+
deadline.check();
|
|
18
|
+
if (deadline.signal.aborted) {
|
|
19
|
+
throw deadlineReason(deadline);
|
|
20
|
+
}
|
|
21
|
+
return await Promise.race([
|
|
22
|
+
promise,
|
|
23
|
+
new Promise((_, reject) => {
|
|
24
|
+
const abort = () => reject(deadlineReason(deadline));
|
|
25
|
+
deadline.signal.addEventListener("abort", abort, { once: true });
|
|
26
|
+
const cleanup = () => {
|
|
27
|
+
deadline.signal.removeEventListener("abort", abort);
|
|
28
|
+
};
|
|
29
|
+
promise.then(cleanup, cleanup);
|
|
30
|
+
}),
|
|
31
|
+
]);
|
|
32
|
+
}
|
|
33
|
+
function createExtractionDeadline(timeoutMs, label) {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
36
|
+
return {
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
check: () => undefined,
|
|
39
|
+
dispose: () => undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const timeoutError = new Error(`${label} timed out after ${timeoutMs}ms`);
|
|
43
|
+
const timeoutId = setTimeout(() => {
|
|
44
|
+
controller.abort(timeoutError);
|
|
45
|
+
}, timeoutMs);
|
|
46
|
+
return {
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
check: () => {
|
|
49
|
+
if (controller.signal.aborted) {
|
|
50
|
+
throw signalReason(controller.signal, timeoutError);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
dispose: () => {
|
|
54
|
+
clearTimeout(timeoutId);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export async function withExtractionDeadline(timeoutMs, label, run) {
|
|
59
|
+
const deadline = createExtractionDeadline(timeoutMs, label);
|
|
60
|
+
try {
|
|
61
|
+
deadline.check();
|
|
62
|
+
return await waitForDeadline(run(deadline), deadline);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
deadline.dispose();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FileHandle } from "node:fs/promises";
|
|
2
|
+
import type { ExtractionDeadline } from "./archive-deadline.js";
|
|
3
|
+
export declare function writeFileHandleFully(params: {
|
|
4
|
+
handle: FileHandle;
|
|
5
|
+
buffer: Buffer;
|
|
6
|
+
bytes: number;
|
|
7
|
+
deadline: ExtractionDeadline;
|
|
8
|
+
}): Promise<void>;
|
|
9
|
+
//# sourceMappingURL=archive-file-io.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"archive-file-io.d.ts","sourceRoot":"","sources":["../src/archive-file-io.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,MAAM,EAAE,UAAU,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,kBAAkB,CAAC;CAC9B,GAAG,OAAO,CAAC,IAAI,CAAC,CAchB"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export async function writeFileHandleFully(params) {
|
|
2
|
+
let offset = 0;
|
|
3
|
+
while (offset < params.bytes) {
|
|
4
|
+
params.deadline.check();
|
|
5
|
+
const { bytesWritten } = await params.handle.write(params.buffer, offset, params.bytes - offset);
|
|
6
|
+
if (bytesWritten <= 0) {
|
|
7
|
+
throw new Error("archive staging write made no progress");
|
|
8
|
+
}
|
|
9
|
+
offset += bytesWritten;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"archive-staging.d.ts","sourceRoot":"","sources":["../src/archive-staging.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,wBAAwB,GAChC,2BAA2B,GAC3B,qBAAqB,GACrB,+BAA+B,CAAC;AAEpC,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,IAAI,EAAE,wBAAwB,CAAC;gBAEnB,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAKpF;AAkCD,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA4BnF;AA6CD,wBAAsB,wBAAwB,CAAC,MAAM,EAAE;IACrD,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmChB;
|
|
1
|
+
{"version":3,"file":"archive-staging.d.ts","sourceRoot":"","sources":["../src/archive-staging.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,wBAAwB,GAChC,2BAA2B,GAC3B,qBAAqB,GACrB,+BAA+B,CAAC;AAEpC,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,IAAI,EAAE,wBAAwB,CAAC;gBAEnB,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAKpF;AAkCD,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA4BnF;AA6CD,wBAAsB,wBAAwB,CAAC,MAAM,EAAE;IACrD,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmChB;AAyHD,wBAAsB,4BAA4B,CAAC,CAAC,EAAE,MAAM,EAAE;IAC5D,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,GAAG,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;CACzC,GAAG,OAAO,CAAC,CAAC,CAAC,CA6Bb;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;CAC5B,GAAG,OAAO,CAAC,IAAI,CAAC,CAgFhB;AAED,wBAAgB,kCAAkC,CAAC,YAAY,EAAE,MAAM,GAAG,oBAAoB,CAE7F"}
|
package/dist/archive-staging.js
CHANGED
|
@@ -195,6 +195,16 @@ async function assertExtractedFileHasNoHardlinkAlias(params) {
|
|
|
195
195
|
}
|
|
196
196
|
async function removeExtractedDestinationFile(params) {
|
|
197
197
|
const destinationPath = path.join(params.destinationRealDir, params.relPath);
|
|
198
|
+
let stat;
|
|
199
|
+
try {
|
|
200
|
+
stat = await fs.lstat(destinationPath);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!stat.isFile()) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
198
208
|
let resolved;
|
|
199
209
|
try {
|
|
200
210
|
resolved = await fs.realpath(destinationPath);
|
|
@@ -205,7 +215,19 @@ async function removeExtractedDestinationFile(params) {
|
|
|
205
215
|
if (!isPathInside(params.destinationRealDir, resolved)) {
|
|
206
216
|
return;
|
|
207
217
|
}
|
|
208
|
-
|
|
218
|
+
const targetRoot = await root(params.destinationRealDir);
|
|
219
|
+
await targetRoot.remove(params.relPath).catch(() => undefined);
|
|
220
|
+
}
|
|
221
|
+
function assertSafeArchiveStagingPrefix(prefix) {
|
|
222
|
+
if (!prefix ||
|
|
223
|
+
prefix === "." ||
|
|
224
|
+
prefix === ".." ||
|
|
225
|
+
prefix.includes("/") ||
|
|
226
|
+
prefix.includes("\\") ||
|
|
227
|
+
path.basename(prefix) !== prefix) {
|
|
228
|
+
throw new Error("archive staging prefix must be a single path segment");
|
|
229
|
+
}
|
|
230
|
+
return prefix;
|
|
209
231
|
}
|
|
210
232
|
export async function withStagedArchiveDestination(params) {
|
|
211
233
|
const stagingRoot = resolveSecureTempRoot({
|
|
@@ -216,20 +238,34 @@ export async function withStagedArchiveDestination(params) {
|
|
|
216
238
|
if (isPathInside(params.destinationRealDir, stagingRoot)) {
|
|
217
239
|
throw new Error(`archive staging root must be outside destination: ${stagingRoot}`);
|
|
218
240
|
}
|
|
219
|
-
const
|
|
241
|
+
const stagingPrefix = assertSafeArchiveStagingPrefix(params.stagingDirPrefix ?? "fs-safe-archive-");
|
|
242
|
+
const stagingDir = await fs.mkdtemp(path.join(stagingRoot, stagingPrefix));
|
|
243
|
+
const stagingGuard = await createDirectoryIdentityGuard(stagingDir);
|
|
220
244
|
try {
|
|
221
245
|
await fs.chmod(stagingDir, ARCHIVE_STAGING_MODE).catch(() => undefined);
|
|
246
|
+
await assertDirectoryIdentityGuard(stagingGuard);
|
|
222
247
|
return await params.run(stagingDir);
|
|
223
248
|
}
|
|
224
249
|
finally {
|
|
225
|
-
|
|
250
|
+
try {
|
|
251
|
+
await assertDirectoryIdentityGuard(stagingGuard);
|
|
252
|
+
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// The staging path identity changed; deleting by name could target data
|
|
256
|
+
// outside the private temp tree, so fail closed and leave it for OS cleanup.
|
|
257
|
+
}
|
|
226
258
|
}
|
|
227
259
|
}
|
|
228
260
|
export async function mergeExtractedTreeIntoDestination(params) {
|
|
229
261
|
const targetRoot = await root(params.destinationRealDir);
|
|
262
|
+
const sourceRootGuard = await createDirectoryIdentityGuard(params.sourceDir);
|
|
263
|
+
const sourceRootReal = sourceRootGuard.realPath;
|
|
230
264
|
const walk = async (currentSourceDir) => {
|
|
265
|
+
await assertDirectoryIdentityGuard(sourceRootGuard);
|
|
231
266
|
const entries = await fs.readdir(currentSourceDir, { withFileTypes: true });
|
|
232
267
|
for (const entry of entries) {
|
|
268
|
+
await assertDirectoryIdentityGuard(sourceRootGuard);
|
|
233
269
|
const sourcePath = path.join(currentSourceDir, entry.name);
|
|
234
270
|
const relPath = path.relative(params.sourceDir, sourcePath);
|
|
235
271
|
const originalPath = relPath.split(path.sep).join("/");
|
|
@@ -238,6 +274,10 @@ export async function mergeExtractedTreeIntoDestination(params) {
|
|
|
238
274
|
if (sourceStat.isSymbolicLink()) {
|
|
239
275
|
throw symlinkTraversalError(originalPath);
|
|
240
276
|
}
|
|
277
|
+
const sourceReal = await fs.realpath(sourcePath);
|
|
278
|
+
if (!isPathInside(sourceRootReal, sourceReal)) {
|
|
279
|
+
throw symlinkTraversalError(originalPath);
|
|
280
|
+
}
|
|
241
281
|
if (sourceStat.isDirectory()) {
|
|
242
282
|
await prepareArchiveOutputPath({
|
|
243
283
|
destinationDir: params.destinationDir,
|
package/dist/archive.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"AAkBA,OAAO,EAOL,KAAK,oBAAoB,EAC1B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAiBzE,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC,CAAC;AAEF,OAAO,EACL,kBAAkB,EAClB,yBAAyB,EACzB,wBAAwB,EACxB,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAC/F,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,6BAA6B,EAC7B,mBAAmB,EACnB,2BAA2B,EAC3B,uBAAuB,EACvB,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,GAC3B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,oBAAoB,EAAE,KAAK,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAC3F,OAAO,EACL,kCAAkC,EAClC,iCAAiC,EACjC,4BAA4B,EAC5B,wBAAwB,EACxB,4BAA4B,GAC7B,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,8BAA8B,EAAE,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrF,OAAO,EACL,2BAA2B,EAC3B,iCAAiC,EACjC,KAAK,mBAAmB,GACzB,MAAM,4BAA4B,CAAC;AAuTpC,wBAAsB,cAAc,CAAC,MAAM,EAAE;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6EhB"}
|
package/dist/archive.js
CHANGED
|
@@ -4,13 +4,16 @@ import path from "node:path";
|
|
|
4
4
|
import { Readable } from "node:stream";
|
|
5
5
|
import { pipeline } from "node:stream/promises";
|
|
6
6
|
import { resolveArchiveOutputPath, stripArchivePath, validateArchiveEntryPath, } from "./archive-entry.js";
|
|
7
|
+
import { createPipelineTimeoutError, waitForDeadline, withExtractionDeadline, } from "./archive-deadline.js";
|
|
8
|
+
import { writeFileHandleFully } from "./archive-file-io.js";
|
|
7
9
|
import { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, assertArchiveEntryCountWithinLimit, createByteBudgetTracker, createExtractBudgetTransform, resolveExtractLimits, } from "./archive-limits.js";
|
|
8
10
|
import { resolveArchiveKind } from "./archive-kind.js";
|
|
9
11
|
import { mergeExtractedTreeIntoDestination, prepareArchiveDestinationDir, prepareArchiveOutputPath, withStagedArchiveDestination, } from "./archive-staging.js";
|
|
10
12
|
import { createTarEntryPreflightChecker, readTarEntryInfo, } from "./archive-tar.js";
|
|
11
13
|
import { loadZipArchiveWithPreflight } from "./archive-zip-preflight.js";
|
|
14
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
12
15
|
import { writeSiblingTempFile } from "./sibling-temp.js";
|
|
13
|
-
import {
|
|
16
|
+
import { tempFile } from "./temp-target.js";
|
|
14
17
|
export { isWindowsDrivePath, normalizeArchiveEntryPath, resolveArchiveOutputPath, stripArchivePath, validateArchiveEntryPath, } from "./archive-entry.js";
|
|
15
18
|
export { resolveArchiveKind, resolvePackedRootDir } from "./archive-kind.js";
|
|
16
19
|
export { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, DEFAULT_MAX_ARCHIVE_BYTES_ZIP, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_EXTRACTED_BYTES, DEFAULT_MAX_ENTRY_BYTES, } from "./archive-limits.js";
|
|
@@ -29,6 +32,85 @@ function isZipSymlinkEntry(entry) {
|
|
|
29
32
|
return (typeof entry.unixPermissions === "number" &&
|
|
30
33
|
(entry.unixPermissions & ZIP_UNIX_FILE_TYPE_MASK) === ZIP_UNIX_SYMLINK_TYPE);
|
|
31
34
|
}
|
|
35
|
+
function zipEntryFileMode(entry) {
|
|
36
|
+
if (typeof entry.unixPermissions !== "number") {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const mode = entry.unixPermissions & 0o777;
|
|
40
|
+
return mode === 0 ? undefined : mode;
|
|
41
|
+
}
|
|
42
|
+
async function cleanupStagedArchiveFile(staged) {
|
|
43
|
+
if (staged) {
|
|
44
|
+
await staged.cleanup().catch(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function closeFileHandle(handle) {
|
|
48
|
+
if (handle) {
|
|
49
|
+
await handle.close().catch(() => undefined);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function stageArchiveFileForExtraction(params) {
|
|
53
|
+
params.deadline.check();
|
|
54
|
+
const sourcePath = path.resolve(params.archivePath);
|
|
55
|
+
const initialStat = await fs.lstat(sourcePath);
|
|
56
|
+
if (initialStat.isSymbolicLink() || !initialStat.isFile()) {
|
|
57
|
+
throw new Error(`archive is not a regular file: ${params.archivePath}`);
|
|
58
|
+
}
|
|
59
|
+
if (initialStat.size > params.limits.maxArchiveBytes) {
|
|
60
|
+
throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT);
|
|
61
|
+
}
|
|
62
|
+
const noFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants ? fsConstants.O_NOFOLLOW : 0;
|
|
63
|
+
const handle = await fs.open(sourcePath, fsConstants.O_RDONLY | noFollow);
|
|
64
|
+
let staged;
|
|
65
|
+
let output;
|
|
66
|
+
try {
|
|
67
|
+
staged = await tempFile({
|
|
68
|
+
prefix: "fs-safe-archive-input",
|
|
69
|
+
fileName: path.basename(sourcePath),
|
|
70
|
+
});
|
|
71
|
+
const openedStat = await handle.stat();
|
|
72
|
+
const pathStat = await fs.lstat(sourcePath);
|
|
73
|
+
if (!openedStat.isFile() ||
|
|
74
|
+
pathStat.isSymbolicLink() ||
|
|
75
|
+
!pathStat.isFile() ||
|
|
76
|
+
!sameFileIdentity(initialStat, openedStat) ||
|
|
77
|
+
!sameFileIdentity(pathStat, openedStat)) {
|
|
78
|
+
throw new Error("archive changed during validation");
|
|
79
|
+
}
|
|
80
|
+
output = await fs.open(staged.path, OPEN_WRITE_CREATE_FLAGS, 0o600);
|
|
81
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
82
|
+
let written = 0;
|
|
83
|
+
while (true) {
|
|
84
|
+
params.deadline.check();
|
|
85
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
|
86
|
+
if (bytesRead === 0) {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
written += bytesRead;
|
|
90
|
+
if (written > params.limits.maxArchiveBytes) {
|
|
91
|
+
throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT);
|
|
92
|
+
}
|
|
93
|
+
await writeFileHandleFully({
|
|
94
|
+
handle: output,
|
|
95
|
+
buffer,
|
|
96
|
+
bytes: bytesRead,
|
|
97
|
+
deadline: params.deadline,
|
|
98
|
+
});
|
|
99
|
+
params.deadline.check();
|
|
100
|
+
}
|
|
101
|
+
await output.close();
|
|
102
|
+
output = undefined;
|
|
103
|
+
return staged;
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
await closeFileHandle(output);
|
|
107
|
+
await cleanupStagedArchiveFile(staged);
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
await closeFileHandle(handle);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
32
114
|
async function readZipEntryStream(entry) {
|
|
33
115
|
if (typeof entry.nodeStream === "function") {
|
|
34
116
|
return entry.nodeStream();
|
|
@@ -57,6 +139,7 @@ async function prepareZipOutputPath(params) {
|
|
|
57
139
|
await prepareArchiveOutputPath(params);
|
|
58
140
|
}
|
|
59
141
|
async function writeZipFileEntry(params) {
|
|
142
|
+
params.deadline.check();
|
|
60
143
|
params.budget.startEntry();
|
|
61
144
|
const readable = await readZipEntryStream(params.entry);
|
|
62
145
|
const destinationPath = params.outPath;
|
|
@@ -67,13 +150,20 @@ async function writeZipFileEntry(params) {
|
|
|
67
150
|
dir: path.dirname(destinationPath),
|
|
68
151
|
tempPrefix: `.${path.basename(destinationPath)}.fs-safe-archive`,
|
|
69
152
|
chmodDir: false,
|
|
153
|
+
mode: zipEntryFileMode(params.entry),
|
|
70
154
|
writeTemp: async (tempPath) => {
|
|
71
155
|
tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, 0o666);
|
|
72
156
|
const writable = tempHandle.createWriteStream();
|
|
73
157
|
writable.once("close", () => {
|
|
74
158
|
handleClosedByStream = true;
|
|
75
159
|
});
|
|
76
|
-
|
|
160
|
+
try {
|
|
161
|
+
await pipeline(readable, createExtractBudgetTransform({ onChunkBytes: params.budget.addBytes }), writable, { signal: params.deadline.signal });
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
throw createPipelineTimeoutError(err, params.deadline);
|
|
165
|
+
}
|
|
166
|
+
params.deadline.check();
|
|
77
167
|
if (!handleClosedByStream) {
|
|
78
168
|
await tempHandle.close().catch(() => undefined);
|
|
79
169
|
handleClosedByStream = true;
|
|
@@ -83,13 +173,6 @@ async function writeZipFileEntry(params) {
|
|
|
83
173
|
},
|
|
84
174
|
resolveFinalPath: (filePath) => filePath,
|
|
85
175
|
});
|
|
86
|
-
// Best-effort permission restore for zip entries created on unix.
|
|
87
|
-
if (typeof params.entry.unixPermissions === "number") {
|
|
88
|
-
const mode = params.entry.unixPermissions & 0o777;
|
|
89
|
-
if (mode !== 0) {
|
|
90
|
-
await fs.chmod(destinationPath, mode).catch(() => undefined);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
176
|
}
|
|
94
177
|
catch (err) {
|
|
95
178
|
// Failures here happen before the temp has been committed. The destination
|
|
@@ -105,57 +188,70 @@ async function writeZipFileEntry(params) {
|
|
|
105
188
|
}
|
|
106
189
|
async function extractZip(params) {
|
|
107
190
|
const limits = resolveExtractLimits(params.limits);
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
191
|
+
const stagedArchive = await stageArchiveFileForExtraction({
|
|
192
|
+
archivePath: params.archivePath,
|
|
193
|
+
limits,
|
|
194
|
+
deadline: params.deadline,
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const destinationRealDir = await prepareArchiveDestinationDir(params.destDir);
|
|
198
|
+
params.deadline.check();
|
|
199
|
+
const buffer = await fs.readFile(stagedArchive.path, { signal: params.deadline.signal });
|
|
200
|
+
params.deadline.check();
|
|
201
|
+
const zip = await waitForDeadline(loadZipArchiveWithPreflight(buffer, limits), params.deadline);
|
|
202
|
+
params.deadline.check();
|
|
203
|
+
const entries = Object.values(zip.files);
|
|
204
|
+
const strip = Math.max(0, Math.floor(params.stripComponents ?? 0));
|
|
205
|
+
assertArchiveEntryCountWithinLimit(entries.length, limits);
|
|
206
|
+
const budget = createByteBudgetTracker(limits);
|
|
207
|
+
await withStagedArchiveDestination({
|
|
208
|
+
destinationRealDir,
|
|
209
|
+
run: async (stagingDir) => {
|
|
210
|
+
const stagingRealDir = await fs.realpath(stagingDir);
|
|
211
|
+
for (const entry of entries) {
|
|
212
|
+
params.deadline.check();
|
|
213
|
+
const output = resolveZipOutputPath({
|
|
214
|
+
entryPath: entry.name,
|
|
215
|
+
strip,
|
|
216
|
+
destinationDir: stagingRealDir,
|
|
217
|
+
});
|
|
218
|
+
if (!output) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
await prepareZipOutputPath({
|
|
222
|
+
destinationDir: stagingRealDir,
|
|
223
|
+
destinationRealDir: stagingRealDir,
|
|
224
|
+
relPath: output.relPath,
|
|
225
|
+
outPath: output.outPath,
|
|
226
|
+
originalPath: entry.name,
|
|
227
|
+
isDirectory: entry.dir,
|
|
228
|
+
});
|
|
229
|
+
if (entry.dir) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (isZipSymlinkEntry(entry)) {
|
|
233
|
+
throw new Error(`zip entry is a link: ${entry.name}`);
|
|
234
|
+
}
|
|
235
|
+
await writeZipFileEntry({
|
|
236
|
+
entry,
|
|
237
|
+
outPath: output.outPath,
|
|
238
|
+
budget,
|
|
239
|
+
deadline: params.deadline,
|
|
240
|
+
});
|
|
145
241
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
242
|
+
params.deadline.check();
|
|
243
|
+
await mergeExtractedTreeIntoDestination({
|
|
244
|
+
sourceDir: stagingRealDir,
|
|
245
|
+
destinationDir: params.destDir,
|
|
246
|
+
destinationRealDir,
|
|
150
247
|
});
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
});
|
|
248
|
+
params.deadline.check();
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
await stagedArchive.cleanup();
|
|
254
|
+
}
|
|
159
255
|
}
|
|
160
256
|
export async function extractArchive(params) {
|
|
161
257
|
const kind = params.kind ?? resolveArchiveKind(params.archivePath);
|
|
@@ -164,62 +260,74 @@ export async function extractArchive(params) {
|
|
|
164
260
|
}
|
|
165
261
|
const label = kind === "zip" ? "extract zip" : "extract tar";
|
|
166
262
|
if (kind === "tar") {
|
|
167
|
-
await
|
|
263
|
+
await withExtractionDeadline(params.timeoutMs, label, async (deadline) => {
|
|
168
264
|
const tar = await importOptionalTar();
|
|
169
265
|
const limits = resolveExtractLimits(params.limits);
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const destinationRealDir = await prepareArchiveDestinationDir(params.destDir);
|
|
175
|
-
await withStagedArchiveDestination({
|
|
176
|
-
destinationRealDir,
|
|
177
|
-
run: async (stagingDir) => {
|
|
178
|
-
const checkTarEntrySafety = createTarEntryPreflightChecker({
|
|
179
|
-
rootDir: destinationRealDir,
|
|
180
|
-
stripComponents: params.stripComponents,
|
|
181
|
-
limits,
|
|
182
|
-
});
|
|
183
|
-
// A canonical cwd is not enough here: tar can still follow
|
|
184
|
-
// pre-existing child symlinks in the live destination tree.
|
|
185
|
-
// Extract into a private staging dir first, then merge through
|
|
186
|
-
// the same safe-open boundary checks used by direct file writes.
|
|
187
|
-
await tar.x({
|
|
188
|
-
file: params.archivePath,
|
|
189
|
-
cwd: stagingDir,
|
|
190
|
-
strip: Math.max(0, Math.floor(params.stripComponents ?? 0)),
|
|
191
|
-
gzip: params.tarGzip,
|
|
192
|
-
preservePaths: false,
|
|
193
|
-
strict: true,
|
|
194
|
-
onReadEntry(entry) {
|
|
195
|
-
try {
|
|
196
|
-
checkTarEntrySafety(readTarEntryInfo(entry));
|
|
197
|
-
}
|
|
198
|
-
catch (err) {
|
|
199
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
200
|
-
// Node's EventEmitter calls listeners with `this` bound to the
|
|
201
|
-
// emitter (tar.Unpack), which exposes Parser.abort().
|
|
202
|
-
const emitter = this;
|
|
203
|
-
emitter.abort?.(error);
|
|
204
|
-
}
|
|
205
|
-
},
|
|
206
|
-
});
|
|
207
|
-
await mergeExtractedTreeIntoDestination({
|
|
208
|
-
sourceDir: stagingDir,
|
|
209
|
-
destinationDir: params.destDir,
|
|
210
|
-
destinationRealDir,
|
|
211
|
-
});
|
|
212
|
-
},
|
|
266
|
+
const stagedArchive = await stageArchiveFileForExtraction({
|
|
267
|
+
archivePath: params.archivePath,
|
|
268
|
+
limits,
|
|
269
|
+
deadline,
|
|
213
270
|
});
|
|
214
|
-
|
|
271
|
+
try {
|
|
272
|
+
const destinationRealDir = await prepareArchiveDestinationDir(params.destDir);
|
|
273
|
+
await withStagedArchiveDestination({
|
|
274
|
+
destinationRealDir,
|
|
275
|
+
run: async (stagingDir) => {
|
|
276
|
+
deadline.check();
|
|
277
|
+
const checkTarEntrySafety = createTarEntryPreflightChecker({
|
|
278
|
+
rootDir: destinationRealDir,
|
|
279
|
+
stripComponents: params.stripComponents,
|
|
280
|
+
limits,
|
|
281
|
+
});
|
|
282
|
+
// A canonical cwd is not enough here: tar can still follow
|
|
283
|
+
// pre-existing child symlinks in the live destination tree.
|
|
284
|
+
// Extract into a private staging dir first, then merge through
|
|
285
|
+
// the same safe-open boundary checks used by direct file writes.
|
|
286
|
+
await tar.x({
|
|
287
|
+
file: stagedArchive.path,
|
|
288
|
+
cwd: stagingDir,
|
|
289
|
+
strip: Math.max(0, Math.floor(params.stripComponents ?? 0)),
|
|
290
|
+
gzip: params.tarGzip,
|
|
291
|
+
signal: deadline.signal,
|
|
292
|
+
preservePaths: false,
|
|
293
|
+
strict: true,
|
|
294
|
+
onReadEntry(entry) {
|
|
295
|
+
try {
|
|
296
|
+
deadline.check();
|
|
297
|
+
checkTarEntrySafety(readTarEntryInfo(entry));
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
301
|
+
// Node's EventEmitter calls listeners with `this` bound to the
|
|
302
|
+
// emitter (tar.Unpack), which exposes Parser.abort().
|
|
303
|
+
const emitter = this;
|
|
304
|
+
emitter.abort?.(error);
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
deadline.check();
|
|
309
|
+
await mergeExtractedTreeIntoDestination({
|
|
310
|
+
sourceDir: stagingDir,
|
|
311
|
+
destinationDir: params.destDir,
|
|
312
|
+
destinationRealDir,
|
|
313
|
+
});
|
|
314
|
+
deadline.check();
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
await stagedArchive.cleanup();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
215
322
|
return;
|
|
216
323
|
}
|
|
217
|
-
await
|
|
324
|
+
await withExtractionDeadline(params.timeoutMs, label, async (deadline) => extractZip({
|
|
218
325
|
archivePath: params.archivePath,
|
|
219
326
|
destDir: params.destDir,
|
|
220
327
|
stripComponents: params.stripComponents,
|
|
221
328
|
limits: params.limits,
|
|
222
|
-
|
|
329
|
+
deadline,
|
|
330
|
+
}));
|
|
223
331
|
}
|
|
224
332
|
async function importOptionalTar() {
|
|
225
333
|
try {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"json-durable-queue.d.ts","sourceRoot":"","sources":["../src/json-durable-queue.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAOF,eAAO,MAAM,0CAA0C,QAAmB,CAAC;AAY3E,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtE;AAED,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUpF;AAmBD,wBAAgB,iCAAiC,CAC/C,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,GACT,0BAA0B,CAM5B;AAED,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhB;AA+KD,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAOhB;
|
|
1
|
+
{"version":3,"file":"json-durable-queue.d.ts","sourceRoot":"","sources":["../src/json-durable-queue.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAOF,eAAO,MAAM,0CAA0C,QAAmB,CAAC;AAY3E,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtE;AAED,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUpF;AAmBD,wBAAgB,iCAAiC,CAC/C,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,GACT,0BAA0B,CAM5B;AAED,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhB;AA+KD,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAOhB;AAsDD,wBAAsB,yBAAyB,CAAC,CAAC,EAC/C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED,wBAAsB,wBAAwB,CAAC,KAAK,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/F;AAED,wBAAsB,yBAAyB,CAAC,CAAC,EAAE,MAAM,EAAE;IACzD,KAAK,EAAE,0BAA0B,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAwBpB;AAED,wBAAsB,kCAAkC,CAAC,CAAC,EACxD,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,CAAC,EAAE,CAAC,CAkDd;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;CACZ,GAAG,OAAO,CAAC,IAAI,CAAC,CAShB"}
|
|
@@ -215,6 +215,9 @@ async function readBoundedUtf8File(params) {
|
|
|
215
215
|
if (initialStat.isSymbolicLink() || !initialStat.isFile()) {
|
|
216
216
|
throw new Error("queue entry is not a regular file");
|
|
217
217
|
}
|
|
218
|
+
if (initialStat.nlink > 1) {
|
|
219
|
+
throw new Error("queue entry hardlinks are not allowed");
|
|
220
|
+
}
|
|
218
221
|
if (initialStat.size > params.maxBytes) {
|
|
219
222
|
throw new Error(`queue entry exceeds ${params.maxBytes} bytes`);
|
|
220
223
|
}
|
|
@@ -228,6 +231,8 @@ async function readBoundedUtf8File(params) {
|
|
|
228
231
|
if (!openedStat.isFile() ||
|
|
229
232
|
pathStat.isSymbolicLink() ||
|
|
230
233
|
!pathStat.isFile() ||
|
|
234
|
+
openedStat.nlink > 1 ||
|
|
235
|
+
pathStat.nlink > 1 ||
|
|
231
236
|
!sameFileIdentity(initialStat, openedStat) ||
|
|
232
237
|
!sameFileIdentity(pathStat, openedStat)) {
|
|
233
238
|
throw new Error("queue entry changed during read");
|
package/dist/output.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,wBAAwB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,uBAAuB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA6CF,wBAAsB,2BAA2B,CAAC,CAAC,GAAG,IAAI,EACxD,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GACnC,OAAO,CAAC,uBAAuB,CAAC,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,wBAAwB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,uBAAuB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA6CF,wBAAsB,2BAA2B,CAAC,CAAC,GAAG,IAAI,EACxD,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GACnC,OAAO,CAAC,uBAAuB,CAAC,CAAC,CAAC,CAAC,CA+BrC"}
|
package/dist/output.js
CHANGED
|
@@ -42,6 +42,7 @@ export async function writeExternalFileWithinRoot(options) {
|
|
|
42
42
|
if (requestedTargetPath.length === 0) {
|
|
43
43
|
throw new FsSafeError("invalid-path", "target path is required");
|
|
44
44
|
}
|
|
45
|
+
assertFileTargetPath(requestedTargetPath);
|
|
45
46
|
const targetPath = toRootPathInput({
|
|
46
47
|
rootDir: targetRoot.rootDir,
|
|
47
48
|
rootReal: targetRoot.rootReal,
|
package/dist/pinned-write.js
CHANGED
|
@@ -67,7 +67,7 @@ export async function runPinnedWriteHelper(params) {
|
|
|
67
67
|
relativeParentPath: params.relativeParentPath,
|
|
68
68
|
});
|
|
69
69
|
if (getFsSafePythonConfig().mode === "off") {
|
|
70
|
-
return await
|
|
70
|
+
return await runPinnedWriteFallback(params);
|
|
71
71
|
}
|
|
72
72
|
if (params.input.kind === "stream") {
|
|
73
73
|
try {
|
|
@@ -75,7 +75,7 @@ export async function runPinnedWriteHelper(params) {
|
|
|
75
75
|
}
|
|
76
76
|
catch (error) {
|
|
77
77
|
if (canFallbackFromPythonError(error)) {
|
|
78
|
-
return await
|
|
78
|
+
return await runPinnedWriteFallback(params);
|
|
79
79
|
}
|
|
80
80
|
throw error;
|
|
81
81
|
}
|
|
@@ -99,7 +99,7 @@ export async function runPinnedWriteHelper(params) {
|
|
|
99
99
|
}
|
|
100
100
|
catch (error) {
|
|
101
101
|
if (canFallbackFromPythonError(error)) {
|
|
102
|
-
return await
|
|
102
|
+
return await runPinnedWriteFallback(params);
|
|
103
103
|
}
|
|
104
104
|
throw error;
|
|
105
105
|
}
|
|
@@ -126,12 +126,6 @@ export async function runPinnedCopyHelper(params) {
|
|
|
126
126
|
},
|
|
127
127
|
});
|
|
128
128
|
}
|
|
129
|
-
async function runPinnedWriteFallbackOrThrow(params, cause) {
|
|
130
|
-
if (process.platform !== "win32") {
|
|
131
|
-
throw new FsSafeError("helper-unavailable", "Python helper is required for pinned writes on this platform", { cause });
|
|
132
|
-
}
|
|
133
|
-
return await runPinnedWriteFallback(params);
|
|
134
|
-
}
|
|
135
129
|
async function runPinnedWriteFallback(params) {
|
|
136
130
|
const parentPath = params.relativeParentPath
|
|
137
131
|
? path.join(params.rootPath, ...params.relativeParentPath.split("/"))
|
package/dist/root-paths.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"root-paths.d.ts","sourceRoot":"","sources":["../src/root-paths.ts"],"names":[],"mappings":"AAMA,KAAK,iBAAiB,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AACtD,KAAK,4BAA4B,GAAG;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AACF,KAAK,4BAA4B,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,iBAAiB,CAAC;AACtF,MAAM,MAAM,uBAAuB,GAAG;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AACF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AACF,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CACL,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE,uBAAuB,GAChC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7D,UAAU,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,4BAA4B,CAAC;IACnE,QAAQ,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,4BAA4B,CAAC,CAAC;IAC1E,KAAK,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,4BAA4B,CAAC,CAAC;IACvE,QAAQ,CACN,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE,uBAAuB,GAChC,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtE,SAAS,CACP,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE,uBAAuB,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GACpD,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACvE,CAAC;
|
|
1
|
+
{"version":3,"file":"root-paths.d.ts","sourceRoot":"","sources":["../src/root-paths.ts"],"names":[],"mappings":"AAMA,KAAK,iBAAiB,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AACtD,KAAK,4BAA4B,GAAG;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AACF,KAAK,4BAA4B,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,iBAAiB,CAAC;AACtF,MAAM,MAAM,uBAAuB,GAAG;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AACF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AACF,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CACL,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE,uBAAuB,GAChC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7D,UAAU,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,4BAA4B,CAAC;IACnE,QAAQ,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,4BAA4B,CAAC,CAAC;IAC1E,KAAK,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,4BAA4B,CAAC,CAAC;IACvE,QAAQ,CACN,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE,uBAAuB,GAChC,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtE,SAAS,CACP,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE,uBAAuB,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GACpD,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACvE,CAAC;AA4DF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAkB5D;AAED,wBAAsB,6BAA6B,CAAC,MAAM,EAAE;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAiCrE;AAoDD,wBAAsB,yBAAyB,CAAC,MAAM,EAAE;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAkErE;AAED,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,4BAA4B,GACnC,4BAA4B,CAc9B;AAED,wBAAsB,8BAA8B,CAClD,MAAM,EAAE,4BAA4B,GACnC,OAAO,CAAC,4BAA4B,CAAC,CAEvC;AAED,wBAAsB,oCAAoC,CACxD,MAAM,EAAE,4BAA4B,GACnC,OAAO,CAAC,4BAA4B,CAAC,CAEvC;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,GAAG,SAAS,CAwC/E"}
|
package/dist/root-paths.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { FsSafeError } from "./errors.js";
|
|
4
|
-
import { isNotFoundPathError, isPathInside } from "./path.js";
|
|
4
|
+
import { isNotFoundPathError, isPathInside, isPathRelativeEscape } from "./path.js";
|
|
5
5
|
import { root as openRoot } from "./root.js";
|
|
6
6
|
function invalidPath(scopeLabel) {
|
|
7
7
|
return {
|
|
@@ -9,6 +9,10 @@ function invalidPath(scopeLabel) {
|
|
|
9
9
|
error: `Invalid path: must stay within ${scopeLabel}`,
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
|
+
function pathStaysWithinRoot(rootDir, candidatePath) {
|
|
13
|
+
const relative = path.relative(rootDir, candidatePath);
|
|
14
|
+
return Boolean(relative) && !isPathRelativeEscape(relative);
|
|
15
|
+
}
|
|
12
16
|
async function resolveRealPathIfExists(targetPath) {
|
|
13
17
|
try {
|
|
14
18
|
return await fs.realpath(targetPath);
|
|
@@ -58,11 +62,14 @@ export function resolvePathWithinRoot(params) {
|
|
|
58
62
|
if (!params.defaultFileName) {
|
|
59
63
|
return { ok: false, error: "path is required" };
|
|
60
64
|
}
|
|
61
|
-
|
|
65
|
+
const defaultPath = path.resolve(root, params.defaultFileName);
|
|
66
|
+
if (!pathStaysWithinRoot(root, defaultPath)) {
|
|
67
|
+
return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, path: defaultPath };
|
|
62
70
|
}
|
|
63
71
|
const resolved = path.resolve(root, raw);
|
|
64
|
-
|
|
65
|
-
if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
72
|
+
if (!pathStaysWithinRoot(root, resolved)) {
|
|
66
73
|
return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
|
|
67
74
|
}
|
|
68
75
|
return { ok: true, path: resolved };
|
|
@@ -118,7 +125,7 @@ async function resolveNearestExistingPath(targetPath) {
|
|
|
118
125
|
}
|
|
119
126
|
async function assertNoSymlinkSegments(params) {
|
|
120
127
|
const relative = path.relative(params.rootDir, params.targetPath);
|
|
121
|
-
if (
|
|
128
|
+
if (isPathRelativeEscape(relative)) {
|
|
122
129
|
throw new Error(`Invalid path: must stay within ${params.scopeLabel}`);
|
|
123
130
|
}
|
|
124
131
|
let current = params.rootDir;
|
|
@@ -195,6 +202,10 @@ export async function ensureDirectoryWithinRoot(params) {
|
|
|
195
202
|
}
|
|
196
203
|
}
|
|
197
204
|
}
|
|
205
|
+
const currentReal = await fs.realpath(current);
|
|
206
|
+
if (!isPathInside(rootReal, currentReal)) {
|
|
207
|
+
return invalidPath(params.scopeLabel);
|
|
208
|
+
}
|
|
198
209
|
}
|
|
199
210
|
const targetReal = await fs.realpath(targetPath);
|
|
200
211
|
if (!isPathInside(rootReal, targetReal)) {
|
package/dist/secret-file.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare const PRIVATE_SECRET_FILE_MODE = 384;
|
|
|
4
4
|
export type SecretFileReadOptions = {
|
|
5
5
|
maxBytes?: number;
|
|
6
6
|
rejectSymlink?: boolean;
|
|
7
|
+
rejectHardlinks?: boolean;
|
|
7
8
|
};
|
|
8
9
|
export declare function readSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): string;
|
|
9
10
|
export declare function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: SecretFileReadOptions): string | undefined;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAkIF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAcpB;AAoID,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+ChB"}
|
package/dist/secret-file.js
CHANGED
|
@@ -13,6 +13,10 @@ export const PRIVATE_SECRET_FILE_MODE = 0o600;
|
|
|
13
13
|
function normalizeSecretReadError(error) {
|
|
14
14
|
return error instanceof Error ? error : new Error(String(error));
|
|
15
15
|
}
|
|
16
|
+
function secretPathErrorCode(error) {
|
|
17
|
+
const code = error.code;
|
|
18
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "not-found" : "invalid-path";
|
|
19
|
+
}
|
|
16
20
|
function resolveUserPath(input) {
|
|
17
21
|
return resolveHomeRelativePath(input);
|
|
18
22
|
}
|
|
@@ -31,7 +35,7 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
|
|
|
31
35
|
const normalized = normalizeSecretReadError(error);
|
|
32
36
|
return {
|
|
33
37
|
ok: false,
|
|
34
|
-
code: error
|
|
38
|
+
code: secretPathErrorCode(error),
|
|
35
39
|
error: normalized,
|
|
36
40
|
message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
|
|
37
41
|
};
|
|
@@ -45,7 +49,7 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
|
|
|
45
49
|
const normalized = normalizeSecretReadError(error);
|
|
46
50
|
return {
|
|
47
51
|
ok: false,
|
|
48
|
-
code: error
|
|
52
|
+
code: secretPathErrorCode(error),
|
|
49
53
|
error: normalized,
|
|
50
54
|
message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
|
|
51
55
|
};
|
|
@@ -66,6 +70,13 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
|
|
|
66
70
|
message: `${label} file at ${resolvedPath} must be a regular file.`,
|
|
67
71
|
};
|
|
68
72
|
}
|
|
73
|
+
if (options.rejectHardlinks !== false && previewStat.nlink > 1) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
code: "hardlink",
|
|
77
|
+
message: `${label} file at ${resolvedPath} must not be hardlinked.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
69
80
|
if (previewStat.size > maxBytes) {
|
|
70
81
|
return {
|
|
71
82
|
ok: false,
|
|
@@ -76,6 +87,7 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
|
|
|
76
87
|
const opened = openPinnedFileSync({
|
|
77
88
|
filePath: resolvedPath,
|
|
78
89
|
rejectPathSymlink: options.rejectSymlink,
|
|
90
|
+
rejectHardlinks: options.rejectHardlinks !== false,
|
|
79
91
|
maxBytes,
|
|
80
92
|
});
|
|
81
93
|
if (!opened.ok) {
|
|
@@ -126,7 +138,15 @@ export function tryReadSecretFileSync(filePath, label, options = {}) {
|
|
|
126
138
|
return undefined;
|
|
127
139
|
}
|
|
128
140
|
const result = readSecretFileOutcomeSync(filePath, label, options);
|
|
129
|
-
|
|
141
|
+
if (result.ok) {
|
|
142
|
+
return result.secret;
|
|
143
|
+
}
|
|
144
|
+
if (result.code === "not-found") {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
throw new FsSafeError(result.code, result.message, {
|
|
148
|
+
cause: result.error,
|
|
149
|
+
});
|
|
130
150
|
}
|
|
131
151
|
function isRelativeEscape(relativePath) {
|
|
132
152
|
return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,aAAa,GAAG,qBAAqB,CAAC;AAE7E,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,yBAAyB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,uBAAuB,CAAC;IAChC,aAAa,CAAC,EAAE,wBAAwB,CAAC;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,OAAO,EAAE,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5C,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE;QACvB,QAAQ,EAAE,MAAM,CAAC;QACjB,oBAAoB,EAAE,MAAM,CAAC;QAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;QACxC,OAAO,EAAE,MAAM,CAAC;QAChB,KAAK,EAAE,MAAM,CAAC;QACd,iBAAiB,EAAE,OAAO,CAAC;KAC5B,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjC,qBAAqB,CAAC,EAAE,CACtB,QAAQ,EAAE,wBAAwB,KAC/B,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,YAAY,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CACtC,CAAC;AAEF,MAAM,MAAM,sBAAsB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,IAAI,CACjF,yBAAyB,CAAC,QAAQ,CAAC,EACnC,YAAY,CACb,GAAG;IACF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;
|
|
1
|
+
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
|
package/dist/sidecar-lock.js
CHANGED
|
@@ -34,8 +34,11 @@ async function readLockSnapshot(lockPath) {
|
|
|
34
34
|
return { raw, payload: null, stat };
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
catch {
|
|
38
|
-
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err.code === "ENOENT") {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
throw err;
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
function snapshotMatches(current, observed) {
|
|
@@ -86,7 +89,10 @@ async function removeStaleLockIfAllowed(params) {
|
|
|
86
89
|
try {
|
|
87
90
|
await fs.rm(params.lockPath, { force: true });
|
|
88
91
|
}
|
|
89
|
-
catch {
|
|
92
|
+
catch (err) {
|
|
93
|
+
if (err.code === "ENOENT") {
|
|
94
|
+
return "changed";
|
|
95
|
+
}
|
|
90
96
|
return "not-approved";
|
|
91
97
|
}
|
|
92
98
|
return "removed";
|
package/dist/symlink-parents.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import fsSync from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { isNotFoundPathError } from "./path.js";
|
|
4
|
+
import { isNotFoundPathError, isPathRelativeEscape } from "./path.js";
|
|
5
5
|
function resolvePathWalk(params) {
|
|
6
6
|
const root = path.resolve(params.rootDir);
|
|
7
7
|
const target = path.resolve(params.targetPath);
|
|
8
8
|
const relative = path.relative(root, target);
|
|
9
|
-
if (
|
|
9
|
+
if (isPathRelativeEscape(relative)) {
|
|
10
10
|
if (params.allowOutsideRoot) {
|
|
11
11
|
return null;
|
|
12
12
|
}
|
package/docs/python-helper.md
CHANGED
|
@@ -62,12 +62,12 @@ Node-only mode still keeps the important application-level guardrails:
|
|
|
62
62
|
- root-relative path validation;
|
|
63
63
|
- canonical root checks;
|
|
64
64
|
- no-follow opens where Node/platform support exists;
|
|
65
|
-
- file identity checks around reads and writes
|
|
66
|
-
- atomic sibling-temp replacement
|
|
65
|
+
- file identity checks around reads and writes;
|
|
66
|
+
- atomic sibling-temp replacement;
|
|
67
67
|
- hardlink/symlink policy checks where the API requests them;
|
|
68
68
|
- byte limits and structured `FsSafeError` failures.
|
|
69
69
|
|
|
70
|
-
What gets weaker is the POSIX defense against another same-UID process swapping a parent directory between validation and mutation.
|
|
70
|
+
What gets weaker is the POSIX defense against another same-UID process swapping a parent directory between validation and mutation. Without fd-relative mutation, `root().move()`, `root().remove()`, `root().mkdir()`, and some write paths rely on Node path operations plus pre/post checks instead of parent-fd syscalls.
|
|
71
71
|
|
|
72
72
|
That is usually acceptable when the root directory is only writable by the trusted application user. It is not the right posture if untrusted local processes can race writes in the same tree and you are relying on `fs-safe` as part of the security boundary.
|
|
73
73
|
|
package/docs/root.md
CHANGED
|
@@ -102,14 +102,15 @@ operations that Node's `fs` API does not expose ergonomically.
|
|
|
102
102
|
```ts
|
|
103
103
|
import { configureFsSafePython } from "@openclaw/fs-safe/config";
|
|
104
104
|
|
|
105
|
-
configureFsSafePython({ mode: "off" }); //
|
|
105
|
+
configureFsSafePython({ mode: "off" }); // Node-only fallback path
|
|
106
106
|
configureFsSafePython({ mode: "require" }); // fail if fd-relative helper unavailable
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
`auto` is the default. Configure the mode before creating roots.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
`auto` is the default. Configure the mode before creating roots. Without the
|
|
110
|
+
helper, root methods still run, but same-UID races that swap parent directories
|
|
111
|
+
between validation and mutation are harder to close completely. Use `require`
|
|
112
|
+
when that downgrade should be treated as a deployment failure. See
|
|
113
|
+
[Python helper policy](python-helper.md) for deployment guidance.
|
|
113
114
|
|
|
114
115
|
### Properties
|
|
115
116
|
|
package/docs/secret-file.md
CHANGED
|
@@ -36,7 +36,7 @@ The 16 KiB cap is intentionally aggressive — credentials should be small. If y
|
|
|
36
36
|
|
|
37
37
|
### `tryReadSecretFileSync(filePath, label, options?)`
|
|
38
38
|
|
|
39
|
-
The lenient reader. Returns the trimmed secret string, or `undefined` when the path is missing
|
|
39
|
+
The lenient reader. Returns the trimmed secret string, or `undefined` when the path is missing or blank. Validation failures, unreadable files, oversized files, symlinks, and hardlinks throw `FsSafeError` so callers fail closed on suspicious credential state.
|
|
40
40
|
|
|
41
41
|
```ts
|
|
42
42
|
import { tryReadSecretFileSync } from "@openclaw/fs-safe/secret";
|
|
@@ -63,10 +63,11 @@ const token = readSecretFileSync("/var/lib/app/auth.token");
|
|
|
63
63
|
type SecretFileReadOptions = {
|
|
64
64
|
maxBytes?: number; // default DEFAULT_SECRET_FILE_MAX_BYTES (16 KiB)
|
|
65
65
|
rejectSymlink?: boolean;
|
|
66
|
+
rejectHardlinks?: boolean; // default true
|
|
66
67
|
};
|
|
67
68
|
```
|
|
68
69
|
|
|
69
|
-
The reader trims the file content and rejects empty results. `rejectSymlink` blocks a symlink path before the pinned read.
|
|
70
|
+
The reader trims the file content and rejects empty results. `rejectSymlink` blocks a symlink path before the pinned read. Hardlinks are rejected by default so another in-tree name cannot alias the credential; pass `rejectHardlinks: false` only when you explicitly trust that layout.
|
|
70
71
|
|
|
71
72
|
## Writing
|
|
72
73
|
|
package/docs/timing.md
CHANGED
|
@@ -102,7 +102,7 @@ await extractArchive({
|
|
|
102
102
|
});
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
`extractArchive` already takes `timeoutMs` and
|
|
105
|
+
`extractArchive` already takes `timeoutMs` and carries its own abort signal/deadline checks — you don't need to wrap it. Reach for `withTimeout` for operations that don't carry their own timeout knob.
|
|
106
106
|
|
|
107
107
|
### Disable in tests
|
|
108
108
|
|