diff --git a/src/cli/pack.ts b/src/cli/pack.ts index c3ec099..b4f5c0f 100644 --- a/src/cli/pack.ts +++ b/src/cli/pack.ts @@ -225,27 +225,15 @@ export async function packExtension({ // Create zip with preserved file permissions const zipFiles: Zippable = {}; - const isUnix = process.platform !== "win32"; const isBinaryType = manifest.server?.type === "binary"; const binaryEntryPoint = isBinaryType ? manifest.server.entry_point : null; for (const [filePath, fileData] of Object.entries(files)) { - if (isUnix) { - let mode = fileData.mode & 0o777; - // Ensure binary entry points are always executable in the ZIP, - // regardless of filesystem permissions. Windows-built ZIPs store - // 644 because Windows has no Unix execute bit — CD spawns binary - // entry points directly, so they must have +x to avoid EACCES. - if (binaryEntryPoint && filePath === binaryEntryPoint) { - mode = mode | 0o111; - } - // Set external file attributes to preserve Unix permissions - // The mode needs to be shifted to the upper 16 bits for ZIP format - zipFiles[filePath] = [fileData.data, { os: 3, attrs: mode << 16 }]; - } else { - // On Windows, use default ZIP attributes (no Unix permissions) - zipFiles[filePath] = fileData.data; + let mode = fileData.mode & 0o777; + if (binaryEntryPoint && filePath === binaryEntryPoint) { + mode = mode | 0o111; } + zipFiles[filePath] = [fileData.data, { os: 3, attrs: mode << 16 }]; } const zipData = zipSync(zipFiles, { diff --git a/test/cli.test.ts b/test/cli.test.ts index b74e9ac..31ebefc 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -11,6 +11,48 @@ interface ExecSyncError extends Error { signal: string | null; } +function readZipMode(zipPath: string, targetPath: string): number | undefined { + const zipBuffer = fs.readFileSync(zipPath); + let eocdOffset = -1; + + for (let i = zipBuffer.length - 22; i >= 0; i--) { + if (zipBuffer.readUInt32LE(i) === 0x06054b50) { + eocdOffset = i; + break; + } + } + + if (eocdOffset === -1) { + throw new Error("End of central directory record not found"); + } + + const centralDirOffset = zipBuffer.readUInt32LE(eocdOffset + 16); + const centralDirEntries = zipBuffer.readUInt16LE(eocdOffset + 8); + let offset = centralDirOffset; + + for (let i = 0; i < centralDirEntries; i++) { + if (zipBuffer.readUInt32LE(offset) !== 0x02014b50) { + throw new Error(`Invalid central directory record at ${offset}`); + } + + const externalAttrs = zipBuffer.readUInt32LE(offset + 38); + const filenameLength = zipBuffer.readUInt16LE(offset + 28); + const extraFieldLength = zipBuffer.readUInt16LE(offset + 30); + const commentLength = zipBuffer.readUInt16LE(offset + 32); + const filename = zipBuffer.toString( + "utf8", + offset + 46, + offset + 46 + filenameLength, + ); + + if (filename === targetPath) { + return (externalAttrs >> 16) & 0o777; + } + + offset += 46 + filenameLength + extraFieldLength + commentLength; + } +} + describe("DXT CLI", () => { const cliPath = join(__dirname, "../dist/cli/cli.js"); const validManifestPath = join(__dirname, "valid-manifest.json"); @@ -343,6 +385,60 @@ describe("DXT CLI", () => { } }); + it("should write Unix permission attributes when packing on any platform", () => { + const tempBinDir = join(__dirname, "temp-binary-zip-attrs-test"); + const packedFilePath = join(__dirname, "test-binary-zip-attrs.dxt"); + + try { + fs.mkdirSync(join(tempBinDir, "server"), { recursive: true }); + fs.writeFileSync( + join(tempBinDir, "manifest.json"), + JSON.stringify({ + manifest_version: DEFAULT_MANIFEST_VERSION, + name: "Test Binary Zip Attrs", + version: "1.0.0", + description: "A test extension with zip mode attrs", + author: { + name: "MCPB", + }, + server: { + type: "binary", + entry_point: "server/myserver", + mcp_config: { + command: "${__dirname}/server/myserver", + }, + }, + }), + ); + fs.writeFileSync( + join(tempBinDir, "server", "myserver"), + "#!/bin/sh\necho binary", + ); + fs.writeFileSync(join(tempBinDir, "config.json"), "{}"); + + execSync(`node ${cliPath} pack ${tempBinDir} ${packedFilePath}`, { + encoding: "utf-8", + }); + + const entryMode = fs.statSync( + join(tempBinDir, "server", "myserver"), + ).mode; + const configMode = fs.statSync(join(tempBinDir, "config.json")).mode; + + expect(readZipMode(packedFilePath, "server/myserver")).toBe( + (entryMode & 0o777) | 0o111, + ); + expect(readZipMode(packedFilePath, "config.json")).toBe( + configMode & 0o777, + ); + } finally { + fs.rmSync(tempBinDir, { recursive: true, force: true }); + if (fs.existsSync(packedFilePath)) { + fs.unlinkSync(packedFilePath); + } + } + }); + it("should preserve executable file permissions after packing and unpacking", () => { // Skip this test on Windows since it doesn't support Unix permissions if (process.platform === "win32") {