488 lines
16 KiB
TypeScript
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`
|
|
);
|
|
});
|
|
});
|