// @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'), 'Toolbox' ); 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'), 'Portal' ); await zipEntries(artifact, [ { name: 'index.html', content: 'App' }, { 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: 'App' }, { name: 'favicon.svg', content: '' }, { 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'), 'Toolbox' ); await zipEntries(artifact, [ { name: 'index.html', content: 'Test app' }, { name: 'favicon.svg', content: '', }, { 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` ); }); });