From 5cc25dc6f917d364ff8d2b9224f598c9810308f1 Mon Sep 17 00:00:00 2001 From: Alec McLeod Date: Mon, 8 Jun 2026 14:31:08 -0300 Subject: [PATCH] Skip directory entries when unpacking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZIP archives produced by standard tooling (e.g. `zip -r`) include explicit directory entries (keys ending in "/") with empty data. unpackExtension wrote every entry unconditionally, so the trailing-slash path write failed and aborted the entire unpack — making `mcpb unpack` unusable on most real-world .mcpb files not produced by `mcpb pack` itself. Skip directory entries; needed parents are already created. Adds a regression test using a zip with a directory entry. Fixes #261 --- src/cli/unpack.ts | 8 ++++++++ test/unpack.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 test/unpack.test.ts diff --git a/src/cli/unpack.ts b/src/cli/unpack.ts index 2bf78c2..a877213 100644 --- a/src/cli/unpack.ts +++ b/src/cli/unpack.ts @@ -93,6 +93,14 @@ export async function unpackExtension({ for (const relativePath in decompressed) { if (Object.prototype.hasOwnProperty.call(decompressed, relativePath)) { + // Skip explicit directory entries (keys ending in "/"). ZIP archives + // produced by most tooling (e.g. `zip -r`) include these with empty + // data; writing to a path with a trailing slash fails and would abort + // the whole unpack. Needed parent directories are created below. + if (relativePath.endsWith("/")) { + continue; + } + const data = decompressed[relativePath]; const fullPath = join(finalOutputDir, relativePath); diff --git a/test/unpack.test.ts b/test/unpack.test.ts new file mode 100644 index 0000000..2aa2513 --- /dev/null +++ b/test/unpack.test.ts @@ -0,0 +1,41 @@ +import fs from "node:fs"; +import { join } from "node:path"; + +import { zipSync } from "fflate"; + +import { unpackExtension } from "../src/cli/unpack"; + +describe("unpackExtension", () => { + const tmpRoot = join(__dirname, "temp-unpack-dir-entries"); + const mcpbPath = join(tmpRoot, "demo.mcpb"); + const outDir = join(tmpRoot, "out"); + + beforeEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + fs.mkdirSync(tmpRoot, { recursive: true }); + }); + + afterAll(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it("unpacks archives that contain explicit directory entries", () => { + // Standard ZIP tooling emits directory entries (keys ending in "/"). + const archive = zipSync({ + "manifest.json": new TextEncoder().encode("{}"), + "server/": new Uint8Array(0), + "server/index.js": new TextEncoder().encode("console.log('hi');"), + }); + fs.writeFileSync(mcpbPath, archive); + + const ok = unpackExtension({ mcpbPath, outputDir: outDir, silent: true }); + + return Promise.resolve(ok).then((result) => { + expect(result).toBe(true); + expect(fs.existsSync(join(outDir, "manifest.json"))).toBe(true); + expect(fs.existsSync(join(outDir, "server", "index.js"))).toBe(true); + // The directory entry must not be written as a file. + expect(fs.statSync(join(outDir, "server")).isDirectory()).toBe(true); + }); + }); +});