import { createHash } from 'node:crypto' import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' interface TargetDefinition { platform: 'windows' | 'macos' | 'linux' arch: 'x64' | 'arm64' formats: string[] } interface AggregateModule { aggregateRelease: ( inputDirectory: string, outputDirectory: string ) => Promise<{ version: string targets: Array<{ platform: string arch: string manifest: string }> }> assertSafeName: (name: string, description: string) => void targetDefinitions: TargetDefinition[] } const require = createRequire(import.meta.url) const aggregate = require( '../build/aggregate-release.cjs' ) as AggregateModule const packageVersion = ( require('../package.json') as { version: string } ).version function sha256(value: string): string { return createHash('sha256').update(value).digest('hex') } function artifactName( target: TargetDefinition, format: string ): string { const base = `GoodBuddy-${packageVersion}-${target.platform}-${target.arch}` if (format === 'nsis') { return `${base}-setup.exe` } if (format === 'portable') { return `${base}-portable.exe` } const extension = format === 'zip' ? 'zip' : format return `${base}.${extension}` } function createDownloadedArtifacts(parent: string): string { const input = join(parent, 'downloads') mkdirSync(input) for (const target of aggregate.targetDefinitions) { const key = `${target.platform}-${target.arch}` const directory = join(input, `goodbuddy-${key}`) mkdirSync(directory) const files = target.formats.map((format) => { const name = artifactName(target, format) const content = `${key}:${format}` writeFileSync(join(directory, name), content) return { name, size: Buffer.byteLength(content), sha256: sha256(content) } }) const debugContent = `${key}:debug` writeFileSync( join(directory, 'builder-debug.yml'), debugContent ) files.push({ name: 'builder-debug.yml', size: Buffer.byteLength(debugContent), sha256: sha256(debugContent) }) const metadataName = `metadata-${key}.json` const metadataContent = `${key}:metadata` writeFileSync( join(directory, metadataName), metadataContent ) files.push({ name: metadataName, size: Buffer.byteLength(metadataContent), sha256: sha256(metadataContent) }) if (target.platform === 'windows') { const setupName = artifactName(target, 'nsis') const blockmapName = `${setupName}.blockmap` const blockmapContent = `${key}:blockmap` writeFileSync( join(directory, blockmapName), blockmapContent ) files.push({ name: blockmapName, size: Buffer.byteLength(blockmapContent), sha256: sha256(blockmapContent) }) } writeFileSync( join(directory, 'release-manifest.json'), `${JSON.stringify({ formatVersion: 1, productName: 'GoodBuddy', version: packageVersion, platform: target.platform, arch: target.arch, formats: target.formats, files }, null, 2)}\n` ) } return input } describe('release asset aggregation', () => { it('strictly verifies six targets and writes isolated upload assets', async () => { const parent = mkdtempSync( join(tmpdir(), 'goodbuddy-release-aggregate-') ) try { const input = createDownloadedArtifacts(parent) const output = join(parent, 'upload') const manifest = await aggregate.aggregateRelease(input, output) expect(manifest.version).toBe(packageVersion) expect(manifest.targets).toHaveLength(6) expect( manifest.targets.map((target) => target.manifest) ).toEqual([ 'release-manifest-windows-x64.json', 'release-manifest-windows-arm64.json', 'release-manifest-macos-x64.json', 'release-manifest-macos-arm64.json', 'release-manifest-linux-x64.json', 'release-manifest-linux-arm64.json' ]) const outputNames = readdirSync(output) expect(outputNames).toHaveLength(20) expect(outputNames).toContain('release-manifest.json') expect(outputNames).toContain('SHA256SUMS') expect(outputNames).not.toContain('builder-debug.yml') expect( outputNames.some((name) => name.startsWith('metadata-')) ).toBe(false) expect( outputNames.some((name) => name.endsWith('.blockmap')) ).toBe(false) const windowsManifest = JSON.parse( readFileSync( join(output, 'release-manifest-windows-x64.json'), 'utf8' ) ) as { files: Array<{ name: string }> } expect(windowsManifest.files.map((file) => file.name)).toEqual([ artifactName(aggregate.targetDefinitions[0]!, 'nsis'), artifactName(aggregate.targetDefinitions[0]!, 'portable') ]) const sums = readFileSync( join(output, 'SHA256SUMS'), 'utf8' ) expect(sums.trim().split('\n')).toHaveLength(19) expect(sums).toContain( 'release-manifest-windows-x64.json' ) expect(sums).not.toMatch(/\sSHA256SUMS(?:\r?\n|$)/u) } finally { rmSync(parent, { recursive: true, force: true }) } }) it('rejects package hash mismatches', async () => { const parent = mkdtempSync( join(tmpdir(), 'goodbuddy-release-hash-') ) try { const input = createDownloadedArtifacts(parent) const file = join( input, 'goodbuddy-windows-x64', artifactName(aggregate.targetDefinitions[0]!, 'nsis') ) writeFileSync(file, 'tampered') await expect( aggregate.aggregateRelease(input, join(parent, 'upload')) ).rejects.toThrow('完整性校验失败') } finally { rmSync(parent, { recursive: true, force: true }) } }) it('verifies auxiliary files even though they are not published', async () => { const parent = mkdtempSync( join(tmpdir(), 'goodbuddy-release-auxiliary-') ) try { const input = createDownloadedArtifacts(parent) writeFileSync( join( input, 'goodbuddy-windows-x64', 'builder-debug.yml' ), 'tampered' ) await expect( aggregate.aggregateRelease(input, join(parent, 'upload')) ).rejects.toThrow('完整性校验失败') } finally { rmSync(parent, { recursive: true, force: true }) } }) it.each([ '../escape.exe', '..\\escape.exe', '/tmp/escape.exe', 'nested/file.exe' ])('rejects path traversal in file name %s', (name) => { expect(() => aggregate.assertSafeName(name, '测试文件名') ).toThrow('不安全路径') }) it('rejects undeclared files', async () => { const parent = mkdtempSync( join(tmpdir(), 'goodbuddy-release-extra-') ) try { const input = createDownloadedArtifacts(parent) writeFileSync( join(input, 'goodbuddy-linux-x64', 'unknown.rpm'), 'unknown' ) await expect( aggregate.aggregateRelease(input, join(parent, 'upload')) ).rejects.toThrow('未声明的文件') } finally { rmSync(parent, { recursive: true, force: true }) } }) })