// @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 archiver 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 = archiver('zip', { 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.0', 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 = archiver('zip', { 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'), '