Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions lib/services/extension-upload-service.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down
108 changes: 108 additions & 0 deletions lib/utils/zip-entries.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
88 changes: 88 additions & 0 deletions test/extension-upload-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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');
});
145 changes: 145 additions & 0 deletions test/zip-entries.test.js
Original file line number Diff line number Diff line change
@@ -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);
});