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
+141 -4
View File
@@ -1,10 +1,13 @@
import { describe, expect, test } from "bun:test";
import {
HYSTERIA_HASHES_ASSET,
HYSTERIA_LINUX_AMD64_ASSET,
compareVersionParts,
parseAppTag,
parseUpstreamHashes,
parseVersion,
renderLockEnv,
selectHashesAsset,
selectLatestStableRelease,
selectLinuxAmd64Asset,
selectReleaseByVersion,
@@ -12,8 +15,15 @@ import {
} from "../src/build/hysteriaRelease";
/** Upstream отдаёт URL с неэкранированным слэшем в теге; берём его как есть. */
function assetUrl(tag: string): string {
return `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${HYSTERIA_LINUX_AMD64_ASSET}`;
function assetUrl(tag: string, name = HYSTERIA_LINUX_AMD64_ASSET): string {
return `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${name}`;
}
function defaultAssets(tag: string) {
return [
{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl(tag) },
{ name: HYSTERIA_HASHES_ASSET, browser_download_url: assetUrl(tag, HYSTERIA_HASHES_ASSET) }
];
}
function release(tag: string, overrides: Partial<GithubRelease> = {}): GithubRelease {
@@ -22,7 +32,7 @@ function release(tag: string, overrides: Partial<GithubRelease> = {}): GithubRel
draft: false,
prerelease: false,
published_at: "2026-08-23T00:59:00Z",
assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl(tag) }],
assets: defaultAssets(tag),
...overrides
};
}
@@ -98,11 +108,138 @@ describe("selectLatestStableRelease", () => {
const encoded = "https://github.com/HyNetworks/hysteria/releases/download/app%2Fv2.12.2/hysteria-linux-amd64";
const resolved = selectLatestStableRelease([
release("app/v2.12.2", {
assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: encoded }]
assets: [
{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: encoded },
{
name: HYSTERIA_HASHES_ASSET,
browser_download_url: assetUrl("app/v2.12.2", HYSTERIA_HASHES_ASSET)
}
]
})
]);
expect(resolved.artifactUrl).toBe(encoded);
});
test("резолв отдаёт и url ассета с контрольными суммами", () => {
const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE);
expect(resolved.hashesUrl).toBe(
"https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hashes.txt"
);
});
// Без hashes.txt проверять происхождение артефакта нечем, поэтому такой
// релиз для production-сборки непригоден.
test("релиз без hashes.txt отклоняется", () => {
const noHashes = release("app/v2.12.2", {
assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl("app/v2.12.2") }]
});
expect(() => selectLatestStableRelease([noHashes])).toThrow(/has no hashes\.txt asset/);
});
});
describe("selectHashesAsset", () => {
test("находит ассет контрольных сумм", () => {
expect(selectHashesAsset(release("app/v2.12.2")).name).toBe(HYSTERIA_HASHES_ASSET);
});
test("дублирующийся hashes.txt — ошибка", () => {
const ambiguous = release("app/v2.12.2", {
assets: [
...defaultAssets("app/v2.12.2"),
{ name: HYSTERIA_HASHES_ASSET, browser_download_url: "https://example.com/evil" }
]
});
expect(() => selectHashesAsset(ambiguous)).toThrow(/ambiguous/);
});
});
describe("parseUpstreamHashes", () => {
// Реальный формат релиза app/v2.12.2.
const UPSTREAM_HASHES = [
"6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/hysteria-linux-amd64",
"f24f63bece90b91b4e47d392f0afa4d8fe6d38785cca0589c1d121fd53b8e189 build/hysteria-linux-amd64-avx",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa build/hysteria-linux-arm64"
].join("\n");
test("достаёт сумму нужного артефакта", () => {
expect(parseUpstreamHashes(UPSTREAM_HASHES, HYSTERIA_LINUX_AMD64_ASSET)).toBe(
"6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94"
);
});
// Ключевой момент: -avx не должен совпасть по префиксу с базовым артефактом.
test("не путает hysteria-linux-amd64 с hysteria-linux-amd64-avx", () => {
expect(parseUpstreamHashes(UPSTREAM_HASHES, HYSTERIA_LINUX_AMD64_ASSET)).not.toBe(
"f24f63bece90b91b4e47d392f0afa4d8fe6d38785cca0589c1d121fd53b8e189"
);
expect(parseUpstreamHashes(UPSTREAM_HASHES, "hysteria-linux-amd64-avx")).toBe(
"f24f63bece90b91b4e47d392f0afa4d8fe6d38785cca0589c1d121fd53b8e189"
);
});
test("работает без префикса каталога", () => {
const flat = `6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 ${HYSTERIA_LINUX_AMD64_ASSET}`;
expect(parseUpstreamHashes(flat, HYSTERIA_LINUX_AMD64_ASSET)).toBe(
"6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94"
);
});
test("понимает форму sha256:<hex> и бинарный маркер '*'", () => {
const alternative = [
`sha256:6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 *${HYSTERIA_LINUX_AMD64_ASSET}`
].join("\n");
expect(parseUpstreamHashes(alternative, HYSTERIA_LINUX_AMD64_ASSET)).toBe(
"6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94"
);
});
test("верхний регистр нормализуется", () => {
const upper = `6493DFFFD55B5883F64C76C63880ECC32988F0C568C9CA9014907877B4D55F94 ${HYSTERIA_LINUX_AMD64_ASSET}`;
expect(parseUpstreamHashes(upper, HYSTERIA_LINUX_AMD64_ASSET)).toBe(
"6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94"
);
});
test("комментарии и пустые строки игнорируются", () => {
const noisy = [
"# hysteria release checksums",
"",
`6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/${HYSTERIA_LINUX_AMD64_ASSET}`
].join("\n");
expect(parseUpstreamHashes(noisy, HYSTERIA_LINUX_AMD64_ASSET)).toBe(
"6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94"
);
});
test("отсутствие нужной строки — ошибка, а не тихий пропуск проверки", () => {
expect(() => parseUpstreamHashes(UPSTREAM_HASHES, "hysteria-linux-riscv64")).toThrow(
/no SHA-256 entry for hysteria-linux-riscv64/
);
expect(() => parseUpstreamHashes("", HYSTERIA_LINUX_AMD64_ASSET)).toThrow(/no SHA-256 entry/);
});
test("противоречивые записи — ошибка, а не выбор первой", () => {
const conflicting = [
`6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/${HYSTERIA_LINUX_AMD64_ASSET}`,
`bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb dist/${HYSTERIA_LINUX_AMD64_ASSET}`
].join("\n");
expect(() => parseUpstreamHashes(conflicting, HYSTERIA_LINUX_AMD64_ASSET)).toThrow(/conflicting/);
});
test("повторяющаяся одинаковая запись ошибкой не считается", () => {
const duplicated = [
`6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/${HYSTERIA_LINUX_AMD64_ASSET}`,
`6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 ${HYSTERIA_LINUX_AMD64_ASSET}`
].join("\n");
expect(parseUpstreamHashes(duplicated, HYSTERIA_LINUX_AMD64_ASSET)).toBe(
"6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94"
);
});
test("строка с мусором вместо digest игнорируется", () => {
const garbage = `not-a-sha build/${HYSTERIA_LINUX_AMD64_ASSET}`;
expect(() => parseUpstreamHashes(garbage, HYSTERIA_LINUX_AMD64_ASSET)).toThrow(/no SHA-256 entry/);
});
});
describe("selectLinuxAmd64Asset", () => {
+10 -1
View File
@@ -2,11 +2,16 @@ import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
HYSTERIA_HASHES_ASSET,
HYSTERIA_LINUX_AMD64_ASSET,
selectLatestStableRelease,
type GithubRelease
} from "../src/build/hysteriaRelease";
function assetUrl(tag: string, name: string): string {
return `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${name}`;
}
function release(tag: string): GithubRelease {
return {
tag_name: tag,
@@ -16,7 +21,11 @@ function release(tag: string): GithubRelease {
assets: [
{
name: HYSTERIA_LINUX_AMD64_ASSET,
browser_download_url: `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${HYSTERIA_LINUX_AMD64_ASSET}`
browser_download_url: assetUrl(tag, HYSTERIA_LINUX_AMD64_ASSET)
},
{
name: HYSTERIA_HASHES_ASSET,
browser_download_url: assetUrl(tag, HYSTERIA_HASHES_ASSET)
}
]
};