// 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 } = {}, ): RuntimeFetchResponse { return new Response(body, { status: init.status ?? 200, headers: init.headers }) as RuntimeFetchResponse } const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) /** chunkCount 개의 청크를 intervalMs 간격으로 보내는 스트림 */ function slowStream(data: Buffer, chunkCount: number, intervalMs: number): ReadableStream { const chunkSize = Math.ceil(data.length / chunkCount) let offset = 0 return new ReadableStream({ 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 { let sent = false return new ReadableStream({ pull(controller) { if (!sent) { sent = true controller.enqueue(new Uint8Array(data.subarray(0, bytes))) return } return new Promise(() => 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 { 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 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 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 | 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) }) })