d3ro-voice/apps/desktop/tests/main/services/runtime-provisioner-redteam-r1-8.test.ts

390 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// tests/main/services/runtime-provisioner-redteam-r1-8.test.ts
// RuntimeProvisioner 분리(인덱스 검증 / 부품 다운로드 / 스테이징 설치)와 회귀 테스트.
//
// 회귀 대상:
// 1) tar 항목 쓰기 오류가 경고로 삼켜져 반쯤 풀린 엔진에 버전 마커가 붙던 문제
// 2) 부품 전체 전송에 120초 제한이 걸려 느린 링크에서 영영 설치할 수 없던 문제
// 3) "다시 설치" 가 설치된 런타임에서 아무 일도 하지 않던 문제
// 4) 최소 버전 미만 인덱스를 받아들여 매번 다시 받는 루프
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as tar from 'tar'
import {
isRuntimeVersionSatisfied,
parseRuntimeIndex,
type RuntimePart,
} from '../../../src/main/services/runtime/runtime-index'
import {
downloadPart,
type RuntimeFetch,
type RuntimeFetchResponse,
} from '../../../src/main/services/runtime/download-part'
import { installArchive } from '../../../src/main/services/runtime/install-archive'
import { RuntimeProvisioner } from '../../../src/main/services/RuntimeProvisioner'
// ── 헬퍼 ────────────────────────────────────────────────────────────
const sha = (data: Buffer): string => createHash('sha256').update(data).digest('hex')
type TarEntry = { path: string; body?: string; type?: 'File' | 'Directory' }
/** 손으로 만든 tar (파일 다음에 같은 이름의 하위 항목을 넣어 항목 오류를 재현할 수 있다) */
function buildTar(entries: TarEntry[]): Buffer {
const blocks: Buffer[] = []
for (const entry of entries) {
const type = entry.type ?? 'File'
const body = Buffer.from(entry.body ?? '')
const header = new tar.Header({
path: entry.path,
mode: type === 'File' ? 0o644 : 0o755,
size: type === 'File' ? body.length : 0,
type,
mtime: new Date(0),
uid: 0,
gid: 0,
})
const headerBlock = Buffer.alloc(512)
header.encode(headerBlock, 0)
blocks.push(headerBlock)
if (type === 'File') {
blocks.push(body, Buffer.alloc((512 - (body.length % 512)) % 512))
}
}
blocks.push(Buffer.alloc(1024))
return Buffer.concat(blocks)
}
const SIDECAR_BIN = process.platform === 'win32' ? 'sidecar.exe' : 'sidecar'
function sidecarTar(version: string): Buffer {
return buildTar([
{ path: SIDECAR_BIN, body: `engine ${version}` },
{ path: '_internal', type: 'Directory' },
{ path: '_internal/core.dll', body: 'dll' },
])
}
/** 항목 하나가 반드시 실패하는 tar: 파일 `_internal/x` 뒤에 `_internal/x/y` */
function brokenSidecarTar(): Buffer {
return buildTar([
{ path: SIDECAR_BIN, body: 'engine' },
{ path: '_internal', type: 'Directory' },
{ path: '_internal/x', body: 'file in the way' },
{ path: '_internal/x/y.dll', body: 'never written' },
])
}
function response(
body: BodyInit | null,
init: { status?: number; headers?: Record<string, string> } = {},
): RuntimeFetchResponse {
return new Response(body, { status: init.status ?? 200, headers: init.headers }) as RuntimeFetchResponse
}
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
/** chunkCount 개의 청크를 intervalMs 간격으로 보내는 스트림 */
function slowStream(data: Buffer, chunkCount: number, intervalMs: number): ReadableStream<Uint8Array> {
const chunkSize = Math.ceil(data.length / chunkCount)
let offset = 0
return new ReadableStream<Uint8Array>({
async pull(controller) {
if (offset >= data.length) {
controller.close()
return
}
await delay(intervalMs)
controller.enqueue(new Uint8Array(data.subarray(offset, offset + chunkSize)))
offset += chunkSize
},
})
}
/** 앞부분 bytes 만 보내고 영원히 멈추는 스트림 */
function stallingStream(data: Buffer, bytes: number): ReadableStream<Uint8Array> {
let sent = false
return new ReadableStream<Uint8Array>({
pull(controller) {
if (!sent) {
sent = true
controller.enqueue(new Uint8Array(data.subarray(0, bytes)))
return
}
return new Promise<void>(() => undefined)
},
})
}
let workDir: string
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'd3ro-runtime-r1-8-'))
})
afterEach(() => {
rmSync(workDir, { recursive: true, force: true })
})
// ── parseRuntimeIndex ───────────────────────────────────────────────
function validIndex(version = '1.9.0'): Record<string, unknown> {
const part = Buffer.from('abc')
return {
schemaVersion: 1,
version,
components: {
sidecar: {
archive: 'd3ro-runtime-sidecar.tar.gz',
sha256: sha(part),
totalSize: part.length,
parts: [{ name: 'd3ro-runtime-sidecar.tar.gz.001', size: part.length, sha256: sha(part), url: 'https://feed/x.001' }],
},
},
}
}
describe('parseRuntimeIndex', () => {
it('정상 인덱스에서 구성 요소 항목과 버전을 돌려준다', () => {
const resolved = parseRuntimeIndex(validIndex(), 'sidecar', '1.7.0')
expect(resolved.version).toBe('1.9.0')
expect(resolved.entry.parts).toHaveLength(1)
})
it('최소 버전보다 낮은 인덱스는 거부한다 (재다운로드 루프 방지)', () => {
expect(() => parseRuntimeIndex(validIndex('1.6.9'), 'sidecar', '1.7.0')).toThrow(/최소 요구 버전/)
})
it('semver 가 아닌 version 은 거부한다', () => {
expect(() => parseRuntimeIndex(validIndex('latest'), 'sidecar', '1.7.0')).toThrow(/semver/)
})
it('경로 구분자나 .. 가 든 부품 이름은 거부한다', () => {
const index = validIndex()
const components = index.components as Record<string, { parts: RuntimePart[] }>
components.sidecar.parts[0].name = '..\\..\\evil.dll'
expect(() => parseRuntimeIndex(index, 'sidecar', '1.7.0')).toThrow(/안전한 파일 이름/)
})
it('해시가 SHA-256 형식이 아니면 거부한다', () => {
const index = validIndex()
const components = index.components as Record<string, { sha256: string }>
components.sidecar.sha256 = 'nope'
expect(() => parseRuntimeIndex(index, 'sidecar', '1.7.0')).toThrow(/SHA-256/)
})
it('구성 요소가 없으면 그 이름으로 알린다', () => {
expect(() => parseRuntimeIndex(validIndex(), 'ffmpeg', null)).toThrow(/ffmpeg 구성 요소가 없습니다/)
})
it('isRuntimeVersionSatisfied 는 최소 버전 null 이면 항상 참, 읽을 수 없으면 거짓', () => {
expect(isRuntimeVersionSatisfied(undefined, null)).toBe(true)
expect(isRuntimeVersionSatisfied('1.7.0\n', '1.7.0')).toBe(true)
expect(isRuntimeVersionSatisfied('1.6.0', '1.7.0')).toBe(false)
expect(isRuntimeVersionSatisfied('garbage', '1.7.0')).toBe(false)
})
})
// ── downloadPart ────────────────────────────────────────────────────
describe('downloadPart', () => {
const data = Buffer.alloc(64 * 1024, 7)
const part: RuntimePart = { name: 'p.001', size: data.length, sha256: sha(data), url: 'https://feed/p.001' }
it('전체 전송이 정체 타임아웃보다 길어도 데이터가 계속 오면 끝까지 받는다', async () => {
// 10청크 × 40ms ≈ 400ms > stallTimeout 150ms — 예전(전송 전체 타임아웃)에는 끊겼다
const fetchImpl: RuntimeFetch = async () => response(slowStream(data, 10, 40))
const dest = join(workDir, part.name)
const size = await downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 150, attempts: 1 })
expect(size).toBe(data.length)
expect(sha(readFileSync(dest))).toBe(part.sha256)
})
it('데이터가 멈추면 끊고, 재시도에서 Range 로 이어 받는다', async () => {
const requests: Array<Record<string, string> | undefined> = []
const fetchImpl: RuntimeFetch = async (_url, init) => {
requests.push(init.headers)
if (requests.length === 1) return response(stallingStream(data, 1000))
const range = init.headers?.Range ?? ''
const start = Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0)
return response(new Uint8Array(data.subarray(start)), {
status: 206,
headers: { 'content-range': `bytes ${start}-${data.length - 1}/${data.length}` },
})
}
const dest = join(workDir, part.name)
const size = await downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 80, attempts: 2 })
expect(size).toBe(data.length)
expect(requests[0]).toBeUndefined()
expect(requests[1]).toEqual({ Range: 'bytes=1000-' })
expect(sha(readFileSync(dest))).toBe(part.sha256)
})
it('모든 시도가 정체되면 정체 오류로 실패하고 부분 파일을 남기지 않는다', async () => {
const fetchImpl: RuntimeFetch = async () => response(stallingStream(data, 10))
const dest = join(workDir, part.name)
await expect(downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 50, attempts: 2 })).rejects.toThrow(/멈췄습니다/)
expect(existsSync(dest)).toBe(false)
})
it('해시가 틀리면 부분 파일을 버리고 처음부터 다시 받는다', async () => {
let calls = 0
const fetchImpl: RuntimeFetch = async (_url, init) => {
calls += 1
expect(init.headers).toBeUndefined()
return response(new Uint8Array(calls === 1 ? Buffer.alloc(data.length, 1) : data))
}
const dest = join(workDir, part.name)
await downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 1000, attempts: 2 })
expect(calls).toBe(2)
})
})
// ── installArchive ──────────────────────────────────────────────────
describe('installArchive', () => {
const verify = (dir: string): boolean =>
existsSync(join(dir, SIDECAR_BIN)) && existsSync(join(dir, '_internal'))
function seedPrevious(targetDir: string): void {
mkdirSync(join(targetDir, '_internal'), { recursive: true })
writeFileSync(join(targetDir, SIDECAR_BIN), 'old engine')
writeFileSync(join(targetDir, '.runtime-version'), '1.7.0')
}
it('항목 쓰기 오류가 나면 실패하고 기존 설치와 마커를 그대로 둔다', async () => {
const archivePath = join(workDir, 'broken.tar')
writeFileSync(archivePath, brokenSidecarTar())
const targetDir = join(workDir, 'sidecar')
seedPrevious(targetDir)
await expect(
installArchive({ archivePath, targetDir, verify, marker: { fileName: '.runtime-version', content: '1.9.0' } }),
).rejects.toThrow()
expect(readFileSync(join(targetDir, SIDECAR_BIN), 'utf8')).toBe('old engine')
expect(readFileSync(join(targetDir, '.runtime-version'), 'utf8')).toBe('1.7.0')
expect(existsSync(`${targetDir}.staging`)).toBe(false)
})
it('성공하면 새 트리로 교체하고 마커를 새 버전으로 쓴다', async () => {
const archivePath = join(workDir, 'ok.tar')
writeFileSync(archivePath, sidecarTar('1.9.0'))
const targetDir = join(workDir, 'sidecar')
seedPrevious(targetDir)
await installArchive({ archivePath, targetDir, verify, marker: { fileName: '.runtime-version', content: '1.9.0' } })
expect(readFileSync(join(targetDir, SIDECAR_BIN), 'utf8')).toBe('engine 1.9.0')
expect(readFileSync(join(targetDir, '.runtime-version'), 'utf8')).toBe('1.9.0')
expect(existsSync(join(targetDir, '_internal', 'core.dll'))).toBe(true)
expect(existsSync(`${targetDir}.old`)).toBe(false)
})
it('필수 파일이 없으면 교체하지 않는다', async () => {
const archivePath = join(workDir, 'noexe.tar')
writeFileSync(archivePath, buildTar([{ path: 'readme.txt', body: 'x' }]))
const targetDir = join(workDir, 'sidecar')
seedPrevious(targetDir)
await expect(installArchive({ archivePath, targetDir, verify })).rejects.toThrow(/필수 파일/)
expect(readFileSync(join(targetDir, SIDECAR_BIN), 'utf8')).toBe('old engine')
})
})
// ── RuntimeProvisioner (주입된 IO로 전체 흐름) ─────────────────────────
describe('RuntimeProvisioner', () => {
const FEED = 'https://feed.test/runtime-latest'
function feedFor(archive: Buffer, version: string): { fetchImpl: RuntimeFetch; calls: string[] } {
const calls: string[] = []
const partName = 'd3ro-runtime-sidecar.tar.gz.001'
const index = {
schemaVersion: 1,
version,
components: {
sidecar: {
archive: 'd3ro-runtime-sidecar.tar.gz',
sha256: sha(archive),
totalSize: archive.length,
parts: [{ name: partName, size: archive.length, sha256: sha(archive), url: `${FEED}/${partName}` }],
},
},
}
const fetchImpl: RuntimeFetch = async (url) => {
calls.push(url)
if (url === `${FEED}/runtime.json`) return response(JSON.stringify(index))
if (url === `${FEED}/${partName}`) return response(new Uint8Array(archive))
return response(null, { status: 404 })
}
return { fetchImpl, calls }
}
function provisioner(fetchImpl: RuntimeFetch): RuntimeProvisioner {
return new RuntimeProvisioner({
userDataDir: () => workDir,
fetchImpl,
feedUrl: FEED,
platform: process.platform,
stallTimeoutMs: 1000,
partAttempts: 1,
})
}
const markerPath = (): string => join(workDir, 'runtime', 'sidecar', '.runtime-version')
it('설치 후 마커를 쓰고, 다음 ensure 는 내려받지 않는다', async () => {
const feed = feedFor(sidecarTar('1.9.0'), '1.9.0')
const p = provisioner(feed.fetchImpl)
const path = await p.ensure('sidecar')
expect(path).toBe(p.binaryPath('sidecar'))
expect(readFileSync(markerPath(), 'utf8')).toBe('1.9.0')
feed.calls.length = 0
await p.ensure('sidecar')
expect(feed.calls).toEqual([])
})
it('force 면 설치돼 있어도 다시 받아 손상된 트리를 복구한다', async () => {
const feed = feedFor(sidecarTar('1.9.0'), '1.9.0')
const p = provisioner(feed.fetchImpl)
await p.ensure('sidecar')
// 백신이 DLL 을 격리한 상황
rmSync(join(workDir, 'runtime', 'sidecar', '_internal', 'core.dll'))
feed.calls.length = 0
await p.ensure('sidecar', { force: true })
expect(feed.calls.length).toBeGreaterThan(0)
expect(existsSync(join(workDir, 'runtime', 'sidecar', '_internal', 'core.dll'))).toBe(true)
})
it('추출 중 항목 오류가 나면 실패하고 버전 마커를 남기지 않는다', async () => {
const feed = feedFor(brokenSidecarTar(), '1.9.0')
const p = provisioner(feed.fetchImpl)
await expect(p.ensure('sidecar')).rejects.toThrow()
expect(existsSync(markerPath())).toBe(false)
expect(p.isInstalled('sidecar')).toBe(false)
})
it('최소 버전 미만 인덱스는 부품을 받기 전에 거부한다', async () => {
const feed = feedFor(sidecarTar('1.6.0'), '1.6.0')
const p = provisioner(feed.fetchImpl)
await expect(p.ensure('sidecar')).rejects.toThrow(/최소 요구 버전/)
expect(feed.calls).toEqual([`${FEED}/runtime.json`])
})
it('다운로드가 끝나면 임시 폴더를 남기지 않는다', async () => {
const feed = feedFor(sidecarTar('1.9.0'), '1.9.0')
await provisioner(feed.fetchImpl).ensure('sidecar')
expect(existsSync(join(workDir, 'runtime', '.download-sidecar'))).toBe(false)
expect(existsSync(join(workDir, 'runtime', 'sidecar.staging'))).toBe(false)
})
})