feat: make archive dependencies optional

This commit is contained in:
Peter Steinberger 2026-05-05 22:19:59 +01:00
parent 1cd216921d
commit 722cb41390
No known key found for this signature in database
7 changed files with 108 additions and 24 deletions

View File

@ -16,7 +16,7 @@ This is a library-level guardrail, not OS-level isolation. It does not replace c
pnpm add @openclaw/fs-safe
```
Node 20.11 or newer. Core root/path/json/temp helpers avoid framework dependencies; archive helpers use `jszip` and `tar` for ZIP/TAR support.
Node 20.11 or newer. Core root/path/json/temp helpers avoid framework dependencies. Archive helpers use optional `jszip` and `tar` dependencies for ZIP/TAR support; installs that omit optional dependencies can still use every non-archive subpath.
## Quick start

View File

@ -2,6 +2,12 @@
`@openclaw/fs-safe/archive` extracts ZIP and TAR archives behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets. Extraction stages into a private directory and merges through the same safe-open boundary used by direct writes — a symlinked entry can't trick the merge into following an out-of-tree path.
Archive extraction uses optional runtime dependencies: `jszip` for ZIP and `tar`
for TAR. Installs that omit optional dependencies can still import this subpath,
inspect archive kinds, and use pure path/limit helpers, but extraction or ZIP
loading fails with a clear message until the matching optional dependency is
installed.
```ts
import { extractArchive, resolveArchiveKind } from "@openclaw/fs-safe/archive";
```

View File

@ -81,7 +81,7 @@ Use the main entry for the common surface, or the focused subpaths when you want
## Runtime dependencies
`@openclaw/fs-safe` depends on `jszip` and `tar` for [archive extraction](archive.md). Both are loaded lazily — if your code never touches the archive subpath, the runtime cost is negligible.
`@openclaw/fs-safe` lists `jszip` and `tar` as optional dependencies for [archive extraction](archive.md). They are loaded lazily and only required when ZIP/TAR helpers run. Installs that omit optional dependencies can still import and use every non-archive subpath; archive calls fail with a clear missing-optional-dependency message.
There are no peer dependencies and no native build step.

View File

@ -92,7 +92,7 @@
"check": "pnpm build && pnpm test",
"docs:site": "node scripts/build-docs-site.mjs"
},
"dependencies": {
"optionalDependencies": {
"jszip": "^3.10.1",
"tar": "7.5.13"
},

54
pnpm-lock.yaml generated
View File

@ -7,13 +7,6 @@ settings:
importers:
.:
dependencies:
jszip:
specifier: ^3.10.1
version: 3.10.1
tar:
specifier: 7.5.13
version: 7.5.13
devDependencies:
'@types/node':
specifier: ^22.15.19
@ -27,6 +20,13 @@ importers:
vitest:
specifier: ^3.1.4
version: 3.2.4(@types/node@22.19.17)
optionalDependencies:
jszip:
specifier: ^3.10.1
version: 3.10.1
tar:
specifier: 7.5.13
version: 7.5.13
packages:
@ -1002,6 +1002,7 @@ snapshots:
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.3
optional: true
'@istanbuljs/schema@0.1.6': {}
@ -1213,7 +1214,8 @@ snapshots:
check-error@2.1.3: {}
chownr@3.0.0: {}
chownr@3.0.0:
optional: true
color-convert@2.0.1:
dependencies:
@ -1221,7 +1223,8 @@ snapshots:
color-name@1.1.4: {}
core-util-is@1.0.3: {}
core-util-is@1.0.3:
optional: true
cross-spawn@7.0.6:
dependencies:
@ -1303,13 +1306,16 @@ snapshots:
html-escaper@2.0.2: {}
immediate@3.0.6: {}
immediate@3.0.6:
optional: true
inherits@2.0.4: {}
inherits@2.0.4:
optional: true
is-fullwidth-code-point@3.0.0: {}
isarray@1.0.0: {}
isarray@1.0.0:
optional: true
isexe@2.0.0: {}
@ -1350,10 +1356,12 @@ snapshots:
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
optional: true
lie@3.3.0:
dependencies:
immediate: 3.0.6
optional: true
loupe@3.2.1: {}
@ -1386,6 +1394,7 @@ snapshots:
minizlib@3.1.0:
dependencies:
minipass: 7.1.3
optional: true
ms@2.1.3: {}
@ -1393,7 +1402,8 @@ snapshots:
package-json-from-dist@1.0.1: {}
pako@1.0.11: {}
pako@1.0.11:
optional: true
path-key@3.1.1: {}
@ -1416,7 +1426,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
process-nextick-args@2.0.1: {}
process-nextick-args@2.0.1:
optional: true
readable-stream@2.3.8:
dependencies:
@ -1427,6 +1438,7 @@ snapshots:
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
optional: true
rollup@4.60.2:
dependencies:
@ -1459,11 +1471,13 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.2
fsevents: 2.3.3
safe-buffer@5.1.2: {}
safe-buffer@5.1.2:
optional: true
semver@7.7.4: {}
setimmediate@1.0.5: {}
setimmediate@1.0.5:
optional: true
shebang-command@2.0.0:
dependencies:
@ -1496,6 +1510,7 @@ snapshots:
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
optional: true
strip-ansi@6.0.1:
dependencies:
@ -1520,6 +1535,7 @@ snapshots:
minipass: 7.1.3
minizlib: 3.1.0
yallist: 5.0.0
optional: true
test-exclude@7.0.2:
dependencies:
@ -1546,7 +1562,8 @@ snapshots:
undici-types@6.21.0: {}
util-deprecate@1.0.2: {}
util-deprecate@1.0.2:
optional: true
vite-node@3.2.4(@types/node@22.19.17):
dependencies:
@ -1643,4 +1660,5 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.2.0
yallist@5.0.0: {}
yallist@5.0.0:
optional: true

View File

@ -1,4 +1,3 @@
import JSZip from "jszip";
import {
ARCHIVE_LIMIT_ERROR_CODE,
ArchiveLimitError,
@ -7,6 +6,14 @@ import {
type ArchiveExtractLimits,
} from "./archive-limits.js";
export type ZipArchiveWithFiles = {
files: Record<string, unknown>;
};
type JsZipConstructor = {
loadAsync(buffer: Buffer | Uint8Array): Promise<ZipArchiveWithFiles>;
};
const ZIP_EOCD_SIGNATURE = 0x06054b50;
const ZIP64_EOCD_SIGNATURE = 0x06064b50;
const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50;
@ -193,7 +200,7 @@ export function readZipCentralDirectoryEntryCount(buffer: Buffer | Uint8Array):
export async function loadZipArchiveWithPreflight(
buffer: Buffer | Uint8Array,
limits?: ArchiveExtractLimits,
): Promise<JSZip> {
): Promise<ZipArchiveWithFiles> {
const resolvedLimits = resolveExtractLimits(limits);
if (buffer.byteLength > resolvedLimits.maxArchiveBytes) {
throw new ArchiveLimitError(ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT);
@ -202,5 +209,31 @@ export async function loadZipArchiveWithPreflight(
if (entryCount !== null) {
assertArchiveEntryCountWithinLimit(entryCount, resolvedLimits);
}
const JSZip = await importOptionalJsZip();
return await JSZip.loadAsync(buffer);
}
async function importOptionalJsZip(): Promise<JsZipConstructor> {
try {
const module = await import("jszip");
const candidate: unknown =
typeof module === "function" ? module : (module as { default?: unknown }).default;
if (
(typeof candidate !== "object" && typeof candidate !== "function") ||
candidate === null ||
typeof (candidate as { loadAsync?: unknown }).loadAsync !== "function"
) {
throw new Error('Optional archive dependency "jszip" does not expose loadAsync().');
}
return candidate as JsZipConstructor;
} catch (err) {
throw missingOptionalArchiveDependencyError("jszip", err);
}
}
function missingOptionalArchiveDependencyError(packageName: "jszip", cause: unknown): Error {
return new Error(
`Optional archive dependency "${packageName}" is not installed. Install it to use ZIP archive helpers from @openclaw/fs-safe/archive.`,
{ cause },
);
}

View File

@ -5,7 +5,6 @@ import fs from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import * as tar from "tar";
import {
resolveArchiveOutputPath,
stripArchivePath,
@ -71,6 +70,7 @@ export { createTarEntryPreflightChecker, type TarEntryInfo } from "./archive-tar
export {
loadZipArchiveWithPreflight,
readZipCentralDirectoryEntryCount,
type ZipArchiveWithFiles,
} from "./archive-zip-preflight.js";
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
@ -111,6 +111,17 @@ type ZipEntry = {
};
type ZipExtractBudget = ReturnType<typeof createByteBudgetTracker>;
type TarModule = {
x(options: {
file: string;
cwd: string;
strip: number;
gzip?: boolean;
preservePaths: false;
strict: true;
onReadEntry(this: unknown, entry: unknown): void;
}): Promise<unknown>;
};
const ZIP_UNIX_FILE_TYPE_MASK = 0o170000;
const ZIP_UNIX_SYMLINK_TYPE = 0o120000;
@ -304,6 +315,7 @@ export async function extractArchive(params: {
if (kind === "tar") {
await withTimeout(
(async () => {
const tar = await importOptionalTar();
const limits = resolveExtractLimits(params.limits);
const stat = await fs.stat(params.archivePath);
if (stat.size > limits.maxArchiveBytes) {
@ -367,3 +379,18 @@ export async function extractArchive(params: {
label,
);
}
async function importOptionalTar(): Promise<TarModule> {
try {
return await import("tar");
} catch (err) {
throw missingOptionalArchiveDependencyError("tar", err);
}
}
function missingOptionalArchiveDependencyError(packageName: "tar", cause: unknown): Error {
return new Error(
`Optional archive dependency "${packageName}" is not installed. Install it to use TAR archive helpers from @openclaw/fs-safe/archive.`,
{ cause },
);
}