diff --git a/README.md b/README.md index 67e2094..e59da9b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/archive.md b/docs/archive.md index 149bfcb..390e422 100644 --- a/docs/archive.md +++ b/docs/archive.md @@ -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"; ``` diff --git a/docs/install.md b/docs/install.md index 5f3c0d3..4f83bc9 100644 --- a/docs/install.md +++ b/docs/install.md @@ -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. diff --git a/package.json b/package.json index 0f56174..364385c 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0c675b..f92f05e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/archive-zip-preflight.ts b/src/archive-zip-preflight.ts index c2535fa..e9ccbea 100644 --- a/src/archive-zip-preflight.ts +++ b/src/archive-zip-preflight.ts @@ -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; +}; + +type JsZipConstructor = { + loadAsync(buffer: Buffer | Uint8Array): Promise; +}; + 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 { +): Promise { 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 { + 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 }, + ); +} diff --git a/src/archive.ts b/src/archive.ts index 524e0b8..3bb19f7 100644 --- a/src/archive.ts +++ b/src/archive.ts @@ -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; +type TarModule = { + x(options: { + file: string; + cwd: string; + strip: number; + gzip?: boolean; + preservePaths: false; + strict: true; + onReadEntry(this: unknown, entry: unknown): void; + }): Promise; +}; 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 { + 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 }, + ); +}