build: сверка артефакта Hysteria с upstream hashes.txt

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.
This commit is contained in:
2026-08-27 12:15:38 +05:00
parent 9da61c9482
commit 10d2c48cf0
7 changed files with 350 additions and 18 deletions
+81 -6
View File
@@ -8,6 +8,13 @@
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+)$/;
@@ -32,6 +39,8 @@ export type ResolvedHysteriaRelease = {
tag: string;
/** Immutable release asset URL ровно в том виде, как его отдал GitHub API. */
artifactUrl: string;
/** URL ассета с контрольными суммами релиза. */
hashesUrl: string;
publishedAt: string;
};
@@ -68,17 +77,15 @@ function isStable(release: GithubRelease): boolean {
return release.draft !== true && release.prerelease !== true;
}
export function selectLinuxAmd64Asset(release: GithubRelease): GithubAsset {
const matches = (release.assets ?? []).filter((asset) => asset.name === HYSTERIA_LINUX_AMD64_ASSET);
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 ${HYSTERIA_LINUX_AMD64_ASSET} asset`
);
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 ${HYSTERIA_LINUX_AMD64_ASSET} assets`
`upstream release ${release.tag_name} has ${matches.length} ambiguous ${assetName} assets`
);
}
@@ -93,11 +100,79 @@ export function selectLinuxAmd64Asset(release: GithubRelease): GithubAsset {
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()
};
}