10d2c48cf0
SHA-256 считался локально от уже скачанного файла. Это защищает target от последующей подмены, но не доказывает, что builder скачал именно ожидаемый upstream artifact: сумма фиксирует то, что пришло, каким бы оно ни было. То есть trust-on-first-use, а не проверка происхождения. Upstream публикует контрольные суммы релиза ассетом hashes.txt: 6493dfff...f94 build/hysteria-linux-amd64 f24f63be...189 build/hysteria-linux-amd64-avx Теперь резолвер отдаёт и URL этого ассета, сборка скачивает его, берёт оттуда ожидаемую сумму и сверяет с ней бинарник - и только после этого записывает SHA-256 в HY2XS lock и metadata. Сопоставление идёт по базовому имени и строго на равенство: build/ - часть пути, а hysteria-linux-amd64-avx - другой артефакт, который не должен совпасть по префиксу. Разбор вынесен в parseUpstreamHashes и покрыт тестами, включая форму sha256:<hex>, верхний регистр, противоречивые и отсутствующие записи. Релиз без hashes.txt для production-сборки непригоден и отклоняется. Источник ожидаемой суммы фиксируется в metadata как hysteria_sha_source.
255 lines
8.5 KiB
TypeScript
255 lines
8.5 KiB
TypeScript
/**
|
||
* 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:<hex>`: если 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");
|
||
}
|