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.
358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
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/
|
||
);
|
||
}
|
||
});
|
||
});
|