blob: cf4849623b173b52837a74c00d7ae036ceff6d31 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import { gzipSync } from 'node:zlib';
import {
RuntimeHostUpdatePackageError,
assertRuntimeHostArchiveExpansionBudget,
withRuntimeHostRegistryUpdateArtifact,
withRuntimeHostRegistryUpdatePackage,
withVerifiedRuntimeHostUpdateArchive,
} from '../runtime-host-update-package.js';
function tarHeader(name: string, size: number, type = '0'): Buffer {
const header = Buffer.alloc(512);
header.write(name, 0, 'latin1');
header.write('0000644\0', 100, 'latin1');
header.write(size.toString(8).padStart(11, '0') + '\0', 124, 'latin1');
header.write(type, 156, 'latin1');
header.write(' ', 148, 'latin1');
let checksum = 0;
for (const byte of header) checksum += byte;
header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148, 'latin1');
return header;
}
function tgz(entries: ReadonlyArray<{ name: string; body?: Buffer; type?: string }>): Buffer {
const blocks: Buffer[] = [];
for (const entry of entries) {
const body = entry.body ?? Buffer.alloc(0);
blocks.push(tarHeader(entry.name, body.length, entry.type));
blocks.push(body);
const padding = (512 - (body.length % 512)) % 512;
if (padding > 0) blocks.push(Buffer.alloc(padding));
}
blocks.push(Buffer.alloc(1024));
return gzipSync(Buffer.concat(blocks));
}
const ARCHIVE = tgz([{ name: 'package/package.json', body: Buffer.from('{"name":"maka-agent"}') }]);
const INTEGRITY = `sha512-${createHash('sha512').update(ARCHIVE).digest('base64')}`;
describe('managed Runtime Host update package acquisition', () => {
it('keeps the verified archive available for an installation-owner finalizer', async () => {
const candidate = {
kind: 'npm_registry' as const,
version: '2.0.0',
integrity: INTEGRITY,
};
let archivePath = '';
await withRuntimeHostRegistryUpdateArtifact(
candidate,
async (artifact) => {
archivePath = artifact.archivePath;
assert.equal((await stat(archivePath)).isFile(), true);
},
async (args) => {
if (args[0] === 'pack') {
const destination = args[args.indexOf('--pack-destination') + 1]!;
await writeFile(join(destination, 'maka-agent-2.0.0.tgz'), ARCHIVE);
return 0;
}
const prefix = args[args.indexOf('--prefix') + 1]!;
const root = join(prefix, 'node_modules', 'maka-agent');
await Promise.all([
mkdir(join(root, 'dist'), { recursive: true }),
mkdir(join(root, 'node_modules', '@maka', 'runtime-host'), { recursive: true }),
]);
await Promise.all([
writeFile(
join(root, 'package.json'),
JSON.stringify({ name: 'maka-agent', version: '2.0.0' }),
),
writeFile(join(root, 'dist', 'cli.js'), ''),
writeFile(join(root, 'node_modules', '@maka', 'runtime-host', 'package.json'), '{}'),
]);
return 0;
},
);
await assert.rejects(stat(archivePath), { code: 'ENOENT' });
});
it('revalidates an archive before a coordinator can consume it', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-update-archive-'));
t.after(() => rm(root, { recursive: true, force: true }));
const archive = join(root, 'maka.tgz');
await writeFile(archive, Buffer.from('changed archive'));
await assert.rejects(
withVerifiedRuntimeHostUpdateArchive(
{ kind: 'npm_registry', version: '2.0.0', integrity: INTEGRITY },
archive,
async () => assert.fail('mismatched archive must not be consumed'),
async () => assert.fail('mismatched archive must not reach npm'),
),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError &&
error.code === 'package_integrity_mismatch',
);
});
it('binds the official archive to its extracted release evidence', async () => {
const calls: string[][] = [];
const candidate = {
kind: 'npm_registry' as const,
version: '2.0.0',
integrity: INTEGRITY,
compatibility: 7,
};
let acquiredRoot = '';
await withRuntimeHostRegistryUpdatePackage(
candidate,
async (root) => {
acquiredRoot = root;
assert.equal((await stat(root)).isDirectory(), true);
},
async (args) => {
calls.push([...args]);
if (args[0] === 'pack') {
const destination = args[args.indexOf('--pack-destination') + 1]!;
await writeFile(join(destination, 'maka-agent-2.0.0.tgz'), ARCHIVE);
return 0;
}
const prefix = args[args.indexOf('--prefix') + 1]!;
const root = join(prefix, 'node_modules', 'maka-agent');
await Promise.all([
mkdir(join(root, 'dist'), { recursive: true }),
mkdir(join(root, 'node_modules', '@maka', 'runtime-host'), {
recursive: true,
}),
]);
await Promise.all([
writeFile(
join(root, 'package.json'),
JSON.stringify({
name: 'maka-agent',
version: candidate.version,
maka: {
managedRuntimeHostUpdateCompatibility: candidate.compatibility,
},
}),
),
writeFile(join(root, 'dist', 'cli.js'), ''),
writeFile(join(root, 'node_modules', '@maka', 'runtime-host', 'package.json'), '{}'),
]);
return 0;
},
);
assert.deepEqual(calls[0]?.slice(0, 2), ['pack', 'maka-agent@2.0.0']);
assert.equal(calls[0]?.includes('https://registry.npmjs.org/'), true);
const downloadCache = calls[0]?.[calls[0].indexOf('--cache') + 1];
const installCache = calls[1]?.[calls[1].indexOf('--cache') + 1];
assert.match(downloadCache ?? '', /download-cache$/u);
assert.match(installCache ?? '', /empty-cache$/u);
assert.notEqual(downloadCache, installCache);
assert.equal(calls[1]?.includes('--offline'), true);
assert.equal(calls[1]?.includes('--ignore-scripts'), true);
assert.equal(calls[1]?.includes('http://127.0.0.1:9/'), true);
await assert.rejects(stat(acquiredRoot), { code: 'ENOENT' });
});
it('rejects archive or manifest evidence that differs from discovery', async () => {
let installed = false;
await assert.rejects(
withRuntimeHostRegistryUpdatePackage(
{
kind: 'npm_registry',
version: '2.0.0',
integrity: `sha512-${Buffer.alloc(64).toString('base64')}`,
},
async () => assert.fail('invalid integrity must not expose a package'),
async (args) => {
if (args[0] === 'pack') {
const destination = args[args.indexOf('--pack-destination') + 1]!;
await writeFile(join(destination, 'maka-agent-2.0.0.tgz'), ARCHIVE);
return 0;
}
installed = true;
return 0;
},
),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError &&
error.code === 'package_integrity_mismatch',
);
assert.equal(installed, false);
await assert.rejects(
withRuntimeHostRegistryUpdatePackage(
{
kind: 'npm_registry',
version: '2.0.0',
integrity: INTEGRITY,
compatibility: 7,
},
async () => assert.fail('invalid manifest must not expose a package'),
async (args) => {
if (args[0] === 'pack') {
const destination = args[args.indexOf('--pack-destination') + 1]!;
await writeFile(join(destination, 'maka-agent-2.0.0.tgz'), ARCHIVE);
return 0;
}
const prefix = args[args.indexOf('--prefix') + 1]!;
const root = join(prefix, 'node_modules', 'maka-agent');
await Promise.all([
mkdir(join(root, 'dist'), { recursive: true }),
mkdir(join(root, 'node_modules', '@maka', 'runtime-host'), {
recursive: true,
}),
]);
await Promise.all([
writeFile(
join(root, 'package.json'),
JSON.stringify({
name: 'maka-agent',
version: '2.0.0',
maka: { managedRuntimeHostUpdateCompatibility: 8 },
}),
),
writeFile(join(root, 'dist', 'cli.js'), ''),
writeFile(join(root, 'node_modules', '@maka', 'runtime-host', 'package.json'), '{}'),
]);
return 0;
},
),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package',
);
});
it('rejects a small verified archive whose headers claim excessive expansion', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-update-budget-'));
t.after(() => rm(root, { recursive: true, force: true }));
// One header claims 4 GiB of entry data inside a tiny compressed archive:
// integrity still matches, so only the expansion budget can stop it.
const bloated = tgz([{ name: 'package/blob.bin', body: Buffer.from('x') }]);
const bloatedHeader = tarHeader('package/huge.bin', 4 * 1024 * 1024 * 1024);
const oversized = gzipSync(Buffer.concat([bloatedHeader, Buffer.alloc(1024)]));
const crowded = tgz(
Array.from({ length: 6 }, (_, index) => ({ name: `package/file-${index}.js` })),
);
const oversizedPath = join(root, 'oversized.tgz');
const crowdedPath = join(root, 'crowded.tgz');
await Promise.all([
writeFile(oversizedPath, oversized),
writeFile(crowdedPath, crowded),
writeFile(join(root, 'ok.tgz'), bloated),
]);
const budget = { maxExtractedBytes: 1024 * 1024, maxEntries: 5 };
await assert.rejects(
assertRuntimeHostArchiveExpansionBudget(oversizedPath, budget),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package',
);
await assert.rejects(
assertRuntimeHostArchiveExpansionBudget(crowdedPath, budget),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package',
);
await assertRuntimeHostArchiveExpansionBudget(join(root, 'ok.tgz'), budget);
// The bound runs before npm ever sees the archive: an integrity-verified
// but over-budget tarball must not reach the install spawn.
await assert.rejects(
withVerifiedRuntimeHostUpdateArchive(
{
kind: 'npm_registry',
version: '2.0.0',
integrity: `sha512-${createHash('sha512').update(oversized).digest('base64')}`,
},
oversizedPath,
async () => assert.fail('over-budget archive must not be consumed'),
async () => assert.fail('over-budget archive must not reach npm'),
),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package',
);
});
it('accepts entries whose payload spans multiple gunzip chunks', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-update-stream-'));
t.after(() => rm(root, { recursive: true, force: true }));
// A 128 KiB file entry spans many 16 KiB gunzip chunks; the scanner must
// carry the payload skip across chunk boundaries instead of reparsing
// payload bytes as the next header.
const big = tgz([
{ name: 'package/dist/big.js', body: Buffer.alloc(128 * 1024, 65) },
{ name: 'package/package.json', body: Buffer.from('{"name":"maka-agent"}') },
{ name: 'package/dist/cli.js', body: Buffer.from('#!/usr/bin/env node\n') },
]);
const archive = join(root, 'streamed.tgz');
await writeFile(archive, big);
await assertRuntimeHostArchiveExpansionBudget(archive, {
maxExtractedBytes: 256 * 1024,
maxEntries: 10,
});
// The same archive over the budget by one byte must still be caught once
// the whole payload has streamed through.
await assert.rejects(
assertRuntimeHostArchiveExpansionBudget(archive, {
maxExtractedBytes: 128 * 1024,
maxEntries: 10,
}),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package',
);
});
it('fails closed for tar extensions that can reinterpret a raw header size', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-update-extended-tar-'));
t.after(() => rm(root, { recursive: true, force: true }));
for (const type of ['x', 'g', 'X', 'S', 'L', 'K']) {
const archive = join(root, `${type}.tgz`);
await writeFile(
archive,
tgz([
{ name: 'extended-header', body: Buffer.from('size=4294967296\n'), type },
{ name: 'package/package.json', body: Buffer.from('{"name":"maka-agent"}') },
]),
);
await assert.rejects(
assertRuntimeHostArchiveExpansionBudget(archive),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package',
);
}
// This is a syntactically valid POSIX PAX record: its decimal prefix is
// the record's complete byte length. npm would honor it to reinterpret
// the following raw tar header, so the staging seam must reject it before
// it invokes npm at all.
const pax = tgz([
{ name: 'PaxHeader', body: Buffer.from('19 size=4294967296\n'), type: 'x' },
{ name: 'package/package.json', body: Buffer.from('{"name":"maka-agent"}') },
]);
const paxPath = join(root, 'pax.tgz');
await writeFile(paxPath, pax);
let invokedNpm = false;
await assert.rejects(
withVerifiedRuntimeHostUpdateArchive(
{
kind: 'npm_registry',
version: '2.0.0',
integrity: `sha512-${createHash('sha512').update(pax).digest('base64')}`,
},
paxPath,
async () => assert.fail('extended tar archive must not be consumed'),
async () => {
invokedNpm = true;
return 0;
},
),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package',
);
assert.equal(invokedNpm, false);
});
it('requires two consecutive zero blocks to terminate a tar stream', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-update-terminator-'));
t.after(() => rm(root, { recursive: true, force: true }));
const archive = join(root, 'single-zero.tgz');
await writeFile(
archive,
gzipSync(Buffer.concat([tarHeader('package/package.json', 0), Buffer.alloc(512)])),
);
await assert.rejects(
assertRuntimeHostArchiveExpansionBudget(archive),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package',
);
});
it('rejects archives that are not readable gzip tarballs', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-update-tarball-'));
t.after(() => rm(root, { recursive: true, force: true }));
const plainPath = join(root, 'plain.tgz');
const truncatedPath = join(root, 'truncated.tgz');
await Promise.all([
writeFile(plainPath, Buffer.from('verified release archive')),
// A header without its terminating zero blocks.
writeFile(truncatedPath, gzipSync(tarHeader('package/package.json', 0))),
]);
await assert.rejects(
assertRuntimeHostArchiveExpansionBudget(plainPath),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package',
);
await assert.rejects(
assertRuntimeHostArchiveExpansionBudget(truncatedPath),
(error: unknown) =>
error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package',
);
});
});