/** * Build-time логика выбора upstream-релиза Hysteria. * * Этот модуль не входит в install-only orchestrator: target-сервер никогда * не резолвит "latest" сам, он получает уже замороженные version/url/sha256. */ export const HYSTERIA_UPSTREAM_REPO = "HyNetworks/hysteria"; export const HYSTERIA_LINUX_AMD64_ASSET = "hysteria-linux-amd64"; /** * Upstream публикует контрольные суммы релиза отдельным ассетом. Он позволяет * проверить ПРОИСХОЖДЕНИЕ артефакта, а не только его неизменность: без него * SHA-256 считается от уже скачанного файла, то есть является trust-on-first-use. */ export const HYSTERIA_HASHES_ASSET = "hashes.txt"; /** Upstream публикует несколько семейств тегов; серверный бинарник живёт в app/vX.Y.Z. */ export const HYSTERIA_APP_TAG_PATTERN = /^app\/v(\d+)\.(\d+)\.(\d+)$/; export const HYSTERIA_VERSION_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/; export type GithubAsset = { name: string; browser_download_url: string; }; export type GithubRelease = { tag_name: string; draft?: boolean; prerelease?: boolean; published_at?: string | null; assets?: GithubAsset[]; }; export type ResolvedHysteriaRelease = { /** Нормализованная версия вида v2.12.2. */ version: string; /** Полный upstream-тег вида app/v2.12.2. */ tag: string; /** Immutable release asset URL ровно в том виде, как его отдал GitHub API. */ artifactUrl: string; /** URL ассета с контрольными суммами релиза. */ hashesUrl: string; publishedAt: string; }; type SemverParts = [number, number, number]; export function parseVersion(value: string): SemverParts | null { const match = HYSTERIA_VERSION_PATTERN.exec(value.trim()); if (!match) { return null; } return [Number(match[1]), Number(match[2]), Number(match[3])]; } export function parseAppTag(tag: string): { version: string; parts: SemverParts } | null { const match = HYSTERIA_APP_TAG_PATTERN.exec(tag.trim()); if (!match) { return null; } const parts: SemverParts = [Number(match[1]), Number(match[2]), Number(match[3])]; return { version: `v${parts[0]}.${parts[1]}.${parts[2]}`, parts }; } /** Числовое сравнение, а не лексикографическое: v2.9.10 новее v2.9.2. */ export function compareVersionParts(a: SemverParts, b: SemverParts): number { for (let i = 0; i < 3; i += 1) { if (a[i] !== b[i]) { return a[i] < b[i] ? -1 : 1; } } return 0; } function isStable(release: GithubRelease): boolean { return release.draft !== true && release.prerelease !== true; } export function selectAssetByName(release: GithubRelease, assetName: string): GithubAsset { const matches = (release.assets ?? []).filter((asset) => asset.name === assetName); if (matches.length === 0) { throw new Error(`upstream release ${release.tag_name} has no ${assetName} asset`); } if (matches.length > 1) { throw new Error( `upstream release ${release.tag_name} has ${matches.length} ambiguous ${assetName} assets` ); } const url = matches[0].browser_download_url?.trim(); if (!url) { throw new Error(`upstream release ${release.tag_name} has an empty asset download url`); } if (!url.startsWith("https://")) { throw new Error(`upstream release ${release.tag_name} asset url is not https: ${url}`); } return { name: matches[0].name, browser_download_url: url }; } export function selectLinuxAmd64Asset(release: GithubRelease): GithubAsset { return selectAssetByName(release, HYSTERIA_LINUX_AMD64_ASSET); } export function selectHashesAsset(release: GithubRelease): GithubAsset { return selectAssetByName(release, HYSTERIA_HASHES_ASSET); } /** * Разбирает upstream hashes.txt. * * Фактический формат релиза app/v2.12.2: * * 6493dfff…f94 build/hysteria-linux-amd64 * f24f63be…189 build/hysteria-linux-amd64-avx * * Сопоставление идёт по базовому имени и строго на равенство: префикс * `build/` — часть пути, а `hysteria-linux-amd64-avx` — другой артефакт, * который не должен совпасть по prefix-match. * * Поддерживается и форма `sha256:`: если upstream когда-нибудь сменит * стиль, сборка не должна падать на косметике. */ export function parseUpstreamHashes(text: string, assetName: string): string { const candidates: string[] = []; for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) { continue; } const columns = line.split(/\s+/).filter(Boolean); if (columns.length < 2) { continue; } const [first, ...rest] = columns; const digest = first.toLowerCase().replace(/^sha256:/, ""); if (!/^[a-f0-9]{64}$/.test(digest)) { continue; } const named = rest.some((column) => { const basename = column.replace(/^\*/, "").split("/").pop() ?? ""; return basename === assetName; }); if (named) { candidates.push(digest); } } if (candidates.length === 0) { throw new Error(`upstream hashes.txt has no SHA-256 entry for ${assetName}`); } const unique = [...new Set(candidates)]; if (unique.length > 1) { throw new Error( `upstream hashes.txt has conflicting SHA-256 entries for ${assetName}: ${unique.join(", ")}` ); } return unique[0]; } function toResolved(release: GithubRelease, version: string): ResolvedHysteriaRelease { return { version, tag: release.tag_name.trim(), artifactUrl: selectLinuxAmd64Asset(release).browser_download_url, hashesUrl: selectHashesAsset(release).browser_download_url, publishedAt: (release.published_at ?? "").trim() }; } /** * Выбирает последний стабильный app-релиз: без draft и prerelease, тег строго * app/vX.Y.Z, наибольшая версия по числовому сравнению. */ export function selectLatestStableRelease(releases: GithubRelease[]): ResolvedHysteriaRelease { let best: { release: GithubRelease; version: string; parts: SemverParts } | null = null; for (const release of releases) { if (!isStable(release)) { continue; } const parsed = parseAppTag(release.tag_name ?? ""); if (!parsed) { continue; } if (!best || compareVersionParts(parsed.parts, best.parts) > 0) { best = { release, version: parsed.version, parts: parsed.parts }; } } if (!best) { throw new Error( `no stable ${HYSTERIA_UPSTREAM_REPO} release matching app/vX.Y.Z found in upstream release list` ); } return toResolved(best.release, best.version); } /** Выбирает конкретную версию по HYSTERIA_VERSION_OVERRIDE. */ export function selectReleaseByVersion( releases: GithubRelease[], version: string ): ResolvedHysteriaRelease { const requested = parseVersion(version); if (!requested) { throw new Error(`invalid Hysteria version override: ${version} (expected vX.Y.Z)`); } const normalized = `v${requested[0]}.${requested[1]}.${requested[2]}`; for (const release of releases) { const parsed = parseAppTag(release.tag_name ?? ""); if (parsed?.version === normalized) { if (release.draft === true) { throw new Error(`Hysteria version override ${normalized} points to a draft release`); } return toResolved(release, parsed.version); } } throw new Error(`Hysteria version override ${normalized} was not found in ${HYSTERIA_UPSTREAM_REPO}`); } export function renderLockEnv( resolved: ResolvedHysteriaRelease, sha256: string, resolution: string, resolvedAt: string ): string { if (!/^[a-f0-9]{64}$/.test(sha256)) { throw new Error(`invalid Hysteria artifact sha256: ${sha256}`); } return [ "# Файл генерируется tools/build/lib/hysteria.sh.", "# HYSTERIA_CHANNEL=pinned использует эти значения без обращения к сети.", `HYSTERIA_VERSION=${resolved.version}`, `HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`, `HYSTERIA_ARTIFACT_SHA256=${sha256}`, `HYSTERIA_RESOLUTION=${resolution}`, `HYSTERIA_RESOLVED_AT=${resolvedAt}`, "" ].join("\n"); }