@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 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;AAiGD,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,CAkBb;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,CAyEhB;AAED,wBAAgB,kCAAkC,CAAC,YAAY,EAAE,MAAM,GAAG,oBAAoB,CAE7F"}
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"}
@@ -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
- await fs.rm(destinationPath, { force: true }).catch(() => undefined);
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 stagingDir = await fs.mkdtemp(path.join(stagingRoot, params.stagingDirPrefix ?? "fs-safe-archive-"));
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
- await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
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,
@@ -1 +1 @@
1
- {"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"AAWA,OAAO,EAOL,KAAK,oBAAoB,EAC1B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAgBzE,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;AA8MpC,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,CAyEhB"}
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 { withTimeout } from "./timing.js";
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
- await pipeline(readable, createExtractBudgetTransform({ onChunkBytes: params.budget.addBytes }), writable);
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 destinationRealDir = await prepareArchiveDestinationDir(params.destDir);
109
- const stat = await fs.stat(params.archivePath);
110
- if (stat.size > limits.maxArchiveBytes) {
111
- throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT);
112
- }
113
- const buffer = await fs.readFile(params.archivePath);
114
- const zip = await loadZipArchiveWithPreflight(buffer, limits);
115
- const entries = Object.values(zip.files);
116
- const strip = Math.max(0, Math.floor(params.stripComponents ?? 0));
117
- assertArchiveEntryCountWithinLimit(entries.length, limits);
118
- const budget = createByteBudgetTracker(limits);
119
- await withStagedArchiveDestination({
120
- destinationRealDir,
121
- run: async (stagingDir) => {
122
- const stagingRealDir = await fs.realpath(stagingDir);
123
- for (const entry of entries) {
124
- const output = resolveZipOutputPath({
125
- entryPath: entry.name,
126
- strip,
127
- destinationDir: stagingRealDir,
128
- });
129
- if (!output) {
130
- continue;
131
- }
132
- await prepareZipOutputPath({
133
- destinationDir: stagingRealDir,
134
- destinationRealDir: stagingRealDir,
135
- relPath: output.relPath,
136
- outPath: output.outPath,
137
- originalPath: entry.name,
138
- isDirectory: entry.dir,
139
- });
140
- if (entry.dir) {
141
- continue;
142
- }
143
- if (isZipSymlinkEntry(entry)) {
144
- throw new Error(`zip entry is a link: ${entry.name}`);
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
- await writeZipFileEntry({
147
- entry,
148
- outPath: output.outPath,
149
- budget,
242
+ params.deadline.check();
243
+ await mergeExtractedTreeIntoDestination({
244
+ sourceDir: stagingRealDir,
245
+ destinationDir: params.destDir,
246
+ destinationRealDir,
150
247
  });
151
- }
152
- await mergeExtractedTreeIntoDestination({
153
- sourceDir: stagingRealDir,
154
- destinationDir: params.destDir,
155
- destinationRealDir,
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 withTimeout((async () => {
263
+ await withExtractionDeadline(params.timeoutMs, label, async (deadline) => {
168
264
  const tar = await importOptionalTar();
169
265
  const limits = resolveExtractLimits(params.limits);
170
- const stat = await fs.stat(params.archivePath);
171
- if (stat.size > limits.maxArchiveBytes) {
172
- throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT);
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
- })(), params.timeoutMs, label);
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 withTimeout(extractZip({
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
- }), params.timeoutMs, label);
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 (relative.startsWith("..") || path.isAbsolute(relative)) {
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":"AAaA,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,CAiChB"}
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"}