diff --git a/lib/services/extension-upload-service.js b/lib/services/extension-upload-service.js index d7ca9fc..e8db5b9 100644 --- a/lib/services/extension-upload-service.js +++ b/lib/services/extension-upload-service.js @@ -1,6 +1,27 @@ import fs from 'node:fs'; import { parseSpaceIdFromInput } from '../utils/space-id.js'; +import { readZipEntryNames } from '../utils/zip-entries.js'; + +/** + * The zip format requires "/" in entry names. Archives built with a "\" + * separator (for example by .NET's ZipFile in Windows PowerShell 5.1) are + * rejected by the API with only "Invalid extension package", so they are + * caught here with the cause and the fix. + */ +async function assertForwardSlashEntries(file) { + const names = await readZipEntryNames(file); + if (!names) return; + const offending = names.filter((name) => name.includes('\\')); + if (offending.length === 0) return; + const shown = offending.slice(0, 3).join(', '); + const more = offending.length > 3 ? ` and ${offending.length - 3} more` : ''; + throw new Error( + `Extension package has entries with backslash paths (${shown}${more}). ` + + 'Mantis rejects these as "Invalid extension package". ' + + 'Re-create the archive with forward slashes ("/") in entry names, as the zip format requires.', + ); +} /** Uploads an extension package through the developer API. */ export class ExtensionUploadService { @@ -20,6 +41,7 @@ export class ExtensionUploadService { if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { throw new Error(`Extension package not found: ${file}`); } + await assertForwardSlashEntries(file); const result = await this.client.installExtensionInSpace(resolvedSpaceId, file); const bundle = result.bundle; diff --git a/lib/utils/zip-entries.js b/lib/utils/zip-entries.js new file mode 100644 index 0000000..5f06a38 --- /dev/null +++ b/lib/utils/zip-entries.js @@ -0,0 +1,108 @@ +import fs from 'node:fs/promises'; + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_HEADER_SIGNATURE = 0x02014b50; +const EOCD_SIZE = 22; +const CENTRAL_HEADER_SIZE = 46; +const MAX_COMMENT_SIZE = 0xffff; +// The EOCD record and its trailing comment always fit in this many final bytes. +const MAX_TAIL_SIZE = EOCD_SIZE + MAX_COMMENT_SIZE; + +/** + * Finds the end-of-central-directory record in `tail`, the last bytes of an + * archive of `archiveSize` bytes, and returns where its central directory is. + * + * A candidate signature only counts when its comment length reaches exactly + * to the end of the archive, so a "PK\x05\x06" inside a comment is skipped. + * Returns null for anything this reader does not handle: no EOCD, split + * archives, zip64 markers, or a directory that is not wholly before the EOCD. + */ +function locateCentralDirectory(tail, archiveSize) { + const tailStart = archiveSize - tail.length; + for (let i = tail.length - EOCD_SIZE; i >= 0; i--) { + if (tail.readUInt32LE(i) !== EOCD_SIGNATURE) continue; + const commentLength = tail.readUInt16LE(i + 20); + if (i + EOCD_SIZE + commentLength !== tail.length) continue; + + const eocdOffset = tailStart + i; + const diskNumber = tail.readUInt16LE(i + 4); + const directoryDisk = tail.readUInt16LE(i + 6); + const entriesOnDisk = tail.readUInt16LE(i + 8); + const entryCount = tail.readUInt16LE(i + 10); + const size = tail.readUInt32LE(i + 12); + const offset = tail.readUInt32LE(i + 16); + + if (diskNumber !== 0 || directoryDisk !== 0 || entriesOnDisk !== entryCount) return null; + if (entryCount === 0xffff || size === 0xffffffff || offset === 0xffffffff) return null; + if (offset + size > eocdOffset) return null; + return { offset, size, entryCount }; + } + return null; +} + +/** + * Reads entry names from a central directory buffer that holds exactly + * `entryCount` records. Returns null when any record, name, extra field or + * comment would run past the directory, or when bytes are left over. + */ +function parseCentralDirectory(directory, entryCount) { + const names = []; + let offset = 0; + for (let i = 0; i < entryCount; i++) { + if (offset + CENTRAL_HEADER_SIZE > directory.length) return null; + if (directory.readUInt32LE(offset) !== CENTRAL_HEADER_SIGNATURE) return null; + const nameLength = directory.readUInt16LE(offset + 28); + const extraLength = directory.readUInt16LE(offset + 30); + const commentLength = directory.readUInt16LE(offset + 32); + const nameStart = offset + CENTRAL_HEADER_SIZE; + const recordEnd = nameStart + nameLength + extraLength + commentLength; + if (recordEnd > directory.length) return null; + names.push(directory.toString('utf8', nameStart, nameStart + nameLength)); + offset = recordEnd; + } + return offset === directory.length ? names : null; +} + +/** + * Lists entry names from a complete zip archive held in memory. + * + * Returns null when the buffer is not a zip this reader handles (for example + * a JSON package, a zip64 or split archive, or a malformed directory), so + * callers can leave validation of those files to the server. + */ +export function listZipEntryNames(buffer) { + if (buffer.length < EOCD_SIZE) return null; + const tail = buffer.subarray(Math.max(0, buffer.length - MAX_TAIL_SIZE)); + const directory = locateCentralDirectory(tail, buffer.length); + if (!directory) return null; + return parseCentralDirectory( + buffer.subarray(directory.offset, directory.offset + directory.size), + directory.entryCount, + ); +} + +/** + * Lists entry names from a zip archive on disk, reading only the final + * bytes that can hold the EOCD record and then the central directory itself. + * Returns null under the same conditions as listZipEntryNames. + */ +export async function readZipEntryNames(file) { + const handle = await fs.open(file, 'r'); + try { + const { size } = await handle.stat(); + if (size < EOCD_SIZE) return null; + + const tailLength = Math.min(size, MAX_TAIL_SIZE); + const tail = Buffer.alloc(tailLength); + await handle.read(tail, 0, tailLength, size - tailLength); + const directory = locateCentralDirectory(tail, size); + if (!directory) return null; + + const region = Buffer.alloc(directory.size); + const { bytesRead } = await handle.read(region, 0, directory.size, directory.offset); + if (bytesRead !== directory.size) return null; + return parseCentralDirectory(region, directory.entryCount); + } finally { + await handle.close(); + } +} diff --git a/test/extension-upload-service.test.js b/test/extension-upload-service.test.js index 3620aab..d9d7847 100644 --- a/test/extension-upload-service.test.js +++ b/test/extension-upload-service.test.js @@ -9,6 +9,49 @@ import { ToolService } from '../lib/services/tool-service.js'; const SPACE_ID = '4c9beaf7-85db-4648-b5f6-bb2acdea48dd'; +// Minimal stored (uncompressed) zip with the given entry names. The CRC fields +// are left at zero because only the entry names are read. +function makeZip(names) { + const locals = []; + const centrals = []; + let offset = 0; + for (const name of names) { + const nameBuf = Buffer.from(name, 'utf8'); + const data = Buffer.from('x'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBuf.length, 26); + const localBlock = Buffer.concat([local, nameBuf, data]); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(localBlock); + centrals.push(Buffer.concat([central, nameBuf])); + offset += localBlock.length; + } + const directory = Buffer.concat(centrals); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(names.length, 8); + eocd.writeUInt16LE(names.length, 10); + eocd.writeUInt32LE(directory.length, 12); + eocd.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, directory, eocd]); +} + +function writeTempPackage(t, name, contents) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mantis-extension-test-')); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + const file = path.join(tempDir, name); + fs.writeFileSync(file, contents); + return file; +} + test('installs an extension package into the requested space', async (t) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mantis-extension-test-')); t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); @@ -86,3 +129,48 @@ test('rejects a missing package before making an API request', async () => { /Extension package not found/, ); }); + +test('rejects a zip with backslash entry names before making an API request', async (t) => { + const file = writeTempPackage( + t, + 'windows.mantisx', + makeZip(['mantis.extension.json', 'extension.js', 'panel\\main.js']), + ); + const service = new ExtensionUploadService({ + configStore: { requireAuth: () => ({ apiKey: 'test' }) }, + client: { + installExtensionInSpace: async () => assert.fail('client should not be called'), + }, + }); + + await assert.rejects( + service.install(file, { spaceId: SPACE_ID }), + (err) => { + assert.match(err.message, /backslash paths \(panel\\main\.js\)/); + assert.match(err.message, /forward slashes/); + return true; + }, + ); +}); + +test('uploads a zip whose entry names use forward slashes', async (t) => { + const file = writeTempPackage( + t, + 'portable.mantisx', + makeZip(['mantis.extension.json', 'extension.js', 'panel/main.js']), + ); + let called = false; + const service = new ExtensionUploadService({ + configStore: { requireAuth: () => ({ apiKey: 'test' }) }, + client: { + installExtensionInSpace: async (spaceId) => { + called = true; + return { extension_id: 'demo.extension', version: '1.0.0', space_id: spaceId, scope: 'user' }; + }, + }, + }); + + const result = await service.install(file, { spaceId: SPACE_ID }); + assert.equal(called, true); + assert.equal(result.extension_id, 'demo.extension'); +}); diff --git a/test/zip-entries.test.js b/test/zip-entries.test.js new file mode 100644 index 0000000..4725ca0 --- /dev/null +++ b/test/zip-entries.test.js @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { listZipEntryNames, readZipEntryNames } from '../lib/utils/zip-entries.js'; + +const EOCD_SIGNATURE = 0x06054b50; + +// Builds a stored (uncompressed) zip with the given entry names and optional +// archive comment. Returns the bytes plus the offsets tests need to corrupt +// specific fields. CRCs are zero because only entry names are read. +function buildZip(names, { comment = Buffer.alloc(0) } = {}) { + const locals = []; + const centrals = []; + let offset = 0; + for (const name of names) { + const nameBuf = Buffer.from(name, 'utf8'); + const data = Buffer.from('x'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBuf.length, 26); + const localBlock = Buffer.concat([local, nameBuf, data]); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(localBlock); + centrals.push(Buffer.concat([central, nameBuf])); + offset += localBlock.length; + } + const directory = Buffer.concat(centrals); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(EOCD_SIGNATURE, 0); + eocd.writeUInt16LE(names.length, 8); + eocd.writeUInt16LE(names.length, 10); + eocd.writeUInt32LE(directory.length, 12); + eocd.writeUInt32LE(offset, 16); + eocd.writeUInt16LE(comment.length, 20); + return { + buffer: Buffer.concat([...locals, directory, eocd, comment]), + directoryOffset: offset, + directorySize: directory.length, + eocdOffset: offset + directory.length, + }; +} + +// A comment that contains a complete-looking EOCD record followed by text. +function commentWithFakeEocd() { + const fake = Buffer.alloc(22); + fake.writeUInt32LE(EOCD_SIGNATURE, 0); + return Buffer.concat([Buffer.from('built by '), fake, Buffer.from(' on Windows')]); +} + +function writeTemp(t, contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mantis-zip-test-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const file = path.join(dir, 'package.mantisx'); + fs.writeFileSync(file, contents); + return file; +} + +test('lists entry names of a valid archive', () => { + const { buffer } = buildZip(['mantis.extension.json', 'extension.js', 'panel/main.js']); + assert.deepEqual(listZipEntryNames(buffer), ['mantis.extension.json', 'extension.js', 'panel/main.js']); +}); + +test('returns an empty list for an empty zip archive', () => { + assert.deepEqual(listZipEntryNames(buildZip([]).buffer), []); +}); + +test('returns null for data that is not a zip archive', () => { + assert.equal(listZipEntryNames(Buffer.from('{"manifest":{}}')), null); + assert.equal(listZipEntryNames(Buffer.alloc(0)), null); +}); + +test('ignores a fake EOCD signature inside the archive comment', () => { + const { buffer } = buildZip(['extension.js', 'panel\\main.js'], { comment: commentWithFakeEocd() }); + assert.deepEqual(listZipEntryNames(buffer), ['extension.js', 'panel\\main.js']); +}); + +test('returns null when a filename runs past the central directory', () => { + const { buffer, directoryOffset } = buildZip(['panel/main.js']); + buffer.writeUInt16LE(200, directoryOffset + 28); + assert.equal(listZipEntryNames(buffer), null); +}); + +test('returns null when the extra field or comment runs past the central directory', () => { + const extra = buildZip(['panel/main.js']); + extra.buffer.writeUInt16LE(50, extra.directoryOffset + 30); + assert.equal(listZipEntryNames(extra.buffer), null); + + const comment = buildZip(['panel/main.js']); + comment.buffer.writeUInt16LE(50, comment.directoryOffset + 32); + assert.equal(listZipEntryNames(comment.buffer), null); +}); + +test('returns null when a central directory record is truncated', () => { + const { buffer, eocdOffset } = buildZip(['panel/main.js']); + buffer.writeUInt32LE(40, eocdOffset + 12); + assert.equal(listZipEntryNames(buffer), null); +}); + +test('returns null when the entry count exceeds the records present', () => { + const { buffer, eocdOffset } = buildZip(['panel/main.js']); + buffer.writeUInt16LE(2, eocdOffset + 8); + buffer.writeUInt16LE(2, eocdOffset + 10); + assert.equal(listZipEntryNames(buffer), null); +}); + +test('returns null when the central directory size is wrong', () => { + const tooLarge = buildZip(['panel/main.js']); + tooLarge.buffer.writeUInt32LE(tooLarge.directorySize + 10, tooLarge.eocdOffset + 12); + assert.equal(listZipEntryNames(tooLarge.buffer), null); + + const leftover = buildZip(['panel/main.js']); + leftover.buffer.writeUInt32LE(leftover.directorySize + 4, leftover.eocdOffset + 12); + leftover.buffer.writeUInt32LE(leftover.directoryOffset - 4, leftover.eocdOffset + 16); + assert.equal(listZipEntryNames(leftover.buffer), null); +}); + +test('returns null when the central directory offset is out of bounds', () => { + const { buffer, eocdOffset } = buildZip(['panel/main.js']); + buffer.writeUInt32LE(1000, eocdOffset + 16); + assert.equal(listZipEntryNames(buffer), null); +}); + +test('reads entry names from a file without loading the whole archive', async (t) => { + // A maximum-length comment puts the central directory outside the tail read. + const comment = Buffer.alloc(0xffff, 'x'); + const { buffer } = buildZip(['extension.js', 'panel\\main.js'], { comment }); + const file = writeTemp(t, buffer); + assert.deepEqual(await readZipEntryNames(file), ['extension.js', 'panel\\main.js']); +}); + +test('file reader matches the buffer reader for fake signatures and non-zip files', async (t) => { + const { buffer } = buildZip(['panel\\main.js'], { comment: commentWithFakeEocd() }); + assert.deepEqual(await readZipEntryNames(writeTemp(t, buffer)), ['panel\\main.js']); + assert.equal(await readZipEntryNames(writeTemp(t, '{"manifest":{}}')), null); +});