Files
HY2XS_flamy/orchestrator/test/hysteria-release.test.ts
T
founder 10d2c48cf0 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.
2026-08-27 12:15:38 +05:00

358 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import {
HYSTERIA_HASHES_ASSET,
HYSTERIA_LINUX_AMD64_ASSET,
compareVersionParts,
parseAppTag,
parseUpstreamHashes,
parseVersion,
renderLockEnv,
selectHashesAsset,
selectLatestStableRelease,
selectLinuxAmd64Asset,
selectReleaseByVersion,
type GithubRelease
} from "../src/build/hysteriaRelease";
/** Upstream отдаёт URL с неэкранированным слэшем в теге; берём его как есть. */
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 {
return {
tag_name: tag,
draft: false,
prerelease: false,
published_at: "2026-08-23T00:59:00Z",
assets: defaultAssets(tag),
...overrides
};
}
/** Реалистичная выборка: stable, prerelease, draft, чужие семейства тегов. */
const UPSTREAM_FIXTURE: GithubRelease[] = [
release("app/v2.13.0-rc1", { prerelease: true }),
release("app/v2.13.0", { prerelease: true }),
release("app/v2.14.0", { draft: true }),
release("app/v2.12.2"),
release("app/v2.12.1"),
release("app/v2.9.10"),
release("app/v2.9.2"),
release("core/v2.12.2"),
release("docs/v1.0.0"),
release("v2.12.2")
];
describe("selectLatestStableRelease", () => {
test("выбирает последнюю стабильную app-версию", () => {
const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE);
expect(resolved.version).toBe("v2.12.2");
expect(resolved.tag).toBe("app/v2.12.2");
});
test("игнорирует prerelease", () => {
const resolved = selectLatestStableRelease([release("app/v2.12.2"), release("app/v2.13.0", { prerelease: true })]);
expect(resolved.version).toBe("v2.12.2");
});
test("игнорирует draft", () => {
const resolved = selectLatestStableRelease([release("app/v2.12.2"), release("app/v2.13.0", { draft: true })]);
expect(resolved.version).toBe("v2.12.2");
});
test("игнорирует чужие семейства тегов", () => {
const resolved = selectLatestStableRelease([release("core/v9.9.9"), release("app/v2.12.2")]);
expect(resolved.version).toBe("v2.12.2");
});
test("игнорирует тег без префикса app/", () => {
expect(() => selectLatestStableRelease([release("v2.12.2")])).toThrow(/no stable/);
});
test("сравнение числовое, а не лексикографическое", () => {
const resolved = selectLatestStableRelease([release("app/v2.9.2"), release("app/v2.9.10")]);
expect(resolved.version).toBe("v2.9.10");
});
test("порядок элементов в ответе API не влияет на результат", () => {
const reversed = [...UPSTREAM_FIXTURE].reverse();
expect(selectLatestStableRelease(reversed).version).toBe("v2.12.2");
});
test("невалидный semver в теге отбрасывается", () => {
expect(() => selectLatestStableRelease([release("app/v2.12"), release("app/vNEXT")])).toThrow(
/no stable/
);
});
test("пустой список релизов даёт понятную ошибку", () => {
expect(() => selectLatestStableRelease([])).toThrow(/no stable/);
});
test("берёт browser_download_url ровно как отдал API, без пересборки строки", () => {
const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE);
expect(resolved.artifactUrl).toBe(
"https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hysteria-linux-amd64"
);
});
test("процентное кодирование в url upstream не переписывается", () => {
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 },
{
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", () => {
test("отсутствующий linux-amd64 asset — ошибка", () => {
const broken = release("app/v2.12.2", {
assets: [{ name: "hysteria-linux-arm64", browser_download_url: assetUrl("app/v2.12.2") }]
});
expect(() => selectLatestStableRelease([broken])).toThrow(/has no hysteria-linux-amd64 asset/);
});
test("дублирующийся asset — ошибка, а не случайный выбор", () => {
const ambiguous = release("app/v2.12.2", {
assets: [
{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl("app/v2.12.2") },
{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "https://example.com/evil" }
]
});
expect(() => selectLatestStableRelease([ambiguous])).toThrow(/ambiguous/);
});
test("non-https url отклоняется", () => {
const insecure = release("app/v2.12.2", {
assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "http://github.com/x" }]
});
expect(() => selectLinuxAmd64Asset(insecure)).toThrow(/not https/);
});
test("пустой url отклоняется", () => {
const empty = release("app/v2.12.2", {
assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "" }]
});
expect(() => selectLinuxAmd64Asset(empty)).toThrow(/empty asset download url/);
});
test("частичное совпадение имени asset не принимается", () => {
const partial = release("app/v2.12.2", {
assets: [
{ name: "hysteria-linux-amd64-avx", browser_download_url: assetUrl("app/v2.12.2") }
]
});
expect(() => selectLinuxAmd64Asset(partial)).toThrow(/has no hysteria-linux-amd64 asset/);
});
});
describe("selectReleaseByVersion", () => {
test("находит явно запрошенную версию, даже если она не последняя", () => {
const resolved = selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.12.1");
expect(resolved.version).toBe("v2.12.1");
expect(resolved.tag).toBe("app/v2.12.1");
});
test("позволяет осознанно закрепить prerelease по явному запросу", () => {
const resolved = selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.13.0");
expect(resolved.tag).toBe("app/v2.13.0");
// при этом автоматический выбор её по-прежнему не берёт
expect(selectLatestStableRelease(UPSTREAM_FIXTURE).version).toBe("v2.12.2");
});
test("draft по явному запросу отклоняется", () => {
expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.14.0")).toThrow(/draft release/);
});
test("несуществующая версия даёт ошибку", () => {
expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, "v9.9.9")).toThrow(/was not found/);
});
test("невалидный формат override отклоняется", () => {
for (const bad of ["2.12.2", "v2.12", "latest", ""]) {
expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, bad)).toThrow(/invalid Hysteria version override/);
}
});
});
describe("парсинг версий", () => {
test("parseAppTag", () => {
expect(parseAppTag("app/v2.12.2")?.version).toBe("v2.12.2");
expect(parseAppTag(" app/v2.12.2 ")?.version).toBe("v2.12.2");
expect(parseAppTag("app/v2.12.2-rc1")).toBeNull();
expect(parseAppTag("app/2.12.2")).toBeNull();
});
test("parseVersion", () => {
expect(parseVersion("v2.12.2")).toEqual([2, 12, 2]);
expect(parseVersion("v2.12.2-rc1")).toBeNull();
});
test("compareVersionParts", () => {
expect(compareVersionParts([2, 12, 2], [2, 12, 1])).toBe(1);
expect(compareVersionParts([2, 9, 10], [2, 9, 2])).toBe(1);
expect(compareVersionParts([2, 12, 2], [2, 12, 2])).toBe(0);
expect(compareVersionParts([1, 99, 99], [2, 0, 0])).toBe(-1);
});
});
describe("renderLockEnv", () => {
const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE);
const sha = "a".repeat(64);
test("содержит замороженные версию, url и sha256", () => {
const rendered = renderLockEnv(resolved, sha, "latest-stable", "2026-08-27T00:00:00Z");
expect(rendered).toContain("HYSTERIA_VERSION=v2.12.2");
expect(rendered).toContain(`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`);
expect(rendered).toContain(`HYSTERIA_ARTIFACT_SHA256=${sha}`);
expect(rendered).toContain("HYSTERIA_RESOLUTION=latest-stable");
expect(rendered).toContain("HYSTERIA_RESOLVED_AT=2026-08-27T00:00:00Z");
});
test("невалидный sha256 отклоняется", () => {
for (const bad of ["", "abc", "replace-with-real-sha256", "A".repeat(64), "z".repeat(64)]) {
expect(() => renderLockEnv(resolved, bad, "latest-stable", "2026-08-27T00:00:00Z")).toThrow(
/invalid Hysteria artifact sha256/
);
}
});
});