@openclaw/fs-safe 0.2.5 → 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 +13 -0
- package/README.md +1 -1
- 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/file-store-boundary.js +2 -2
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +6 -6
- 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/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +3 -0
- package/dist/pinned-python.d.ts.map +1 -1
- package/dist/pinned-python.js +45 -38
- package/dist/pinned-write.d.ts +3 -1
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +45 -42
- package/dist/root-path.js +2 -2
- 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 +99 -53
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +9 -3
- package/dist/symlink-parents.js +2 -2
- package/dist/temp-target.js +1 -1
- 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,18 @@
|
|
|
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
|
+
|
|
10
|
+
## 0.2.6 - 2026-05-17
|
|
11
|
+
|
|
12
|
+
### Security and Correctness
|
|
13
|
+
|
|
14
|
+
- Harden DeepSec-reported temp path handling, private secret writes, pinned helper commits, fallback mkdir writes, and dot-prefixed root relative paths against symlink, race, and relative-path edge cases.
|
|
15
|
+
|
|
3
16
|
## 0.2.5 - 2026-05-16
|
|
4
17
|
|
|
5
18
|
### Security and Correctness
|
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ 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; use Node fallbacks
|
|
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
|
|
|
@@ -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 {
|
|
@@ -5,7 +5,7 @@ import { Transform } from "node:stream";
|
|
|
5
5
|
import { pipeline } from "node:stream/promises";
|
|
6
6
|
import { assertSyncDirectoryGuard as assertDirectoryGuardSync, createSyncDirectoryGuard, } from "./directory-guard.js";
|
|
7
7
|
import { FsSafeError } from "./errors.js";
|
|
8
|
-
import { isPathInside } from "./path.js";
|
|
8
|
+
import { isPathInside, isPathRelativeEscape } from "./path.js";
|
|
9
9
|
import { resolveOpenedFileRealPathForHandle, root } from "./root.js";
|
|
10
10
|
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
11
11
|
function parentRelativePath(relativePath) {
|
|
@@ -131,7 +131,7 @@ export function ensureParentSync(params) {
|
|
|
131
131
|
const rootDir = path.resolve(params.rootDir);
|
|
132
132
|
const dir = path.dirname(path.resolve(params.filePath));
|
|
133
133
|
const relative = path.relative(rootDir, dir);
|
|
134
|
-
if (
|
|
134
|
+
if (isPathRelativeEscape(relative)) {
|
|
135
135
|
throw new FsSafeError("outside-workspace", "file path escapes store root");
|
|
136
136
|
}
|
|
137
137
|
syncFs.mkdirSync(rootDir, { recursive: true, mode: params.mode });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guarded-mkdir.d.ts","sourceRoot":"","sources":["../src/guarded-mkdir.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"guarded-mkdir.d.ts","sourceRoot":"","sources":["../src/guarded-mkdir.ts"],"names":[],"mappings":"AAWA,wBAAsB,6BAA6B,CAAC,MAAM,EAAE;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACnE,GAAG,OAAO,CAAC,IAAI,CAAC,CAkChB"}
|