266 lines
7.4 KiB
TypeScript
266 lines
7.4 KiB
TypeScript
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 })
|
|
}
|
|
})
|
|
})
|