Files
goodbuddy/src/main/model-archive.test.ts
T

212 lines
5.9 KiB
TypeScript

import { createHash } from 'node:crypto'
import {
mkdtemp,
mkdir,
readFile,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { zipSync } from 'fflate'
import { afterEach, describe, expect, it } from 'vitest'
import {
exportModelArchive,
extractModelArchive
} from './model-archive'
const temporaryDirectories: string[] = []
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-model-archive-')
)
temporaryDirectories.push(directory)
return directory
}
function sha256(value: Uint8Array): string {
return createHash('sha256').update(value).digest('hex')
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('model archive', () => {
it('exports and extracts only declared verified model files', async () => {
const directory = await temporaryDirectory()
const source = join(directory, 'source')
const extracted = join(directory, 'extracted')
const archive = join(directory, 'model.zip')
await Promise.all([mkdir(source), mkdir(extracted)])
const model = Buffer.from('verified model bytes')
const tokens = Buffer.from('verified tokens')
await Promise.all([
writeFile(join(source, 'model.onnx'), model),
writeFile(join(source, 'tokens.txt'), tokens),
writeFile(join(source, 'ignored.txt'), 'not exported'),
writeFile(archive, 'archive selected for replacement')
])
await exportModelArchive({
destinationPath: archive,
sourceDirectory: source,
descriptor: {
kind: 'speech',
modelId: 'test-model',
displayName: 'Test model',
files: [
{
name: 'model.onnx',
role: 'model',
size: model.byteLength,
sha256: sha256(model)
},
{
name: 'tokens.txt',
role: 'tokens',
size: tokens.byteLength,
sha256: sha256(tokens)
}
]
}
})
await expect(
extractModelArchive({
archivePath: archive,
destinationDirectory: extracted,
expectedKind: 'speech',
expectedModelId: 'test-model',
expectedFiles: [
{ name: 'model.onnx', role: 'model' },
{ name: 'tokens.txt', role: 'tokens' }
],
maximumArchiveBytes: 1024 * 1024,
maximumFileBytes: 1024,
maximumTotalBytes: 2048
})
).resolves.toMatchObject({
kind: 'speech',
modelId: 'test-model'
})
await expect(readFile(join(extracted, 'model.onnx'))).resolves.toEqual(
model
)
await expect(readFile(join(extracted, 'tokens.txt'))).resolves.toEqual(
tokens
)
})
it('preserves an existing archive when source verification fails', async () => {
const directory = await temporaryDirectory()
const source = join(directory, 'source')
const archive = join(directory, 'model.zip')
await mkdir(source)
const model = Buffer.from('changed model')
await Promise.all([
writeFile(join(source, 'model.onnx'), model),
writeFile(archive, 'existing archive')
])
await expect(
exportModelArchive({
destinationPath: archive,
sourceDirectory: source,
descriptor: {
kind: 'speech',
modelId: 'test-model',
displayName: 'Test model',
files: [
{
name: 'model.onnx',
role: 'model',
size: model.byteLength,
sha256: 'a'.repeat(64)
}
]
}
})
).rejects.toThrow('模型文件校验失败')
await expect(readFile(archive, 'utf8')).resolves.toBe(
'existing archive'
)
})
it('rejects path traversal and undeclared archive entries', async () => {
const directory = await temporaryDirectory()
const archive = join(directory, 'unsafe.zip')
const extracted = join(directory, 'extracted')
await mkdir(extracted)
await writeFile(
archive,
zipSync({
'../model.onnx': Buffer.from('unsafe')
})
)
await expect(
extractModelArchive({
archivePath: archive,
destinationDirectory: extracted,
expectedKind: 'speech',
expectedModelId: 'test-model',
expectedFiles: [{ name: 'model.onnx', role: 'model' }],
maximumArchiveBytes: 1024 * 1024,
maximumFileBytes: 1024,
maximumTotalBytes: 1024
})
).rejects.toThrow()
})
it('rejects an archive whose manifest model ID does not match', async () => {
const directory = await temporaryDirectory()
const archive = join(directory, 'mismatch.zip')
const extracted = join(directory, 'extracted')
await mkdir(extracted)
const model = Buffer.from('model')
await writeFile(
archive,
zipSync({
'goodbuddy-model.json': Buffer.from(
JSON.stringify({
format: 'goodbuddy-model-archive',
version: 1,
kind: 'speech',
modelId: 'other-model',
displayName: 'Other model',
exportedAt: '2026-08-11T00:00:00.000Z',
files: [
{
name: 'model.onnx',
role: 'model',
size: model.byteLength,
sha256: sha256(model)
}
]
})
),
'model.onnx': model
})
)
await expect(
extractModelArchive({
archivePath: archive,
destinationDirectory: extracted,
expectedKind: 'speech',
expectedModelId: 'test-model',
expectedFiles: [{ name: 'model.onnx', role: 'model' }],
maximumArchiveBytes: 1024 * 1024,
maximumFileBytes: 1024,
maximumTotalBytes: 1024
})
).rejects.toThrow('模型 ID 不匹配')
})
})