Files
codebase-memory-mcp/pkg/npm/test/archive-limits.test.js
T
Martin Vogel 10c7fc3206 fix(pkg): remove the archive size caps from the npm, PyPI and Go wrappers
v0.12.0 could not be installed through npm, pnpm, pip or the Go wrapper on
any platform (#2657): each wrapper refused an archive member above 256 MiB,
and every v0.12.0 binary is 331-335 MB. The v0.11.0 binaries were already
about 300 MB, so the Go wrapper, which has had the cap since v0.10.0, was
broken before; npm and PyPI gained it in v0.12.0. The archive is verified
against the release's checksums.txt before it is unpacked, so a byte cap
only ever refused what we ship.

- npm, PyPI, Go: delete every byte-size cap (compressed archive, per member,
  aggregate, checksum manifest, download size). The checksum, the exact
  member set, path and link refusal, the member count, HTTPS and the
  --version check stay.
- All three honour CBM_DOWNLOAD_URL like install.sh, for testing: https, or
  plain http to an exact loopback authority with no proxy and no redirect.
- Smoke: scripts/smoke-wrappers.sh installs the three wrappers from the
  checkout against the release fixture and runs the binary each one
  installed. smoke-test.sh Phase 13w runs it; SMOKE_REQUIRE_WRAPPERS=1 makes
  it required on the PR smoke lanes and in the release smoke, which gates
  publishing.

Other install paths found broken while checking every channel:
- Homebrew: the formula was still at 0.10.3 and installed the dynamic Linux
  build, which needs glibc 2.38 (Ubuntu 22.04, Debian 12 and RHEL 9 lack it).
  It now installs 0.12.0 with the static -portable Linux assets and tracks
  the newest release in the version contract like the other surfaces.
- Go: `go install .../pkg/go/cmd/codebase-memory-mcp@vX.Y.Z` never worked
  (no pkg/go/v* tags, so Go fetched the whole repository, over its 500 MB
  module limit), and @latest built main's wrapper, whose version constant
  lags each release. The release now also tags pkg/go/vX.Y.Z, and the
  wrapper installs its own module version (the constant only for local and
  untagged builds).
- npm: a stable release also moves the `next` dist-tag to itself; `next`
  still pointed to 0.9.1-rc.1.
- Docs: winget and Chocolatey were listed as install channels but are not
  published; they are removed until they are. Windows arm64 ships and is
  now listed.

Fixes #2657.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-16 18:09:06 +02:00

378 lines
13 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { Readable } = require('node:stream');
const test = require('node:test');
const vm = require('node:vm');
const {
UNIX_ARCHIVE_NAMES,
WINDOWS_ARCHIVE_NAMES,
WINDOWS_BINARY_NAME,
download,
extractExactTarArchive,
extractZipOnWindows,
resolveDownloadBase,
} = require('../install.js');
const {
TAR_PATH_SKIP, buildZip, patternBytes, writeTarGz,
} = require('./archive-fixtures.js');
const EXECUTABLE = 'codebase-memory-mcp';
// Spans several pipe chunks, so the counted writer sees a multi-chunk stream.
const LARGE_EXECUTABLE = patternBytes(300 * 1024);
// The size cap the 0.12.0 wrappers enforced, which refused every release.
const FORMER_MEMBER_CAP_BYTES = 256 * 1024 * 1024;
function withScratch(callback) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cbm-npm-archive-test-'));
return Promise.resolve()
.then(() => callback(root))
.finally(() => fs.rmSync(root, { recursive: true, force: true }));
}
function releaseEntries(names, executableData) {
return names.map((name) => ({
name,
data: executableData !== undefined && (name === EXECUTABLE || name === WINDOWS_BINARY_NAME)
? executableData
: `payload of ${name}`,
}));
}
function makeDestination(root) {
const destination = path.join(root, 'extract');
fs.mkdirSync(destination);
return destination;
}
// Stands in for the system tar listing where the archive itself is damaged
// on purpose and the real listing would fail before the writer runs.
function fakeTarListing(names) {
return (command, args) => {
assert.equal(command, 'tar');
assert.equal(args[0], '-tzf');
return `${names.join('\n')}\n`;
};
}
test('tar extraction leaves a pre-existing target untouched', { skip: TAR_PATH_SKIP }, () =>
withScratch(async (root) => {
const archive = writeTarGz(root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES));
const destination = makeDestination(root);
const target = path.join(destination, EXECUTABLE);
fs.writeFileSync(target, 'keep');
await assert.rejects(
extractExactTarArchive(archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE),
/EEXIST/,
);
assert.equal(fs.readFileSync(target, 'utf8'), 'keep');
}));
test('tar extraction leaves no file when tar fails mid-stream', { skip: TAR_PATH_SKIP }, () =>
withScratch(async (root) => {
const complete = writeTarGz(
root, 'complete.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES, LARGE_EXECUTABLE),
);
const bytes = fs.readFileSync(complete);
const archive = path.join(root, 'truncated.tar.gz');
fs.writeFileSync(archive, bytes.subarray(0, Math.floor(bytes.length * 0.6)));
const destination = makeDestination(root);
await assert.rejects(
extractExactTarArchive(
archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE,
fakeTarListing(UNIX_ARCHIVE_NAMES),
),
// Non-zero exit, and tar's own diagnostic text after the colon.
/tar exited with status [1-9]\d* while extracting codebase-memory-mcp: \S/,
);
assert.deepEqual(fs.readdirSync(destination), []);
}));
test('tar extraction rejects an empty executable', { skip: TAR_PATH_SKIP }, () =>
withScratch(async (root) => {
const archive = writeTarGz(
root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES, Buffer.alloc(0)),
);
const destination = makeDestination(root);
await assert.rejects(
extractExactTarArchive(archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE),
/archive member codebase-memory-mcp is empty/,
);
assert.deepEqual(fs.readdirSync(destination), []);
}));
test('tar extraction installs the executable byte-identical with the executable bit set', { skip: TAR_PATH_SKIP }, () =>
withScratch(async (root) => {
const archive = writeTarGz(
root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES, LARGE_EXECUTABLE),
);
const destination = makeDestination(root);
await extractExactTarArchive(archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE);
const target = path.join(destination, EXECUTABLE);
assert.ok(fs.readFileSync(target).equals(LARGE_EXECUTABLE));
if (process.platform !== 'win32') {
// Windows has no mode bits; everywhere else the file must be runnable.
assert.equal(fs.statSync(target).mode & 0o111, 0o111);
}
assert.deepEqual(fs.readdirSync(destination), [EXECUTABLE]);
}));
function loadInstallerWithTransports({ fakeHttps, fakeHttp }) {
const installPath = path.join(__dirname, '..', 'install.js');
const source = fs.readFileSync(installPath, 'utf8');
const sandbox = {
Buffer,
URL,
__dirname: path.dirname(installPath),
__filename: installPath,
clearTimeout,
console,
exports: {},
// A parent keeps the postinstall entry point from running on load.
module: { exports: {}, parent: {} },
process,
require: (specifier) => {
if (specifier === './package.json') return { version: '0.0.0-test' };
if (specifier === 'https' && fakeHttps) return fakeHttps;
if (specifier === 'http' && fakeHttp) return fakeHttp;
return require(specifier);
},
setTimeout,
};
vm.runInNewContext(source, sandbox, { filename: installPath });
return sandbox.module.exports;
}
// Answers every request with one HTTP 200 response shaped by `configure`;
// `requests` records each (url, options) the installer issued.
function fakeTransportServing(configure, requests = []) {
return {
get(url, ...rest) {
const callback = rest[rest.length - 1];
requests.push({ url, options: rest.length > 1 ? rest[0] : undefined });
const request = new EventEmitter();
request.destroy = (err) => { if (err) request.emit('error', err); };
setImmediate(() => {
const response = new Readable({ read() {} });
response.statusCode = 200;
response.headers = {};
configure(response);
callback(response);
});
return request;
},
};
}
function refusingTransport() {
return {
get(url) {
throw new Error(`unexpected request to ${url}`);
},
};
}
test('download stores a body whose declared length exceeds the former 256 MiB cap', () =>
withScratch(async (root) => {
const { download: fakeDownload } = loadInstallerWithTransports({
fakeHttps: fakeTransportServing((response) => {
response.headers['content-length'] = String(FORMER_MEMBER_CAP_BYTES + 1);
response.push('payload');
response.push(null);
}),
});
const destination = path.join(root, 'release.tar.gz');
await fakeDownload('https://example.invalid/release.tar.gz', destination);
assert.equal(fs.readFileSync(destination, 'utf8'), 'payload');
}));
test('download refuses plain HTTP unless the base is a loopback override', () =>
withScratch(async (root) => {
const { download: fakeDownload } = loadInstallerWithTransports({
fakeHttps: refusingTransport(),
fakeHttp: refusingTransport(),
});
const destination = path.join(root, 'release.tar.gz');
await assert.rejects(
fakeDownload('http://127.0.0.1/release.tar.gz', destination),
/Refusing non-HTTPS or credentialed URL/,
);
await assert.rejects(
fakeDownload('http://example.invalid/release.tar.gz', destination, true),
/Refusing non-loopback HTTP URL/,
);
assert.equal(fs.existsSync(destination), false);
}));
test('a loopback download uses a fresh agent, so no proxy applies', () =>
withScratch(async (root) => {
const requests = [];
const { download: fakeDownload } = loadInstallerWithTransports({
fakeHttps: refusingTransport(),
fakeHttp: fakeTransportServing((response) => {
response.push('payload');
response.push(null);
}, requests),
});
const destination = path.join(root, 'release.tar.gz');
await fakeDownload('http://[::1]:8080/release.tar.gz', destination, true);
assert.equal(fs.readFileSync(destination, 'utf8'), 'payload');
assert.equal(requests.length, 1);
assert.equal(requests[0].url, 'http://[::1]:8080/release.tar.gz');
assert.equal(requests[0].options.agent, false);
}));
function withLoopbackServer(handler, callback) {
const server = http.createServer(handler);
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
}).then(() => Promise.resolve()
.then(() => callback(`http://127.0.0.1:${server.address().port}`))
.finally(() => new Promise((resolve) => server.close(resolve))));
}
test('a loopback download fetches from a local server and refuses its redirects', () =>
withScratch((root) => withLoopbackServer((request, response) => {
if (request.url === '/release/codebase-memory-mcp.tar.gz') {
response.writeHead(200, { 'content-type': 'application/octet-stream' });
response.end('loopback payload');
return;
}
response.writeHead(302, { location: 'https://example.invalid/elsewhere' });
response.end();
}, async (origin) => {
const archive = path.join(root, 'release.tar.gz');
await download(`${origin}/release/codebase-memory-mcp.tar.gz`, archive, true);
assert.equal(fs.readFileSync(archive, 'utf8'), 'loopback payload');
const redirected = path.join(root, 'redirected.tar.gz');
await assert.rejects(
download(`${origin}/release/checksums.txt`, redirected, true),
/Refusing redirect from loopback download/,
);
assert.equal(fs.existsSync(redirected), false);
})));
test('CBM_DOWNLOAD_URL follows install.sh: HTTPS or exact-loopback HTTP only', () => {
const { version } = require('../package.json');
const githubBase = `https://github.com/DeusData/codebase-memory-mcp/releases/download/v${version}`;
assert.deepEqual(resolveDownloadBase({}), { base: githubBase, loopback: false });
assert.deepEqual(
resolveDownloadBase({ CBM_DOWNLOAD_URL: '' }), { base: githubBase, loopback: false },
);
for (const base of [
'https://mirror.example/releases/v1',
'https://127.0.0.1:8443/fixture',
]) {
assert.deepEqual(
resolveDownloadBase({ CBM_DOWNLOAD_URL: base }), { base, loopback: false }, base,
);
}
for (const base of [
'http://localhost',
'http://localhost:8080/fixture',
'http://127.0.0.1:41234/standard',
'http://[::1]:9/x?y#z',
]) {
assert.deepEqual(
resolveDownloadBase({ CBM_DOWNLOAD_URL: base }), { base, loopback: true }, base,
);
}
for (const base of [
'http://example.com/releases',
'http://127.0.0.1.example.com/',
'http://localhost.example.com:8080/',
'http://user@localhost/',
'http://127.0.0.2/',
'HTTP://localhost/',
'file:///tmp/releases',
'ftp://localhost/releases',
'github.com/DeusData/codebase-memory-mcp',
]) {
assert.throws(
() => resolveDownloadBase({ CBM_DOWNLOAD_URL: base }),
/Refusing non-HTTPS download URL \(CBM_DOWNLOAD_URL\)/,
base,
);
}
});
// The Windows path runs a constant PowerShell program through powershell.exe,
// which only a Windows host provides (pwsh is not a wrapper dependency, and
// no PowerShell is installed on the Unix wrapper hosts), so these cases run
// in the Windows wrapper leg and are skipped elsewhere.
const WINDOWS_ONLY = process.platform === 'win32'
? false
: 'zip extraction runs through powershell.exe, which this host does not provide';
test('Windows zip extraction refuses an archive with more members than the limit', { skip: WINDOWS_ONLY }, () =>
withScratch(async (root) => {
const archive = path.join(root, 'members.zip');
fs.writeFileSync(archive, buildZip([
{ name: 'one', data: 'a' },
{ name: 'two', data: 'b' },
{ name: 'three', data: 'c' },
]));
const destination = makeDestination(root);
assert.throws(
() => extractZipOnWindows(
archive, destination, ['one', 'two', 'three'], ['one'], 2,
),
/2-member safety limit/,
);
assert.deepEqual(fs.readdirSync(destination), []);
}));
test('Windows zip extraction rejects a member that yields more bytes than it declares', { skip: WINDOWS_ONLY }, () =>
withScratch(async (root) => {
const archive = path.join(root, 'longer.zip');
fs.writeFileSync(archive, buildZip([
{ name: WINDOWS_BINARY_NAME, data: Buffer.alloc(600, 0x78), declaredSize: 10 },
]));
const destination = makeDestination(root);
assert.throws(
() => extractZipOnWindows(
archive, destination, [WINDOWS_BINARY_NAME], [WINDOWS_BINARY_NAME],
),
/actual size \d+ does not match declared size 10/,
);
assert.deepEqual(fs.readdirSync(destination), []);
}));
test('Windows zip extraction installs only the executable', { skip: WINDOWS_ONLY }, () =>
withScratch(async (root) => {
const archive = path.join(root, 'release.zip');
fs.writeFileSync(archive, buildZip(releaseEntries(WINDOWS_ARCHIVE_NAMES)));
const destination = makeDestination(root);
extractZipOnWindows(
archive, destination, WINDOWS_ARCHIVE_NAMES, [WINDOWS_BINARY_NAME],
);
assert.equal(
fs.readFileSync(path.join(destination, WINDOWS_BINARY_NAME), 'utf8'),
`payload of ${WINDOWS_BINARY_NAME}`,
);
assert.deepEqual(fs.readdirSync(destination), [WINDOWS_BINARY_NAME]);
}));