import { createHash } from 'node:crypto'; import type { GithubPluginAsset, GithubPluginRelease } from '$lib/plugin-github.js'; const MAX_DLL_BYTES = 64 * 1024 * 1024; const CACHE_MS = 5 * 60 * 1000; const cache = new Map }>(); export class GithubReleaseError extends Error { constructor( message: string, public readonly status = 400 ) { super(message); } } export function parseGithubReleaseUrl(input: string): { repository: string; tag: string | null } { let url: URL; try { url = new URL(input); } catch { throw new GithubReleaseError('Enter a GitHub repository or release URL.'); } if ( url.protocol !== 'https:' || url.hostname !== 'github.com' || url.port || url.username || url.password || url.search ) { throw new GithubReleaseError('Use an https://github.com repository or release URL.'); } const match = /^\/([\w-]+)\/([\w.-]+)(?:\/(.*?))?\/?$/.exec(url.pathname); if (!match || match[2] === '.' || match[2] === '..') throw new GithubReleaseError('Invalid GitHub repository URL.'); const repository = `${match[1]}/${match[2]}`; const path = (match[3] ?? '').replace(/\/$/, ''); let tag: string | null = null; try { if (path.startsWith('releases/tag/')) tag = decodeURIComponent(path.slice(13)); else if (path === 'releases' && url.hash.startsWith('#release-')) tag = decodeURIComponent(url.hash.slice(9)); else if (!['', 'releases', 'releases/latest'].includes(path) || url.hash) throw new Error(); } catch { throw new GithubReleaseError( 'Use a repository URL or a link to a specific GitHub release.' ); } // eslint-disable-next-line no-control-regex -- Reject control characters in release metadata and asset names. if (tag !== null && (!tag || tag.length > 80 || /[\u0000-\u001f\u007f]/.test(tag))) { throw new GithubReleaseError('Release tags must contain 1–80 characters on one line.'); } return { repository, tag }; } function safeAssetName(name: unknown): name is string { return ( typeof name === 'string' && // eslint-disable-next-line no-control-regex -- Reject control characters in release metadata and asset names. /^[^<>:"/\\|?*\u0000-\u001f]+\.dll$/i.test(name) && name.length <= 200 ); } async function limitedBody(response: Response, limit: number): Promise { if (Number(response.headers.get('content-length')) > limit) { await response.body?.cancel(); throw new GithubReleaseError('GitHub response exceeds the download size limit.', 502); } if (!response.body) throw new GithubReleaseError('GitHub returned an empty response.', 502); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; try { for (;;) { const next = await reader.read(); if (next.done) break; size += next.value.byteLength; if (size > limit) throw new GithubReleaseError( 'GitHub response exceeds the download size limit.', 502 ); chunks.push(next.value); } } finally { await reader.cancel(); } return Buffer.concat(chunks); } async function fetchRelease(repository: string, tag: string | null): Promise { const suffix = tag === null ? 'latest' : `tags/${encodeURIComponent(tag)}`; try { const response = await fetch( `https://api.github.com/repos/${repository}/releases/${suffix}`, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Electric-Cherry', 'X-GitHub-Api-Version': '2022-11-28' }, signal: AbortSignal.timeout(15_000), redirect: 'error' } ); if (!response.ok) { await response.body?.cancel(); if (response.status === 404) throw new GithubReleaseError( 'No published release found. Check the repository and tag; only public repositories are supported.', 404 ); if ([403, 429].includes(response.status)) throw new GithubReleaseError( 'GitHub is limiting requests. Try checking again later.', 429 ); throw new GithubReleaseError( `GitHub could not load this release (HTTP ${response.status}).`, 502 ); } const data = JSON.parse((await limitedBody(response, 2 * 1024 * 1024)).toString('utf8')); if ( !Number.isSafeInteger(data.id) || data.id < 1 || data.draft || typeof data.tag_name !== 'string' || !data.tag_name.trim() || data.tag_name.length > 80 || // eslint-disable-next-line no-control-regex -- Reject control characters in release metadata and asset names. /[\u0000-\u001f\u007f]/.test(data.tag_name) || !Number.isFinite(Date.parse(data.published_at)) || !Array.isArray(data.assets) ) { throw new GithubReleaseError('GitHub returned invalid release metadata.', 502); } return { repository, id: data.id, tag: data.tag_name, url: `https://github.com/${repository}/releases/tag/${encodeURIComponent(data.tag_name)}`, name: typeof data.name === 'string' ? data.name : data.tag_name, notes: typeof data.body === 'string' ? data.body : '', publishedAt: data.published_at, prerelease: data.prerelease === true, assets: data.assets .filter( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Validate dynamically decoded GitHub asset fields below. (a: any) => a && Number.isSafeInteger(a.id) && a.id > 0 && safeAssetName(a.name) && a.state === 'uploaded' && Number.isSafeInteger(a.size) && a.size > 0 && a.size <= MAX_DLL_BYTES ) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The preceding filter validates the decoded asset fields. .map((a: any) => ({ id: a.id, name: a.name, size: a.size, digest: typeof a.digest === 'string' ? a.digest : null })) }; } catch (error) { if (error instanceof GithubReleaseError) throw error; throw new GithubReleaseError( 'Could not reach GitHub or read the release. Try again shortly.', 502 ); } } export async function getGithubRelease(url: string, fresh = false): Promise { const { repository, tag } = parseGithubReleaseUrl(url); const key = `${repository.toLowerCase()}:${tag ?? ''}`; const hit = cache.get(key); if (!fresh && hit && hit.expires > Date.now()) return hit.release; if (cache.size >= 100) cache.delete(cache.keys().next().value!); const release = fetchRelease(repository, tag); cache.set(key, { expires: Date.now() + CACHE_MS, release }); try { return await release; } catch (error) { cache.delete(key); throw error; } } export async function downloadGithubAsset( release: GithubPluginRelease, asset: GithubPluginAsset ): Promise { // Construct the URL from validated metadata. Never fetch a client-supplied download URL. let url = new URL( `https://github.com/${release.repository}/releases/download/${encodeURIComponent(release.tag)}/${encodeURIComponent(asset.name)}` ); const signal = AbortSignal.timeout(60_000); try { for (let i = 0; i < 5; i++) { if ( url.protocol !== 'https:' || url.port || url.username || url.password || ![ 'github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com' ].includes(url.hostname) ) { throw new GithubReleaseError( 'GitHub redirected this download to an unsupported host.', 502 ); } const response = await fetch(url, { redirect: 'manual', signal, headers: { 'User-Agent': 'Electric-Cherry' } }); if ([301, 302, 303, 307, 308].includes(response.status)) { await response.body?.cancel(); const location = response.headers.get('location'); if (!location) break; url = new URL(location, url); continue; } if (!response.ok) { await response.body?.cancel(); throw new GithubReleaseError( `GitHub asset download failed (HTTP ${response.status}).`, 502 ); } const content = await limitedBody(response, MAX_DLL_BYTES); if (content.length !== asset.size) throw new GithubReleaseError( 'Downloaded size differs from the release asset. Preview the release again.', 502 ); const sha = createHash('sha256').update(content).digest('hex'); if ( asset.digest?.startsWith('sha256:') && asset.digest.toLowerCase() !== `sha256:${sha}` ) { throw new GithubReleaseError( 'Downloaded checksum does not match GitHub’s asset digest.', 502 ); } return content; } throw new GithubReleaseError('GitHub asset download redirected too many times.', 502); } catch (error) { if (error instanceof GithubReleaseError) throw error; throw new GithubReleaseError( 'Could not download the DLL from GitHub. Try again shortly.', 502 ); } }