import { createHash } from 'node:crypto'; import { constants, createReadStream, createWriteStream } from 'node:fs'; import { copyFile, lstat, mkdir } from 'node:fs/promises'; import path from 'node:path'; import { Readable, Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; const MAX_DOWNLOAD_SIZE = 1024 * 1024 * 1024; const MAX_REDIRECTS = 5; function usesLatestAlias(value) { let decoded = value; try { decoded = decodeURIComponent(value); } catch { // The URL parser below reports malformed encodings where relevant. } return /(?:^|[^a-z0-9])latest(?:[^a-z0-9]|$)/i.test(decoded); } function parseArtifactSource(source, lockDirectory) { if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(source)) { const url = new URL(source); if (url.protocol === 'file:') return { type: 'local', path: fileURLToPath(url) }; if (url.protocol === 'https:') { return { type: 'https', url: validateHttpsArtifactUrl(url) }; } throw new Error(`Artifact URL protocol is not allowed: ${url.protocol}`); } return { type: 'local', path: path.resolve(lockDirectory, source) }; } function validateHttpsArtifactUrl(value, label = 'Artifact HTTPS URL') { const url = value instanceof URL ? value : new URL(value); if (url.protocol !== 'https:') throw new Error(`${label} must use HTTPS: ${url.href}`); if (url.username || url.password) throw new Error(`${label} must not contain credentials.`); if (usesLatestAlias(url.href)) throw new Error(`${label} must not use a latest alias.`); return url; } async function fetchArtifact(url) { let current = validateHttpsArtifactUrl(url); for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) { const response = await fetch(current, { redirect: 'manual', headers: { Accept: 'application/zip, application/octet-stream' }, }); if ([301, 302, 303, 307, 308].includes(response.status)) { if (redirects === MAX_REDIRECTS) throw new Error( `Artifact download exceeded ${MAX_REDIRECTS} redirects.` ); const location = response.headers.get('location'); if (!location) throw new Error('Artifact redirect did not include a Location header.'); current = validateHttpsArtifactUrl( new URL(location, current), 'Artifact redirect URL' ); continue; } if (response.url) { const finalUrl = validateHttpsArtifactUrl( new URL(response.url), 'Artifact final response URL' ); if (finalUrl.href !== current.href) { throw new Error( `Artifact fetch unexpectedly changed URL from ${current.href} to ${finalUrl.href}.` ); } } return { response, finalUrl: current }; } throw new Error(`Artifact download exceeded ${MAX_REDIRECTS} redirects.`); } export async function sha256File(file) { const hash = createHash('sha256'); for await (const chunk of createReadStream(file)) hash.update(chunk); return hash.digest('hex'); } export async function acquireArtifact( source, expectedSha256, lockDirectory, downloadDirectory ) { const parsed = parseArtifactSource(source, lockDirectory); await mkdir(downloadDirectory, { recursive: true }); const file = path.join(downloadDirectory, `${expectedSha256}.zip`); let downloaded = false; if (parsed.type === 'local') { const details = await lstat(parsed.path); if (!details.isFile() || details.isSymbolicLink()) throw new Error(`Local artifact is not a regular file: ${parsed.path}`); // Work from a private snapshot. The checksum below therefore covers the // exact bytes later inspected and extracted, even if the source is replaced. await copyFile(parsed.path, file, constants.COPYFILE_EXCL); } else { const { response, finalUrl } = await fetchArtifact(parsed.url); if (!response.ok || !response.body) throw new Error( `Artifact download failed with HTTP ${response.status}: ${finalUrl.href}` ); const declaredLength = Number(response.headers.get('content-length')); if (Number.isFinite(declaredLength) && declaredLength > MAX_DOWNLOAD_SIZE) throw new Error('Artifact download exceeds the size limit.'); let bytes = 0; const limiter = new Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; callback( bytes > MAX_DOWNLOAD_SIZE ? new Error('Artifact download exceeds the size limit.') : null, chunk ); }, }); await pipeline( Readable.fromWeb(response.body), limiter, createWriteStream(file, { flags: 'wx', mode: 0o600 }) ); downloaded = true; } const actualSha256 = await sha256File(file); if (actualSha256 !== expectedSha256) { throw new Error( `SHA-256 mismatch for ${source}: expected ${expectedSha256}, received ${actualSha256}.` ); } return { file, downloaded, sha256: actualSha256 }; }