import { createHash } from 'node:crypto'; import { appendFile, cp, mkdir, readdir, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; import AdmZip from 'adm-zip'; import { validateLearningPlayerArtifact } from './learning-player-artifact.mjs'; const artifactUrl = process.env.LEARNING_PLAYER_ARTIFACT_URL?.trim(); const expectedSha256 = process.env.LEARNING_PLAYER_ARTIFACT_SHA256?.trim().toLowerCase(); if (!artifactUrl || !/^https:\/\//i.test(artifactUrl)) { throw new Error('LEARNING_PLAYER_ARTIFACT_URL must be a versioned HTTPS artifact URL'); } if (!expectedSha256 || !/^[0-9a-f]{64}$/.test(expectedSha256)) { throw new Error('LEARNING_PLAYER_ARTIFACT_SHA256 must be the pinned artifact digest'); } const response = await fetch(artifactUrl, { redirect: 'follow' }); if (!response.ok) throw new Error(`Learning player download failed (HTTP ${response.status})`); const archive = Buffer.from(await response.arrayBuffer()); const actualSha256 = createHash('sha256').update(archive).digest('hex'); if (actualSha256 !== expectedSha256) { throw new Error(`Learning player archive digest mismatch: expected ${expectedSha256}, got ${actualSha256}`); } const root = resolve(import.meta.dirname, '..'); const extractionRoot = resolve(root, 'build', '.learning-player-download'); const outputRoot = resolve(root, 'build', 'learning-player-source'); await Promise.all([ rm(extractionRoot, { recursive: true, force: true }), rm(outputRoot, { recursive: true, force: true }), ]); await mkdir(extractionRoot, { recursive: true }); const zip = new AdmZip(archive); for (const entry of zip.getEntries()) { const normalized = entry.entryName.replace(/\\/g, '/'); const parts = normalized.split('/'); if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized) || parts.includes('..')) { throw new Error('Learning player archive contains an unsafe path'); } } zip.extractAllTo(extractionRoot, true); async function findArtifactRoots(directory, depth = 0) { const entries = await readdir(directory, { withFileTypes: true }); if (entries.some((entry) => entry.isFile() && entry.name === 'artifact.json')) return [directory]; if (depth >= 2) return []; const nested = await Promise.all(entries .filter((entry) => entry.isDirectory()) .map((entry) => findArtifactRoots(resolve(directory, entry.name), depth + 1))); return nested.flat(); } const candidates = await findArtifactRoots(extractionRoot); if (candidates.length !== 1) throw new Error('Learning player archive must contain exactly one artifact.json root'); await validateLearningPlayerArtifact(candidates[0]); await cp(candidates[0], outputRoot, { recursive: true }); await validateLearningPlayerArtifact(outputRoot); await rm(extractionRoot, { recursive: true, force: true }); if (process.env.GITHUB_ENV) { await appendFile(process.env.GITHUB_ENV, `MAKELORE_LEARNING_PLAYER_ARTIFACT=${outputRoot}\n`); } process.stdout.write(`Downloaded verified learning player ${actualSha256} to ${outputRoot}\n`);