@nimblebrain/mpak-sdk
Advanced tools
+186
-29
| "use strict"; | ||
| var __create = Object.create; | ||
| var __defProp = Object.defineProperty; | ||
| var __getOwnPropDesc = Object.getOwnPropertyDescriptor; | ||
| var __getOwnPropNames = Object.getOwnPropertyNames; | ||
| var __getProtoOf = Object.getPrototypeOf; | ||
| var __hasOwnProp = Object.prototype.hasOwnProperty; | ||
@@ -18,2 +20,10 @@ var __export = (target, all) => { | ||
| }; | ||
| var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( | ||
| // If the importer is in node compatibility mode or this is not an ESM | ||
| // file that has been converted to a CommonJS file using a Babel- | ||
| // compatible transform (i.e. "__esModule" has not been set), then set | ||
| // "default" to the CommonJS "module.exports" for node compatibility. | ||
| isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, | ||
| mod | ||
| )); | ||
| var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); | ||
@@ -24,2 +34,3 @@ | ||
| __export(index_exports, { | ||
| MAX_UNCOMPRESSED_SIZE: () => MAX_UNCOMPRESSED_SIZE, | ||
| Mpak: () => Mpak, | ||
@@ -37,2 +48,4 @@ MpakBundleCache: () => MpakBundleCache, | ||
| MpakNotFoundError: () => MpakNotFoundError, | ||
| extractZip: () => extractZip, | ||
| isSemverEqual: () => isSemverEqual, | ||
| parsePackageSpec: () => parsePackageSpec | ||
@@ -43,3 +56,3 @@ }); | ||
| // src/mpakSDK.ts | ||
| var import_node_child_process2 = require("child_process"); | ||
| var import_node_child_process = require("child_process"); | ||
| var import_node_fs4 = require("fs"); | ||
@@ -439,7 +452,9 @@ var import_node_path4 = require("path"); | ||
| // src/helpers.ts | ||
| var import_node_child_process = require("child_process"); | ||
| var import_node_crypto2 = require("crypto"); | ||
| var import_node_fs = require("fs"); | ||
| var import_node_path = require("path"); | ||
| var MAX_UNCOMPRESSED_SIZE = 500 * 1024 * 1024; | ||
| var import_node_stream = require("stream"); | ||
| var import_promises = require("stream/promises"); | ||
| var import_yauzl = __toESM(require("yauzl"), 1); | ||
| var MAX_UNCOMPRESSED_SIZE = 2 * 1024 * 1024 * 1024; | ||
| var UPDATE_CHECK_TTL_MS = 60 * 60 * 1e3; | ||
@@ -449,34 +464,163 @@ function isSemverEqual(a, b) { | ||
| } | ||
| function extractZip(zipPath, destDir) { | ||
| async function extractZip(zipPath, destDir, options = {}) { | ||
| const maxSize = options.maxUncompressedSize ?? MAX_UNCOMPRESSED_SIZE; | ||
| let zipfile; | ||
| try { | ||
| const listOutput = (0, import_node_child_process.execFileSync)("unzip", ["-l", zipPath], { | ||
| stdio: "pipe", | ||
| encoding: "utf8" | ||
| }); | ||
| const totalMatch = listOutput.match(/^\s*(\d+)\s+\d+\s+files?$/m); | ||
| if (totalMatch) { | ||
| const totalSize = parseInt(totalMatch[1] ?? "0", 10); | ||
| if (totalSize > MAX_UNCOMPRESSED_SIZE) { | ||
| zipfile = await openZip(zipPath); | ||
| } catch (error) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Cannot open bundle archive: ${errorMessage(error)}`, | ||
| zipPath, | ||
| error instanceof Error ? error : void 0 | ||
| ); | ||
| } | ||
| try { | ||
| let entries; | ||
| try { | ||
| entries = await readAllEntries(zipfile); | ||
| } catch (error) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Invalid bundle entry: ${errorMessage(error)}`, | ||
| zipPath, | ||
| error instanceof Error ? error : void 0 | ||
| ); | ||
| } | ||
| const declaredTotal = entries.reduce((sum, e) => sum + e.uncompressedSize, 0); | ||
| if (declaredTotal > maxSize) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Bundle uncompressed size (${formatMB(declaredTotal)}MB) exceeds maximum allowed (${formatMB(maxSize)}MB)`, | ||
| zipPath | ||
| ); | ||
| } | ||
| const normalizedDest = (0, import_node_path.resolve)(destDir); | ||
| (0, import_node_fs.mkdirSync)(normalizedDest, { recursive: true }); | ||
| let cumulativeBytes = 0; | ||
| for (const entry of entries) { | ||
| const safePath = resolveEntryPath(normalizedDest, entry.fileName, zipPath); | ||
| if (isSymlink(entry)) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Bundle uncompressed size (${Math.round(totalSize / 1024 / 1024)}MB) exceeds maximum allowed (${MAX_UNCOMPRESSED_SIZE / (1024 * 1024)}MB)`, | ||
| `Bundle contains a symlink entry (${entry.fileName}); symlinks are not permitted`, | ||
| zipPath | ||
| ); | ||
| } | ||
| if (isDirectoryEntry(entry)) { | ||
| (0, import_node_fs.mkdirSync)(safePath, { recursive: true }); | ||
| continue; | ||
| } | ||
| (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(safePath), { recursive: true }); | ||
| try { | ||
| const readStream = await openReadStream(zipfile, entry); | ||
| const counter = createByteCounter( | ||
| entry, | ||
| zipPath, | ||
| () => cumulativeBytes, | ||
| maxSize, | ||
| (n) => { | ||
| cumulativeBytes = n; | ||
| } | ||
| ); | ||
| await (0, import_promises.pipeline)(readStream, counter, (0, import_node_fs.createWriteStream)(safePath)); | ||
| } catch (error) { | ||
| if (error instanceof MpakCacheCorruptedError) throw error; | ||
| throw new MpakCacheCorruptedError( | ||
| `Failed to extract entry ${entry.fileName}: ${errorMessage(error)}`, | ||
| zipPath, | ||
| error instanceof Error ? error : void 0 | ||
| ); | ||
| } | ||
| const mode = unixMode(entry); | ||
| if (mode !== null) { | ||
| (0, import_node_fs.chmodSync)(safePath, mode); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| if (error instanceof MpakCacheCorruptedError) { | ||
| throw error; | ||
| } | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| } finally { | ||
| zipfile.close(); | ||
| } | ||
| } | ||
| function openZip(path) { | ||
| return new Promise((res, rej) => { | ||
| import_yauzl.default.open(path, { lazyEntries: false, autoClose: false }, (err, zipfile) => { | ||
| if (err || !zipfile) rej(err ?? new Error("yauzl returned no zipfile")); | ||
| else res(zipfile); | ||
| }); | ||
| }); | ||
| } | ||
| function readAllEntries(zipfile) { | ||
| return new Promise((res, rej) => { | ||
| const entries = []; | ||
| zipfile.on("entry", (entry) => entries.push(entry)); | ||
| zipfile.on("end", () => res(entries)); | ||
| zipfile.on("error", rej); | ||
| }); | ||
| } | ||
| function openReadStream(zipfile, entry) { | ||
| return new Promise((res, rej) => { | ||
| zipfile.openReadStream(entry, (err, stream) => { | ||
| if (err || !stream) rej(err ?? new Error("yauzl returned no read stream")); | ||
| else res(stream); | ||
| }); | ||
| }); | ||
| } | ||
| function resolveEntryPath(normalizedDest, entryName, zipPath) { | ||
| if (entryName.includes("\0")) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Cannot verify bundle size before extraction: ${message}`, | ||
| zipPath, | ||
| error instanceof Error ? error : void 0 | ||
| `Bundle entry name contains NUL byte: ${JSON.stringify(entryName)}`, | ||
| zipPath | ||
| ); | ||
| } | ||
| (0, import_node_fs.mkdirSync)(destDir, { recursive: true }); | ||
| (0, import_node_child_process.execFileSync)("unzip", ["-o", "-q", zipPath, "-d", destDir], { | ||
| stdio: "pipe" | ||
| const candidate = (0, import_node_path.resolve)(normalizedDest, entryName); | ||
| if (candidate !== normalizedDest && !candidate.startsWith(normalizedDest + import_node_path.sep)) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Bundle entry escapes destination directory: ${entryName}`, | ||
| zipPath | ||
| ); | ||
| } | ||
| return candidate; | ||
| } | ||
| function isDirectoryEntry(entry) { | ||
| return /\/$/.test(entry.fileName); | ||
| } | ||
| function isSymlink(entry) { | ||
| const mode = entry.externalFileAttributes >>> 16; | ||
| return (mode & 61440) === 40960; | ||
| } | ||
| function unixMode(entry) { | ||
| const mode = entry.externalFileAttributes >>> 16 & 511; | ||
| return mode === 0 ? null : mode; | ||
| } | ||
| function createByteCounter(entry, zipPath, getCumulative, maxSize, setCumulative) { | ||
| let entryBytes = 0; | ||
| return new import_node_stream.Transform({ | ||
| transform(chunk, _enc, cb) { | ||
| entryBytes += chunk.length; | ||
| if (entryBytes > entry.uncompressedSize) { | ||
| cb( | ||
| new MpakCacheCorruptedError( | ||
| `Entry ${entry.fileName} exceeds its declared uncompressed size (possible zip bomb)`, | ||
| zipPath | ||
| ) | ||
| ); | ||
| return; | ||
| } | ||
| const next = getCumulative() + chunk.length; | ||
| if (next > maxSize) { | ||
| cb( | ||
| new MpakCacheCorruptedError( | ||
| `Bundle exceeds maximum uncompressed size (${formatMB(maxSize)}MB) during extraction`, | ||
| zipPath | ||
| ) | ||
| ); | ||
| return; | ||
| } | ||
| setCumulative(next); | ||
| cb(null, chunk); | ||
| } | ||
| }); | ||
| } | ||
| function formatMB(bytes) { | ||
| return Math.round(bytes / (1024 * 1024)); | ||
| } | ||
| function errorMessage(error) { | ||
| return error instanceof Error ? error.message : String(error); | ||
| } | ||
| function hashBundlePath(bundlePath) { | ||
@@ -520,2 +664,3 @@ return (0, import_node_crypto2.createHash)("md5").update((0, import_node_path.resolve)(bundlePath)).digest("hex").slice(0, 12); | ||
| cacheHome; | ||
| maxUncompressedSize; | ||
| mpakClient; | ||
@@ -525,2 +670,3 @@ constructor(client, options) { | ||
| this.cacheHome = (0, import_node_path2.join)(options?.mpakHome ?? (0, import_node_path2.join)((0, import_node_os.homedir)(), ".mpak"), "cache"); | ||
| this.maxUncompressedSize = options?.maxUncompressedSize; | ||
| } | ||
@@ -711,3 +857,3 @@ /** | ||
| } | ||
| extractZip(tempPath, cacheDir); | ||
| await extractZip(tempPath, cacheDir, this.extractOptions()); | ||
| this.writeCacheMetadata(name, { | ||
@@ -722,2 +868,6 @@ version: bundle.version, | ||
| } | ||
| /** Options threaded into every `extractZip` call. */ | ||
| extractOptions() { | ||
| return this.maxUncompressedSize !== void 0 ? { maxUncompressedSize: this.maxUncompressedSize } : {}; | ||
| } | ||
| }; | ||
@@ -952,5 +1102,9 @@ | ||
| this.client = new MpakClient(clientConfig); | ||
| this.bundleCache = new MpakBundleCache(this.client, { | ||
| const cacheOptions = { | ||
| mpakHome: this.configManager.mpakHome | ||
| }); | ||
| }; | ||
| if (options?.maxUncompressedSize !== void 0) { | ||
| cacheOptions.maxUncompressedSize = options.maxUncompressedSize; | ||
| } | ||
| this.bundleCache = new MpakBundleCache(this.client, cacheOptions); | ||
| } | ||
@@ -1035,3 +1189,3 @@ /** | ||
| try { | ||
| extractZip(absolutePath, cacheDir); | ||
| await extractZip(absolutePath, cacheDir, this.bundleCache.extractOptions()); | ||
| } catch (err) { | ||
@@ -1226,3 +1380,3 @@ throw new MpakInvalidBundleError( | ||
| static findPythonCommand() { | ||
| const result = (0, import_node_child_process2.spawnSync)("python3", ["--version"], { stdio: "pipe" }); | ||
| const result = (0, import_node_child_process.spawnSync)("python3", ["--version"], { stdio: "pipe" }); | ||
| if (result.status === 0) { | ||
@@ -1272,2 +1426,3 @@ return "python3"; | ||
| 0 && (module.exports = { | ||
| MAX_UNCOMPRESSED_SIZE, | ||
| Mpak, | ||
@@ -1285,4 +1440,6 @@ MpakBundleCache, | ||
| MpakNotFoundError, | ||
| extractZip, | ||
| isSemverEqual, | ||
| parsePackageSpec | ||
| }); | ||
| //# sourceMappingURL=index.cjs.map |
+61
-1
@@ -129,2 +129,7 @@ import { BundleSearchParamsInput, BundleSearchResponse, BundleDetail, VersionsResponse, VersionDetail, PlatformInfo, DownloadInfo, SkillSearchParamsInput, SkillSearchResponse, SkillDetail, SkillDownloadInfo, CacheMetadata, McpbManifest, CachedBundleInfo } from '@nimblebrain/mpak-schemas'; | ||
| mpakHome?: string; | ||
| /** | ||
| * Maximum allowed uncompressed size (bytes) for any bundle this cache | ||
| * extracts. Defaults to {@link MAX_UNCOMPRESSED_SIZE}. | ||
| */ | ||
| maxUncompressedSize?: number; | ||
| } | ||
@@ -155,2 +160,3 @@ /** | ||
| readonly cacheHome: string; | ||
| readonly maxUncompressedSize: number | undefined; | ||
| private readonly mpakClient; | ||
@@ -231,2 +237,6 @@ constructor(client: MpakClient, options?: MpakBundleCacheOptions); | ||
| private downloadAndExtract; | ||
| /** Options threaded into every `extractZip` call. */ | ||
| extractOptions(): { | ||
| maxUncompressedSize?: number; | ||
| }; | ||
| } | ||
@@ -376,2 +386,10 @@ | ||
| userAgent?: string; | ||
| /** | ||
| * Maximum allowed uncompressed size (bytes) for any bundle this SDK | ||
| * extracts. Defaults to the SDK's built-in cap (see | ||
| * `MAX_UNCOMPRESSED_SIZE` in `helpers.ts`). Raise this for bundles that | ||
| * vendor heavy ML/Python dependencies; lower it to enforce a stricter | ||
| * policy. | ||
| */ | ||
| maxUncompressedSize?: number; | ||
| } | ||
@@ -579,2 +597,44 @@ /** | ||
| /** | ||
| * Default maximum allowed uncompressed size for a bundle (2GB). | ||
| * | ||
| * Override per-consumer via `MpakBundleCache` / `Mpak` options | ||
| * (`maxUncompressedSize`), or per-call via the `extractZip` options. | ||
| */ | ||
| declare const MAX_UNCOMPRESSED_SIZE: number; | ||
| /** Options for {@link extractZip}. */ | ||
| interface ExtractZipOptions { | ||
| /** | ||
| * Maximum total uncompressed size in bytes. Defaults to | ||
| * {@link MAX_UNCOMPRESSED_SIZE}. Enforced against both the declared | ||
| * central-directory sizes and the actual bytes written during extraction | ||
| * (the latter defeats zip bombs that lie about declared sizes). | ||
| */ | ||
| maxUncompressedSize?: number; | ||
| } | ||
| /** | ||
| * Compare two semver strings for equality, ignoring leading 'v' prefix. | ||
| */ | ||
| declare function isSemverEqual(a: string, b: string): boolean; | ||
| /** | ||
| * Extract a ZIP file to a directory, streaming entries to disk. | ||
| * | ||
| * Guarantees: | ||
| * - **Zip-bomb protection:** the declared total uncompressed size (from the | ||
| * central directory) is checked before any files are written; during | ||
| * extraction, per-entry and cumulative byte counts are tracked and | ||
| * extraction aborts if either would exceed limits. | ||
| * - **Path-traversal safe:** entry paths are resolved against `destDir` and | ||
| * any entry that escapes the destination is rejected. | ||
| * - **Symlinks rejected:** bundles should not contain symlinks; their | ||
| * presence fails extraction. | ||
| * - **Unix mode bits preserved** for entries that declare them (matches the | ||
| * behavior of the prior `unzip` shell-out). | ||
| * | ||
| * Uses a pure-JS zip library — no dependency on the `unzip` binary. | ||
| * | ||
| * @throws {MpakCacheCorruptedError} for any format, size, or safety violation. | ||
| */ | ||
| declare function extractZip(zipPath: string, destDir: string, options?: ExtractZipOptions): Promise<void>; | ||
| /** | ||
| * Base error class for mpak SDK errors | ||
@@ -692,2 +752,2 @@ */ | ||
| export { Mpak, MpakBundleCache, type MpakBundleCacheOptions, MpakCacheCorruptedError, MpakClient, type MpakClientConfig, MpakConfigCorruptedError, MpakConfigError, MpakConfigManager, type MpakConfigManagerOptions, MpakError, MpakIntegrityError, MpakInvalidBundleError, MpakNetworkError, MpakNotFoundError, type MpakOptions, type PackageConfig, type PrepareServerOptions, type PrepareServerSpec, type ServerCommand, parsePackageSpec }; | ||
| export { type ExtractZipOptions, MAX_UNCOMPRESSED_SIZE, Mpak, MpakBundleCache, type MpakBundleCacheOptions, MpakCacheCorruptedError, MpakClient, type MpakClientConfig, MpakConfigCorruptedError, MpakConfigError, MpakConfigManager, type MpakConfigManagerOptions, MpakError, MpakIntegrityError, MpakInvalidBundleError, MpakNetworkError, MpakNotFoundError, type MpakOptions, type PackageConfig, type PrepareServerOptions, type PrepareServerSpec, type ServerCommand, extractZip, isSemverEqual, parsePackageSpec }; |
+61
-1
@@ -129,2 +129,7 @@ import { BundleSearchParamsInput, BundleSearchResponse, BundleDetail, VersionsResponse, VersionDetail, PlatformInfo, DownloadInfo, SkillSearchParamsInput, SkillSearchResponse, SkillDetail, SkillDownloadInfo, CacheMetadata, McpbManifest, CachedBundleInfo } from '@nimblebrain/mpak-schemas'; | ||
| mpakHome?: string; | ||
| /** | ||
| * Maximum allowed uncompressed size (bytes) for any bundle this cache | ||
| * extracts. Defaults to {@link MAX_UNCOMPRESSED_SIZE}. | ||
| */ | ||
| maxUncompressedSize?: number; | ||
| } | ||
@@ -155,2 +160,3 @@ /** | ||
| readonly cacheHome: string; | ||
| readonly maxUncompressedSize: number | undefined; | ||
| private readonly mpakClient; | ||
@@ -231,2 +237,6 @@ constructor(client: MpakClient, options?: MpakBundleCacheOptions); | ||
| private downloadAndExtract; | ||
| /** Options threaded into every `extractZip` call. */ | ||
| extractOptions(): { | ||
| maxUncompressedSize?: number; | ||
| }; | ||
| } | ||
@@ -376,2 +386,10 @@ | ||
| userAgent?: string; | ||
| /** | ||
| * Maximum allowed uncompressed size (bytes) for any bundle this SDK | ||
| * extracts. Defaults to the SDK's built-in cap (see | ||
| * `MAX_UNCOMPRESSED_SIZE` in `helpers.ts`). Raise this for bundles that | ||
| * vendor heavy ML/Python dependencies; lower it to enforce a stricter | ||
| * policy. | ||
| */ | ||
| maxUncompressedSize?: number; | ||
| } | ||
@@ -579,2 +597,44 @@ /** | ||
| /** | ||
| * Default maximum allowed uncompressed size for a bundle (2GB). | ||
| * | ||
| * Override per-consumer via `MpakBundleCache` / `Mpak` options | ||
| * (`maxUncompressedSize`), or per-call via the `extractZip` options. | ||
| */ | ||
| declare const MAX_UNCOMPRESSED_SIZE: number; | ||
| /** Options for {@link extractZip}. */ | ||
| interface ExtractZipOptions { | ||
| /** | ||
| * Maximum total uncompressed size in bytes. Defaults to | ||
| * {@link MAX_UNCOMPRESSED_SIZE}. Enforced against both the declared | ||
| * central-directory sizes and the actual bytes written during extraction | ||
| * (the latter defeats zip bombs that lie about declared sizes). | ||
| */ | ||
| maxUncompressedSize?: number; | ||
| } | ||
| /** | ||
| * Compare two semver strings for equality, ignoring leading 'v' prefix. | ||
| */ | ||
| declare function isSemverEqual(a: string, b: string): boolean; | ||
| /** | ||
| * Extract a ZIP file to a directory, streaming entries to disk. | ||
| * | ||
| * Guarantees: | ||
| * - **Zip-bomb protection:** the declared total uncompressed size (from the | ||
| * central directory) is checked before any files are written; during | ||
| * extraction, per-entry and cumulative byte counts are tracked and | ||
| * extraction aborts if either would exceed limits. | ||
| * - **Path-traversal safe:** entry paths are resolved against `destDir` and | ||
| * any entry that escapes the destination is rejected. | ||
| * - **Symlinks rejected:** bundles should not contain symlinks; their | ||
| * presence fails extraction. | ||
| * - **Unix mode bits preserved** for entries that declare them (matches the | ||
| * behavior of the prior `unzip` shell-out). | ||
| * | ||
| * Uses a pure-JS zip library — no dependency on the `unzip` binary. | ||
| * | ||
| * @throws {MpakCacheCorruptedError} for any format, size, or safety violation. | ||
| */ | ||
| declare function extractZip(zipPath: string, destDir: string, options?: ExtractZipOptions): Promise<void>; | ||
| /** | ||
| * Base error class for mpak SDK errors | ||
@@ -692,2 +752,2 @@ */ | ||
| export { Mpak, MpakBundleCache, type MpakBundleCacheOptions, MpakCacheCorruptedError, MpakClient, type MpakClientConfig, MpakConfigCorruptedError, MpakConfigError, MpakConfigManager, type MpakConfigManagerOptions, MpakError, MpakIntegrityError, MpakInvalidBundleError, MpakNetworkError, MpakNotFoundError, type MpakOptions, type PackageConfig, type PrepareServerOptions, type PrepareServerSpec, type ServerCommand, parsePackageSpec }; | ||
| export { type ExtractZipOptions, MAX_UNCOMPRESSED_SIZE, Mpak, MpakBundleCache, type MpakBundleCacheOptions, MpakCacheCorruptedError, MpakClient, type MpakClientConfig, MpakConfigCorruptedError, MpakConfigError, MpakConfigManager, type MpakConfigManagerOptions, MpakError, MpakIntegrityError, MpakInvalidBundleError, MpakNetworkError, MpakNotFoundError, type MpakOptions, type PackageConfig, type PrepareServerOptions, type PrepareServerSpec, type ServerCommand, extractZip, isSemverEqual, parsePackageSpec }; |
+182
-31
| // src/mpakSDK.ts | ||
| import { spawnSync } from "child_process"; | ||
| import { chmodSync, existsSync as existsSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs"; | ||
| import { chmodSync as chmodSync2, existsSync as existsSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs"; | ||
| import { join as join4, resolve as resolve3 } from "path"; | ||
@@ -397,7 +397,16 @@ import { McpbManifestSchema as McpbManifestSchema2 } from "@nimblebrain/mpak-schemas"; | ||
| // src/helpers.ts | ||
| import { execFileSync } from "child_process"; | ||
| import { createHash as createHash2 } from "crypto"; | ||
| import { existsSync, mkdirSync, readFileSync, statSync } from "fs"; | ||
| import { resolve, join } from "path"; | ||
| var MAX_UNCOMPRESSED_SIZE = 500 * 1024 * 1024; | ||
| import { | ||
| chmodSync, | ||
| createWriteStream, | ||
| existsSync, | ||
| mkdirSync, | ||
| readFileSync, | ||
| statSync | ||
| } from "fs"; | ||
| import { dirname, join, resolve, sep } from "path"; | ||
| import { Transform } from "stream"; | ||
| import { pipeline } from "stream/promises"; | ||
| import yauzl from "yauzl"; | ||
| var MAX_UNCOMPRESSED_SIZE = 2 * 1024 * 1024 * 1024; | ||
| var UPDATE_CHECK_TTL_MS = 60 * 60 * 1e3; | ||
@@ -407,34 +416,163 @@ function isSemverEqual(a, b) { | ||
| } | ||
| function extractZip(zipPath, destDir) { | ||
| async function extractZip(zipPath, destDir, options = {}) { | ||
| const maxSize = options.maxUncompressedSize ?? MAX_UNCOMPRESSED_SIZE; | ||
| let zipfile; | ||
| try { | ||
| const listOutput = execFileSync("unzip", ["-l", zipPath], { | ||
| stdio: "pipe", | ||
| encoding: "utf8" | ||
| }); | ||
| const totalMatch = listOutput.match(/^\s*(\d+)\s+\d+\s+files?$/m); | ||
| if (totalMatch) { | ||
| const totalSize = parseInt(totalMatch[1] ?? "0", 10); | ||
| if (totalSize > MAX_UNCOMPRESSED_SIZE) { | ||
| zipfile = await openZip(zipPath); | ||
| } catch (error) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Cannot open bundle archive: ${errorMessage(error)}`, | ||
| zipPath, | ||
| error instanceof Error ? error : void 0 | ||
| ); | ||
| } | ||
| try { | ||
| let entries; | ||
| try { | ||
| entries = await readAllEntries(zipfile); | ||
| } catch (error) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Invalid bundle entry: ${errorMessage(error)}`, | ||
| zipPath, | ||
| error instanceof Error ? error : void 0 | ||
| ); | ||
| } | ||
| const declaredTotal = entries.reduce((sum, e) => sum + e.uncompressedSize, 0); | ||
| if (declaredTotal > maxSize) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Bundle uncompressed size (${formatMB(declaredTotal)}MB) exceeds maximum allowed (${formatMB(maxSize)}MB)`, | ||
| zipPath | ||
| ); | ||
| } | ||
| const normalizedDest = resolve(destDir); | ||
| mkdirSync(normalizedDest, { recursive: true }); | ||
| let cumulativeBytes = 0; | ||
| for (const entry of entries) { | ||
| const safePath = resolveEntryPath(normalizedDest, entry.fileName, zipPath); | ||
| if (isSymlink(entry)) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Bundle uncompressed size (${Math.round(totalSize / 1024 / 1024)}MB) exceeds maximum allowed (${MAX_UNCOMPRESSED_SIZE / (1024 * 1024)}MB)`, | ||
| `Bundle contains a symlink entry (${entry.fileName}); symlinks are not permitted`, | ||
| zipPath | ||
| ); | ||
| } | ||
| if (isDirectoryEntry(entry)) { | ||
| mkdirSync(safePath, { recursive: true }); | ||
| continue; | ||
| } | ||
| mkdirSync(dirname(safePath), { recursive: true }); | ||
| try { | ||
| const readStream = await openReadStream(zipfile, entry); | ||
| const counter = createByteCounter( | ||
| entry, | ||
| zipPath, | ||
| () => cumulativeBytes, | ||
| maxSize, | ||
| (n) => { | ||
| cumulativeBytes = n; | ||
| } | ||
| ); | ||
| await pipeline(readStream, counter, createWriteStream(safePath)); | ||
| } catch (error) { | ||
| if (error instanceof MpakCacheCorruptedError) throw error; | ||
| throw new MpakCacheCorruptedError( | ||
| `Failed to extract entry ${entry.fileName}: ${errorMessage(error)}`, | ||
| zipPath, | ||
| error instanceof Error ? error : void 0 | ||
| ); | ||
| } | ||
| const mode = unixMode(entry); | ||
| if (mode !== null) { | ||
| chmodSync(safePath, mode); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| if (error instanceof MpakCacheCorruptedError) { | ||
| throw error; | ||
| } | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| } finally { | ||
| zipfile.close(); | ||
| } | ||
| } | ||
| function openZip(path) { | ||
| return new Promise((res, rej) => { | ||
| yauzl.open(path, { lazyEntries: false, autoClose: false }, (err, zipfile) => { | ||
| if (err || !zipfile) rej(err ?? new Error("yauzl returned no zipfile")); | ||
| else res(zipfile); | ||
| }); | ||
| }); | ||
| } | ||
| function readAllEntries(zipfile) { | ||
| return new Promise((res, rej) => { | ||
| const entries = []; | ||
| zipfile.on("entry", (entry) => entries.push(entry)); | ||
| zipfile.on("end", () => res(entries)); | ||
| zipfile.on("error", rej); | ||
| }); | ||
| } | ||
| function openReadStream(zipfile, entry) { | ||
| return new Promise((res, rej) => { | ||
| zipfile.openReadStream(entry, (err, stream) => { | ||
| if (err || !stream) rej(err ?? new Error("yauzl returned no read stream")); | ||
| else res(stream); | ||
| }); | ||
| }); | ||
| } | ||
| function resolveEntryPath(normalizedDest, entryName, zipPath) { | ||
| if (entryName.includes("\0")) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Cannot verify bundle size before extraction: ${message}`, | ||
| zipPath, | ||
| error instanceof Error ? error : void 0 | ||
| `Bundle entry name contains NUL byte: ${JSON.stringify(entryName)}`, | ||
| zipPath | ||
| ); | ||
| } | ||
| mkdirSync(destDir, { recursive: true }); | ||
| execFileSync("unzip", ["-o", "-q", zipPath, "-d", destDir], { | ||
| stdio: "pipe" | ||
| const candidate = resolve(normalizedDest, entryName); | ||
| if (candidate !== normalizedDest && !candidate.startsWith(normalizedDest + sep)) { | ||
| throw new MpakCacheCorruptedError( | ||
| `Bundle entry escapes destination directory: ${entryName}`, | ||
| zipPath | ||
| ); | ||
| } | ||
| return candidate; | ||
| } | ||
| function isDirectoryEntry(entry) { | ||
| return /\/$/.test(entry.fileName); | ||
| } | ||
| function isSymlink(entry) { | ||
| const mode = entry.externalFileAttributes >>> 16; | ||
| return (mode & 61440) === 40960; | ||
| } | ||
| function unixMode(entry) { | ||
| const mode = entry.externalFileAttributes >>> 16 & 511; | ||
| return mode === 0 ? null : mode; | ||
| } | ||
| function createByteCounter(entry, zipPath, getCumulative, maxSize, setCumulative) { | ||
| let entryBytes = 0; | ||
| return new Transform({ | ||
| transform(chunk, _enc, cb) { | ||
| entryBytes += chunk.length; | ||
| if (entryBytes > entry.uncompressedSize) { | ||
| cb( | ||
| new MpakCacheCorruptedError( | ||
| `Entry ${entry.fileName} exceeds its declared uncompressed size (possible zip bomb)`, | ||
| zipPath | ||
| ) | ||
| ); | ||
| return; | ||
| } | ||
| const next = getCumulative() + chunk.length; | ||
| if (next > maxSize) { | ||
| cb( | ||
| new MpakCacheCorruptedError( | ||
| `Bundle exceeds maximum uncompressed size (${formatMB(maxSize)}MB) during extraction`, | ||
| zipPath | ||
| ) | ||
| ); | ||
| return; | ||
| } | ||
| setCumulative(next); | ||
| cb(null, chunk); | ||
| } | ||
| }); | ||
| } | ||
| function formatMB(bytes) { | ||
| return Math.round(bytes / (1024 * 1024)); | ||
| } | ||
| function errorMessage(error) { | ||
| return error instanceof Error ? error.message : String(error); | ||
| } | ||
| function hashBundlePath(bundlePath) { | ||
@@ -478,2 +616,3 @@ return createHash2("md5").update(resolve(bundlePath)).digest("hex").slice(0, 12); | ||
| cacheHome; | ||
| maxUncompressedSize; | ||
| mpakClient; | ||
@@ -483,2 +622,3 @@ constructor(client, options) { | ||
| this.cacheHome = join2(options?.mpakHome ?? join2(homedir(), ".mpak"), "cache"); | ||
| this.maxUncompressedSize = options?.maxUncompressedSize; | ||
| } | ||
@@ -669,3 +809,3 @@ /** | ||
| } | ||
| extractZip(tempPath, cacheDir); | ||
| await extractZip(tempPath, cacheDir, this.extractOptions()); | ||
| this.writeCacheMetadata(name, { | ||
@@ -680,2 +820,6 @@ version: bundle.version, | ||
| } | ||
| /** Options threaded into every `extractZip` call. */ | ||
| extractOptions() { | ||
| return this.maxUncompressedSize !== void 0 ? { maxUncompressedSize: this.maxUncompressedSize } : {}; | ||
| } | ||
| }; | ||
@@ -910,5 +1054,9 @@ | ||
| this.client = new MpakClient(clientConfig); | ||
| this.bundleCache = new MpakBundleCache(this.client, { | ||
| const cacheOptions = { | ||
| mpakHome: this.configManager.mpakHome | ||
| }); | ||
| }; | ||
| if (options?.maxUncompressedSize !== void 0) { | ||
| cacheOptions.maxUncompressedSize = options.maxUncompressedSize; | ||
| } | ||
| this.bundleCache = new MpakBundleCache(this.client, cacheOptions); | ||
| } | ||
@@ -993,3 +1141,3 @@ /** | ||
| try { | ||
| extractZip(absolutePath, cacheDir); | ||
| await extractZip(absolutePath, cacheDir, this.bundleCache.extractOptions()); | ||
| } catch (err) { | ||
@@ -1117,3 +1265,3 @@ throw new MpakInvalidBundleError( | ||
| try { | ||
| chmodSync(command, 493); | ||
| chmodSync2(command, 493); | ||
| } catch { | ||
@@ -1229,2 +1377,3 @@ } | ||
| export { | ||
| MAX_UNCOMPRESSED_SIZE, | ||
| Mpak, | ||
@@ -1242,4 +1391,6 @@ MpakBundleCache, | ||
| MpakNotFoundError, | ||
| extractZip, | ||
| isSemverEqual, | ||
| parsePackageSpec | ||
| }; | ||
| //# sourceMappingURL=index.js.map |
+3
-1
| { | ||
| "name": "@nimblebrain/mpak-sdk", | ||
| "version": "0.5.0", | ||
| "version": "0.6.0", | ||
| "description": "TypeScript SDK for mpak registry - MCPB bundles and Agent Skills", | ||
@@ -53,2 +53,3 @@ "author": "NimbleBrain Inc <engineering@mpak.dev>", | ||
| "dependencies": { | ||
| "yauzl": "^3.2.0", | ||
| "zod": "^4.3.6", | ||
@@ -59,2 +60,3 @@ "@nimblebrain/mpak-schemas": "0.2.0" | ||
| "@types/node": "^22.0.0", | ||
| "@types/yauzl": "^2.10.3", | ||
| "jszip": "^3.10.1", | ||
@@ -61,0 +63,0 @@ "tsup": "^8.4.0", |
+7
-4
@@ -289,8 +289,11 @@ # @nimblebrain/mpak-sdk | ||
| #### Static Methods | ||
| #### Utility Exports | ||
| | Method | Description | | ||
| Standalone functions exported from the package (not methods on any class): | ||
| | Export | Description | | ||
| |---|---| | ||
| | `BundleCache.extractZip(zipPath, destDir)` | Extract a ZIP with zip-bomb protection | | ||
| | `BundleCache.isSemverEqual(a, b)` | Compare semver strings (ignores `v` prefix) | | ||
| | `extractZip(zipPath, destDir, options?)` | Async. Stream-extract a ZIP with zip-bomb, path-traversal, and symlink protection. Pass `{ maxUncompressedSize }` to override the default cap. | | ||
| | `MAX_UNCOMPRESSED_SIZE` | Default uncompressed-size cap applied by `extractZip` (2 GB). | | ||
| | `isSemverEqual(a, b)` | Compare semver strings (ignores `v` prefix) | | ||
@@ -297,0 +300,0 @@ ### ConfigManager (`mpak.config`) |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
372344
10.79%3532
11.6%388
0.78%4
-33.33%3
50%6
20%+ Added
+ Added
+ Added