Files
toolbox-portal/scripts/assembler.test.ts

488 lines
16 KiB
TypeScript

// @vitest-environment node
import { execFileSync } from 'node:child_process';
import { createWriteStream } from 'node:fs';
import {
mkdtemp,
mkdir,
readFile,
rename,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { pathToFileURL } from 'node:url';
import { ZipArchive } from 'archiver';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { assembleRelease } from './assemble.mjs';
import { createStaticArchive } from './archive.mjs';
import { acquireArtifact, sha256File } from './artifact.mjs';
import { publishStagedTargets } from './publication.mjs';
import { parseReleaseLock } from './release-lock.mjs';
import { inspectAppArchive } from './zip-security.mjs';
const temporaryDirectories: string[] = [];
afterEach(async () => {
vi.unstubAllGlobals();
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
);
});
async function temporaryDirectory() {
const directory = await mkdtemp(
path.join(os.tmpdir(), 'toolbox-portal-test-')
);
temporaryDirectories.push(directory);
return directory;
}
async function zipEntries(
output: string,
entries: Array<{ name: string; content: string }>
) {
const archive = new ZipArchive({ zlib: { level: 9 } });
const writing = pipeline(archive, createWriteStream(output, { flags: 'wx' }));
for (const entry of entries)
archive.append(entry.content, { name: entry.name });
await archive.finalize();
await writing;
}
const manifest = {
schemaVersion: 1,
id: 'de.add-ideas.test-tool',
name: 'Test tool',
version: '1.2.3',
description: 'Test fixture',
entry: './',
icon: './favicon.svg',
categories: ['test'],
tags: [],
integration: {
contextVersion: 1,
launchModes: ['navigate'],
embedding: 'unsupported',
},
requirements: {
secureContext: false,
workers: false,
indexedDb: false,
crossOriginIsolated: false,
},
privacy: { processing: 'local', fileUploads: false, telemetry: false },
source: { repository: 'https://example.com/test-tool', license: 'MIT' },
};
function makeLock(artifact: string, sha256: string) {
return {
schemaVersion: 1,
releaseVersion: '0.1.0',
portalVersion: '0.2.10',
catalogue: {
id: 'de.add-ideas.toolbox',
name: 'Test Toolbox',
home: './',
theme: { mode: 'system', brand: 'add·ideas' },
},
apps: [
{
id: manifest.id,
version: manifest.version,
artifact,
sha256,
target: 'test',
},
],
};
}
describe('release assembly integrity', () => {
it('rejects latest aliases and malformed checksums in release locks', () => {
expect(() =>
parseReleaseLock(
makeLock('https://example.com/latest/tool.zip', '0'.repeat(64))
)
).toThrow(/latest alias/i);
expect(() => parseReleaseLock(makeLock('./tool.zip', 'ABC'))).toThrow(
/sha256/i
);
});
it('refuses an artifact whose SHA-256 does not match', async () => {
const directory = await temporaryDirectory();
const artifact = path.join(directory, 'app.zip');
await writeFile(artifact, 'not the expected artifact');
await expect(
acquireArtifact(
artifact,
'0'.repeat(64),
directory,
path.join(directory, 'downloads')
)
).rejects.toThrow(/SHA-256 mismatch/);
});
it('rejects credential-bearing HTTPS sources and self-containing archives', async () => {
const directory = await temporaryDirectory();
await expect(
acquireArtifact(
'https://user:secret@example.com/tool.zip',
'0'.repeat(64),
directory,
path.join(directory, 'downloads')
)
).rejects.toThrow(/credentials/i);
await expect(
createStaticArchive(directory, path.join(directory, 'self.zip'))
).rejects.toThrow(/overlap/i);
});
it('canonicalizes symlink aliases before archive overlap checks', async () => {
const directory = await temporaryDirectory();
const input = path.join(directory, 'input');
const alias = path.join(directory, 'input-alias');
await mkdir(input);
await writeFile(path.join(input, 'index.html'), 'release input');
await symlink(input, alias, 'dir');
await expect(
createStaticArchive(alias, path.join(input, 'self.zip'))
).rejects.toThrow(/overlap/i);
});
it('rejects a dangling symlink in an archive output parent', async () => {
const directory = await temporaryDirectory();
const input = path.join(directory, 'input');
const dangling = path.join(directory, 'dangling');
await mkdir(input);
await writeFile(path.join(input, 'index.html'), 'release input');
await symlink(path.join(directory, 'missing'), dangling, 'dir');
await expect(
createStaticArchive(input, path.join(dangling, 'release.zip'))
).rejects.toThrow(/dangling symbolic link/i);
});
it('rejects insecure or credential-bearing redirect targets', async () => {
const directory = await temporaryDirectory();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'http://example.com/tool.zip' },
})
)
);
await expect(
acquireArtifact(
'https://example.com/tool.zip',
'0'.repeat(64),
directory,
path.join(directory, 'downloads')
)
).rejects.toThrow(/redirect URL must use HTTPS/i);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'https://user:secret@example.com/tool.zip' },
})
)
);
await expect(
acquireArtifact(
'https://example.com/tool.zip',
'0'.repeat(64),
directory,
path.join(directory, 'other-downloads')
)
).rejects.toThrow(/redirect URL must not contain credentials/i);
});
it('blocks ZIP path traversal before extraction', async () => {
const directory = await temporaryDirectory();
const safeZip = path.join(directory, 'safe.zip');
const hostileZip = path.join(directory, 'hostile.zip');
await zipEntries(safeZip, [
{ name: 'toolbox-app.json', content: JSON.stringify(manifest) },
{ name: 'xx/evil.txt', content: 'escape attempt' },
]);
const buffer = await readFile(safeZip);
const before = Buffer.from('xx/evil.txt');
const after = Buffer.from('../evil.txt');
let replacements = 0;
for (
let offset = buffer.indexOf(before);
offset >= 0;
offset = buffer.indexOf(before, offset + after.length)
) {
after.copy(buffer, offset);
replacements += 1;
}
expect(replacements).toBeGreaterThanOrEqual(2);
await writeFile(hostileZip, buffer);
await expect(inspectAppArchive(hostileZip)).rejects.toThrow(
/relative path|traversal/i
);
});
it('blocks symbolic links before extraction', async () => {
const directory = await temporaryDirectory();
const artifact = path.join(directory, 'symlink.zip');
const archive = new ZipArchive({ zlib: { level: 9 } });
const writing = pipeline(
archive,
createWriteStream(artifact, { flags: 'wx' })
);
archive.append(JSON.stringify(manifest), { name: 'toolbox-app.json' });
archive.symlink('link', '../outside');
await archive.finalize();
await writing;
await expect(inspectAppArchive(artifact)).rejects.toThrow(/symbolic link/i);
});
it('packages the same files into byte-identical static archives', async () => {
const directory = await temporaryDirectory();
const input = path.join(directory, 'input');
const first = path.join(directory, 'first.zip');
const second = path.join(directory, 'second.zip');
await mkdir(input);
await writeFile(path.join(input, 'z.txt'), 'last');
await writeFile(path.join(input, 'ä.txt'), 'middle');
await writeFile(path.join(input, '😀.txt'), 'astral');
await createStaticArchive(input, first);
await createStaticArchive(input, second);
expect(await sha256File(first)).toBe(await sha256File(second));
});
it('packages byte-identical static archives in different host time zones', async () => {
const directory = await temporaryDirectory();
const input = path.join(directory, 'input');
const utcArchive = path.join(directory, 'utc.zip');
const honoluluArchive = path.join(directory, 'honolulu.zip');
await mkdir(input);
await writeFile(path.join(input, 'index.html'), 'timezone invariant');
const moduleUrl = pathToFileURL(
path.join(process.cwd(), 'scripts/archive.mjs')
).href;
const run = (timezone: string, output: string) => {
const program = `
import { createStaticArchive } from ${JSON.stringify(moduleUrl)};
await createStaticArchive(${JSON.stringify(input)}, ${JSON.stringify(output)});
`;
execFileSync(
process.execPath,
['--input-type=module', '--eval', program],
{ env: { ...process.env, TZ: timezone } }
);
};
run('UTC', utcArchive);
run('Pacific/Honolulu', honoluluArchive);
expect(await sha256File(utcArchive)).toBe(
await sha256File(honoluluArchive)
);
});
it('rejects a valid archive whose manifest identity is not the lock identity', async () => {
const directory = await temporaryDirectory();
const portalDist = path.join(directory, 'portal-dist');
const artifact = path.join(directory, 'wrong-tool.zip');
await mkdir(portalDist);
await writeFile(
path.join(portalDist, 'index.html'),
'<!doctype html><title>Toolbox</title>'
);
await zipEntries(artifact, [
{
name: 'toolbox-app.json',
content: JSON.stringify({ ...manifest, id: 'de.add-ideas.other-tool' }),
},
]);
const lockFile = path.join(directory, 'toolbox.lock.json');
await writeFile(
lockFile,
JSON.stringify(makeLock(artifact, await sha256File(artifact)))
);
await expect(
assembleRelease({
lockFile,
portalDist,
outputDirectory: path.join(directory, 'assembled'),
})
).rejects.toThrow(/manifest id mismatch/i);
});
it('refuses a force output symlink that aliases the portal input', async () => {
const directory = await temporaryDirectory();
const portalDist = path.join(directory, 'portal-dist');
const outputAlias = path.join(directory, 'output-alias');
const lockFile = path.join(directory, 'toolbox.lock.json');
await mkdir(portalDist);
await writeFile(path.join(portalDist, 'index.html'), 'portal remains');
await symlink(portalDist, outputAlias, 'dir');
await writeFile(lockFile, '{}');
await expect(
assembleRelease({
lockFile,
portalDist,
outputDirectory: outputAlias,
force: true,
})
).rejects.toThrow(/must not overlap/i);
expect(await readFile(path.join(portalDist, 'index.html'), 'utf8')).toBe(
'portal remains'
);
});
it('rolls all targets back if transactional publication fails', async () => {
const directory = await temporaryDirectory();
const stagedFirst = path.join(directory, 'staged-first');
const stagedSecond = path.join(directory, 'staged-second');
const targetFirst = path.join(directory, 'target-first');
const targetSecond = path.join(directory, 'target-second');
await writeFile(stagedFirst, 'new first');
await writeFile(stagedSecond, 'new second');
await writeFile(targetFirst, 'old first');
await writeFile(targetSecond, 'old second');
const renameWithFailure = async (source: string, target: string) => {
if (source === stagedSecond) throw new Error('injected rename failure');
await rename(source, target);
};
await expect(
publishStagedTargets(
[
{ staged: stagedFirst, target: targetFirst },
{ staged: stagedSecond, target: targetSecond },
],
{ force: true, renamePath: renameWithFailure }
)
).rejects.toThrow(/injected rename failure/i);
expect(await readFile(targetFirst, 'utf8')).toBe('old first');
expect(await readFile(targetSecond, 'utf8')).toBe('old second');
});
it('requires every manifest icon and declared asset in the app archive', async () => {
const directory = await temporaryDirectory();
const portalDist = path.join(directory, 'portal-dist');
const artifact = path.join(directory, 'missing-assets.zip');
await mkdir(portalDist);
await writeFile(
path.join(portalDist, 'index.html'),
'<title>Portal</title>'
);
await zipEntries(artifact, [
{ name: 'index.html', content: '<title>App</title>' },
{
name: 'toolbox-app.json',
content: JSON.stringify({
...manifest,
assets: ['./runtime.js'],
}),
},
]);
const lockFile = path.join(directory, 'toolbox.lock.json');
await writeFile(
lockFile,
JSON.stringify(makeLock(artifact, await sha256File(artifact)))
);
await expect(
assembleRelease({
lockFile,
portalDist,
outputDirectory: path.join(directory, 'assembled'),
})
).rejects.toThrow(/manifest\.icon is missing/i);
const artifactWithIcon = path.join(directory, 'missing-runtime.zip');
await zipEntries(artifactWithIcon, [
{ name: 'index.html', content: '<title>App</title>' },
{ name: 'favicon.svg', content: '<svg />' },
{
name: 'toolbox-app.json',
content: JSON.stringify({
...manifest,
assets: ['./runtime.js'],
}),
},
]);
await writeFile(
lockFile,
JSON.stringify(
makeLock(artifactWithIcon, await sha256File(artifactWithIcon))
)
);
await expect(
assembleRelease({
lockFile,
portalDist,
outputDirectory: path.join(directory, 'assembled-again'),
})
).rejects.toThrow(/manifest\.assets\[0\] is missing/i);
});
it('assembles only the pinned artifact, validates its manifest, and emits provenance', async () => {
const directory = await temporaryDirectory();
const portalDist = path.join(directory, 'portal-dist');
const artifact = path.join(directory, 'test-tool-1.2.3.zip');
const output = path.join(directory, 'assembled');
const archive = path.join(directory, 'toolbox-0.1.0.zip');
await mkdir(portalDist);
await writeFile(
path.join(portalDist, 'index.html'),
'<!doctype html><title>Toolbox</title>'
);
await zipEntries(artifact, [
{ name: 'index.html', content: '<!doctype html><title>Test app</title>' },
{
name: 'favicon.svg',
content: '<svg xmlns="http://www.w3.org/2000/svg"/>',
},
{ name: 'toolbox-app.json', content: JSON.stringify(manifest) },
]);
const checksum = await sha256File(artifact);
const lockFile = path.join(directory, 'toolbox.lock.json');
await writeFile(lockFile, JSON.stringify(makeLock(artifact, checksum)));
await assembleRelease({
lockFile,
portalDist,
outputDirectory: output,
archiveFile: archive,
});
const catalogue = JSON.parse(
await readFile(path.join(output, 'toolbox.catalog.json'), 'utf8')
);
const release = JSON.parse(
await readFile(path.join(output, 'toolbox.release.json'), 'utf8')
);
expect(catalogue.apps).toEqual([
{ manifest: './apps/test/toolbox-app.json', enabled: true },
]);
expect(release.apps[0]).toEqual({
id: manifest.id,
version: manifest.version,
sha256: checksum,
target: 'test',
});
expect(
await readFile(path.join(output, 'apps/test/index.html'), 'utf8')
).toContain('Test app');
expect((await readFile(archive)).length).toBeGreaterThan(0);
expect(await readFile(`${archive}.sha256`, 'utf8')).toBe(
`${await sha256File(archive)} ${path.basename(archive)}\n`
);
});
});