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:
@@ -8,6 +8,13 @@
|
|||||||
export const HYSTERIA_UPSTREAM_REPO = "HyNetworks/hysteria";
|
export const HYSTERIA_UPSTREAM_REPO = "HyNetworks/hysteria";
|
||||||
export const HYSTERIA_LINUX_AMD64_ASSET = "hysteria-linux-amd64";
|
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. */
|
/** Upstream публикует несколько семейств тегов; серверный бинарник живёт в app/vX.Y.Z. */
|
||||||
export const HYSTERIA_APP_TAG_PATTERN = /^app\/v(\d+)\.(\d+)\.(\d+)$/;
|
export const HYSTERIA_APP_TAG_PATTERN = /^app\/v(\d+)\.(\d+)\.(\d+)$/;
|
||||||
export const HYSTERIA_VERSION_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/;
|
export const HYSTERIA_VERSION_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/;
|
||||||
@@ -32,6 +39,8 @@ export type ResolvedHysteriaRelease = {
|
|||||||
tag: string;
|
tag: string;
|
||||||
/** Immutable release asset URL ровно в том виде, как его отдал GitHub API. */
|
/** Immutable release asset URL ровно в том виде, как его отдал GitHub API. */
|
||||||
artifactUrl: string;
|
artifactUrl: string;
|
||||||
|
/** URL ассета с контрольными суммами релиза. */
|
||||||
|
hashesUrl: string;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,17 +77,15 @@ function isStable(release: GithubRelease): boolean {
|
|||||||
return release.draft !== true && release.prerelease !== true;
|
return release.draft !== true && release.prerelease !== true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectLinuxAmd64Asset(release: GithubRelease): GithubAsset {
|
export function selectAssetByName(release: GithubRelease, assetName: string): GithubAsset {
|
||||||
const matches = (release.assets ?? []).filter((asset) => asset.name === HYSTERIA_LINUX_AMD64_ASSET);
|
const matches = (release.assets ?? []).filter((asset) => asset.name === assetName);
|
||||||
|
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
throw new Error(
|
throw new Error(`upstream release ${release.tag_name} has no ${assetName} asset`);
|
||||||
`upstream release ${release.tag_name} has no ${HYSTERIA_LINUX_AMD64_ASSET} asset`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (matches.length > 1) {
|
if (matches.length > 1) {
|
||||||
throw new Error(
|
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 };
|
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 {
|
function toResolved(release: GithubRelease, version: string): ResolvedHysteriaRelease {
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
tag: release.tag_name.trim(),
|
tag: release.tag_name.trim(),
|
||||||
artifactUrl: selectLinuxAmd64Asset(release).browser_download_url,
|
artifactUrl: selectLinuxAmd64Asset(release).browser_download_url,
|
||||||
|
hashesUrl: selectHashesAsset(release).browser_download_url,
|
||||||
publishedAt: (release.published_at ?? "").trim()
|
publishedAt: (release.published_at ?? "").trim()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
|
HYSTERIA_HASHES_ASSET,
|
||||||
HYSTERIA_LINUX_AMD64_ASSET,
|
HYSTERIA_LINUX_AMD64_ASSET,
|
||||||
compareVersionParts,
|
compareVersionParts,
|
||||||
parseAppTag,
|
parseAppTag,
|
||||||
|
parseUpstreamHashes,
|
||||||
parseVersion,
|
parseVersion,
|
||||||
renderLockEnv,
|
renderLockEnv,
|
||||||
|
selectHashesAsset,
|
||||||
selectLatestStableRelease,
|
selectLatestStableRelease,
|
||||||
selectLinuxAmd64Asset,
|
selectLinuxAmd64Asset,
|
||||||
selectReleaseByVersion,
|
selectReleaseByVersion,
|
||||||
@@ -12,8 +15,15 @@ import {
|
|||||||
} from "../src/build/hysteriaRelease";
|
} from "../src/build/hysteriaRelease";
|
||||||
|
|
||||||
/** Upstream отдаёт URL с неэкранированным слэшем в теге; берём его как есть. */
|
/** Upstream отдаёт URL с неэкранированным слэшем в теге; берём его как есть. */
|
||||||
function assetUrl(tag: string): string {
|
function assetUrl(tag: string, name = HYSTERIA_LINUX_AMD64_ASSET): string {
|
||||||
return `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${HYSTERIA_LINUX_AMD64_ASSET}`;
|
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 {
|
function release(tag: string, overrides: Partial<GithubRelease> = {}): GithubRelease {
|
||||||
@@ -22,7 +32,7 @@ function release(tag: string, overrides: Partial<GithubRelease> = {}): GithubRel
|
|||||||
draft: false,
|
draft: false,
|
||||||
prerelease: false,
|
prerelease: false,
|
||||||
published_at: "2026-08-23T00:59:00Z",
|
published_at: "2026-08-23T00:59:00Z",
|
||||||
assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl(tag) }],
|
assets: defaultAssets(tag),
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -98,11 +108,138 @@ describe("selectLatestStableRelease", () => {
|
|||||||
const encoded = "https://github.com/HyNetworks/hysteria/releases/download/app%2Fv2.12.2/hysteria-linux-amd64";
|
const encoded = "https://github.com/HyNetworks/hysteria/releases/download/app%2Fv2.12.2/hysteria-linux-amd64";
|
||||||
const resolved = selectLatestStableRelease([
|
const resolved = selectLatestStableRelease([
|
||||||
release("app/v2.12.2", {
|
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);
|
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", () => {
|
describe("selectLinuxAmd64Asset", () => {
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
|
HYSTERIA_HASHES_ASSET,
|
||||||
HYSTERIA_LINUX_AMD64_ASSET,
|
HYSTERIA_LINUX_AMD64_ASSET,
|
||||||
selectLatestStableRelease,
|
selectLatestStableRelease,
|
||||||
type GithubRelease
|
type GithubRelease
|
||||||
} from "../src/build/hysteriaRelease";
|
} 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 {
|
function release(tag: string): GithubRelease {
|
||||||
return {
|
return {
|
||||||
tag_name: tag,
|
tag_name: tag,
|
||||||
@@ -16,7 +21,11 @@ function release(tag: string): GithubRelease {
|
|||||||
assets: [
|
assets: [
|
||||||
{
|
{
|
||||||
name: HYSTERIA_LINUX_AMD64_ASSET,
|
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)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Build-time CLI: достаёт ожидаемый SHA-256 артефакта из upstream hashes.txt.
|
||||||
|
*
|
||||||
|
* Вынесено в отдельную команду, чтобы разбор формата жил в одном месте и был
|
||||||
|
* покрыт юнит-тестами, а не повторялся awk-выражением в bash.
|
||||||
|
*
|
||||||
|
* bun run tools/parse-upstream-hashes.ts --file hashes.txt --asset hysteria-linux-amd64
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseUpstreamHashes } from "../src/build/hysteriaRelease";
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
console.error(`[hy2xs-build] ERROR: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): { file: string; asset: string } {
|
||||||
|
let file = "";
|
||||||
|
let asset = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
const value = argv[i + 1];
|
||||||
|
if (arg === "--file") {
|
||||||
|
file = value ?? "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--asset") {
|
||||||
|
asset = value ?? "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fail(`unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
fail("missing --file");
|
||||||
|
}
|
||||||
|
if (!asset) {
|
||||||
|
fail("missing --asset");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { file, asset };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { file, asset } = parseArgs(Bun.argv.slice(2));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await Bun.file(file).text();
|
||||||
|
process.stdout.write(`${parseUpstreamHashes(text, asset)}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
fail(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
@@ -102,6 +102,7 @@ function emit(resolved: ResolvedHysteriaRelease, resolution: string): void {
|
|||||||
console.log(`HYSTERIA_VERSION=${resolved.version}`);
|
console.log(`HYSTERIA_VERSION=${resolved.version}`);
|
||||||
console.log(`HYSTERIA_TAG=${resolved.tag}`);
|
console.log(`HYSTERIA_TAG=${resolved.tag}`);
|
||||||
console.log(`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`);
|
console.log(`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`);
|
||||||
|
console.log(`HYSTERIA_HASHES_URL=${resolved.hashesUrl}`);
|
||||||
console.log(`HYSTERIA_PUBLISHED_AT=${resolved.publishedAt}`);
|
console.log(`HYSTERIA_PUBLISHED_AT=${resolved.publishedAt}`);
|
||||||
console.log(`HYSTERIA_RESOLUTION=${resolution}`);
|
console.log(`HYSTERIA_RESOLUTION=${resolution}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
HYSTERIA_VERSION=v2.12.2
|
HYSTERIA_VERSION=v2.12.2
|
||||||
HYSTERIA_ARTIFACT_URL=https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hysteria-linux-amd64
|
HYSTERIA_ARTIFACT_URL=https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hysteria-linux-amd64
|
||||||
HYSTERIA_ARTIFACT_SHA256=6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94
|
HYSTERIA_ARTIFACT_SHA256=6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94
|
||||||
|
HYSTERIA_SHA_SOURCE=upstream-hashes
|
||||||
HYSTERIA_RESOLUTION=latest-stable
|
HYSTERIA_RESOLUTION=latest-stable
|
||||||
HYSTERIA_RESOLVED_AT=2026-08-27T00:00:00Z
|
HYSTERIA_RESOLVED_AT=2026-08-27T00:00:00Z
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ HYSTERIA_VERSION_OVERRIDE="${HYSTERIA_VERSION_OVERRIDE:-}"
|
|||||||
HYSTERIA_WRITE_LOCK="${HYSTERIA_WRITE_LOCK:-false}"
|
HYSTERIA_WRITE_LOCK="${HYSTERIA_WRITE_LOCK:-false}"
|
||||||
# Compatibility gate обязателен для production-сборки.
|
# Compatibility gate обязателен для production-сборки.
|
||||||
HYSTERIA_COMPAT_GATE="${HYSTERIA_COMPAT_GATE:-true}"
|
HYSTERIA_COMPAT_GATE="${HYSTERIA_COMPAT_GATE:-true}"
|
||||||
|
# Сверка скачанного бинарника с upstream hashes.txt. Отключается только
|
||||||
|
# break-glass: без неё SHA-256 считается от уже скачанного файла и является
|
||||||
|
# trust-on-first-use, а не проверкой происхождения артефакта.
|
||||||
|
HYSTERIA_VERIFY_UPSTREAM_HASHES="${HYSTERIA_VERIFY_UPSTREAM_HASHES:-true}"
|
||||||
|
HYSTERIA_LINUX_AMD64_ASSET="hysteria-linux-amd64"
|
||||||
|
|
||||||
HYSTERIA_GATE_PORT="${HYSTERIA_GATE_PORT:-34443}"
|
HYSTERIA_GATE_PORT="${HYSTERIA_GATE_PORT:-34443}"
|
||||||
HYSTERIA_GATE_STATS_PORT="${HYSTERIA_GATE_STATS_PORT:-34712}"
|
HYSTERIA_GATE_STATS_PORT="${HYSTERIA_GATE_STATS_PORT:-34712}"
|
||||||
@@ -55,8 +60,12 @@ load_pinned_hysteria_lock() {
|
|||||||
|
|
||||||
HYSTERIA_RESOLUTION="pinned"
|
HYSTERIA_RESOLUTION="pinned"
|
||||||
HYSTERIA_RESOLVED_AT="${HYSTERIA_RESOLVED_AT:-unknown}"
|
HYSTERIA_RESOLVED_AT="${HYSTERIA_RESOLVED_AT:-unknown}"
|
||||||
|
# Сумма в lock-файле уже была проверена против upstream hashes.txt в тот
|
||||||
|
# момент, когда lock записывался.
|
||||||
|
HYSTERIA_SHA_SOURCE="hy2xs-lock"
|
||||||
|
|
||||||
export HYSTERIA_VERSION HYSTERIA_ARTIFACT_URL HYSTERIA_ARTIFACT_SHA256 HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT
|
export HYSTERIA_VERSION HYSTERIA_ARTIFACT_URL HYSTERIA_ARTIFACT_SHA256
|
||||||
|
export HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT HYSTERIA_SHA_SOURCE
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve_hysteria_from_upstream() {
|
resolve_hysteria_from_upstream() {
|
||||||
@@ -77,6 +86,7 @@ resolve_hysteria_from_upstream() {
|
|||||||
HYSTERIA_VERSION=""
|
HYSTERIA_VERSION=""
|
||||||
HYSTERIA_TAG=""
|
HYSTERIA_TAG=""
|
||||||
HYSTERIA_ARTIFACT_URL=""
|
HYSTERIA_ARTIFACT_URL=""
|
||||||
|
HYSTERIA_HASHES_URL=""
|
||||||
HYSTERIA_PUBLISHED_AT=""
|
HYSTERIA_PUBLISHED_AT=""
|
||||||
HYSTERIA_RESOLUTION=""
|
HYSTERIA_RESOLUTION=""
|
||||||
|
|
||||||
@@ -88,6 +98,7 @@ resolve_hysteria_from_upstream() {
|
|||||||
HYSTERIA_VERSION) HYSTERIA_VERSION="$value" ;;
|
HYSTERIA_VERSION) HYSTERIA_VERSION="$value" ;;
|
||||||
HYSTERIA_TAG) HYSTERIA_TAG="$value" ;;
|
HYSTERIA_TAG) HYSTERIA_TAG="$value" ;;
|
||||||
HYSTERIA_ARTIFACT_URL) HYSTERIA_ARTIFACT_URL="$value" ;;
|
HYSTERIA_ARTIFACT_URL) HYSTERIA_ARTIFACT_URL="$value" ;;
|
||||||
|
HYSTERIA_HASHES_URL) HYSTERIA_HASHES_URL="$value" ;;
|
||||||
HYSTERIA_PUBLISHED_AT) HYSTERIA_PUBLISHED_AT="$value" ;;
|
HYSTERIA_PUBLISHED_AT) HYSTERIA_PUBLISHED_AT="$value" ;;
|
||||||
HYSTERIA_RESOLUTION) HYSTERIA_RESOLUTION="$value" ;;
|
HYSTERIA_RESOLUTION) HYSTERIA_RESOLUTION="$value" ;;
|
||||||
*) fail "unexpected resolver output line: $line" ;;
|
*) fail "unexpected resolver output line: $line" ;;
|
||||||
@@ -96,14 +107,50 @@ resolve_hysteria_from_upstream() {
|
|||||||
|
|
||||||
[ -n "$HYSTERIA_VERSION" ] || fail "resolver did not return HYSTERIA_VERSION"
|
[ -n "$HYSTERIA_VERSION" ] || fail "resolver did not return HYSTERIA_VERSION"
|
||||||
[ -n "$HYSTERIA_ARTIFACT_URL" ] || fail "resolver did not return HYSTERIA_ARTIFACT_URL"
|
[ -n "$HYSTERIA_ARTIFACT_URL" ] || fail "resolver did not return HYSTERIA_ARTIFACT_URL"
|
||||||
|
[ -n "$HYSTERIA_HASHES_URL" ] || fail "resolver did not return HYSTERIA_HASHES_URL"
|
||||||
validate_hysteria_version_format "$HYSTERIA_VERSION"
|
validate_hysteria_version_format "$HYSTERIA_VERSION"
|
||||||
|
|
||||||
HYSTERIA_RESOLVED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
HYSTERIA_RESOLVED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
|
||||||
log_info "Resolved Hysteria $HYSTERIA_VERSION (tag ${HYSTERIA_TAG:-unknown}, published ${HYSTERIA_PUBLISHED_AT:-unknown})"
|
log_info "Resolved Hysteria $HYSTERIA_VERSION (tag ${HYSTERIA_TAG:-unknown}, published ${HYSTERIA_PUBLISHED_AT:-unknown})"
|
||||||
|
|
||||||
export HYSTERIA_VERSION HYSTERIA_TAG HYSTERIA_ARTIFACT_URL HYSTERIA_PUBLISHED_AT
|
export HYSTERIA_VERSION HYSTERIA_TAG HYSTERIA_ARTIFACT_URL HYSTERIA_HASHES_URL
|
||||||
export HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT
|
export HYSTERIA_PUBLISHED_AT HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ожидаемый digest берётся из upstream-ассета hashes.txt ДО того, как он
|
||||||
|
# попадёт в HY2XS lock. Иначе замороженная сумма подтверждает только то, что
|
||||||
|
# файл не изменился после скачивания.
|
||||||
|
resolve_expected_sha_from_upstream() {
|
||||||
|
[ "$HYSTERIA_VERIFY_UPSTREAM_HASHES" = "true" ] || {
|
||||||
|
log_info "WARNING: upstream hashes.txt verification is disabled (HYSTERIA_VERIFY_UPSTREAM_HASHES=$HYSTERIA_VERIFY_UPSTREAM_HASHES)"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
[ -n "${HYSTERIA_HASHES_URL:-}" ] || fail "upstream hashes.txt URL is unknown"
|
||||||
|
|
||||||
|
mkdir -p "$HYSTERIA_WORK_DIR"
|
||||||
|
local hashes_file="$HYSTERIA_WORK_DIR/hashes.txt"
|
||||||
|
|
||||||
|
log_info "Downloading upstream checksums: $HYSTERIA_HASHES_URL"
|
||||||
|
rm -f "$hashes_file"
|
||||||
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \
|
||||||
|
"$HYSTERIA_HASHES_URL" -o "$hashes_file" \
|
||||||
|
|| fail "failed to download upstream hashes.txt: $HYSTERIA_HASHES_URL"
|
||||||
|
[ -s "$hashes_file" ] || fail "downloaded upstream hashes.txt is empty"
|
||||||
|
|
||||||
|
local upstream_sha
|
||||||
|
upstream_sha="$(cd orchestrator && "$BUN_BIN" run tools/parse-upstream-hashes.ts \
|
||||||
|
--file "../$hashes_file" --asset "$HYSTERIA_LINUX_AMD64_ASSET")" \
|
||||||
|
|| fail "could not read the expected SHA-256 from upstream hashes.txt"
|
||||||
|
|
||||||
|
validate_hysteria_sha256_format "$upstream_sha"
|
||||||
|
|
||||||
|
HYSTERIA_ARTIFACT_SHA256="$upstream_sha"
|
||||||
|
HYSTERIA_SHA_SOURCE="upstream-hashes"
|
||||||
|
export HYSTERIA_ARTIFACT_SHA256 HYSTERIA_SHA_SOURCE
|
||||||
|
|
||||||
|
log_info "Expected Hysteria SHA-256 from upstream hashes.txt: $upstream_sha"
|
||||||
}
|
}
|
||||||
|
|
||||||
download_hysteria_artifact() {
|
download_hysteria_artifact() {
|
||||||
@@ -123,13 +170,17 @@ download_hysteria_artifact() {
|
|||||||
if [ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ]; then
|
if [ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ]; then
|
||||||
validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
|
validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
|
||||||
[ "$actual_sha" = "$HYSTERIA_ARTIFACT_SHA256" ] \
|
[ "$actual_sha" = "$HYSTERIA_ARTIFACT_SHA256" ] \
|
||||||
|| fail "Hysteria artifact sha256 mismatch: expected $HYSTERIA_ARTIFACT_SHA256, got $actual_sha"
|
|| fail "Hysteria artifact sha256 mismatch: expected $HYSTERIA_ARTIFACT_SHA256 (${HYSTERIA_SHA_SOURCE:-unknown source}), got $actual_sha"
|
||||||
|
log_info "Hysteria artifact verified against ${HYSTERIA_SHA_SOURCE:-unknown source}: $actual_sha"
|
||||||
else
|
else
|
||||||
|
# Сюда попадаем только при выключенной сверке с upstream: сумма
|
||||||
|
# фиксируется как локальный fingerprint пакета, без проверки происхождения.
|
||||||
HYSTERIA_ARTIFACT_SHA256="$actual_sha"
|
HYSTERIA_ARTIFACT_SHA256="$actual_sha"
|
||||||
log_info "Computed Hysteria artifact sha256: $HYSTERIA_ARTIFACT_SHA256"
|
HYSTERIA_SHA_SOURCE="local-download"
|
||||||
|
log_info "Computed Hysteria artifact sha256 without upstream verification: $HYSTERIA_ARTIFACT_SHA256"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export HYSTERIA_ARTIFACT_SHA256 HYSTERIA_BINARY
|
export HYSTERIA_ARTIFACT_SHA256 HYSTERIA_BINARY HYSTERIA_SHA_SOURCE
|
||||||
|
|
||||||
chmod 0755 "$HYSTERIA_BINARY"
|
chmod 0755 "$HYSTERIA_BINARY"
|
||||||
|
|
||||||
@@ -255,6 +306,7 @@ write_hysteria_lock_file() {
|
|||||||
HYSTERIA_VERSION=$HYSTERIA_VERSION
|
HYSTERIA_VERSION=$HYSTERIA_VERSION
|
||||||
HYSTERIA_ARTIFACT_URL=$HYSTERIA_ARTIFACT_URL
|
HYSTERIA_ARTIFACT_URL=$HYSTERIA_ARTIFACT_URL
|
||||||
HYSTERIA_ARTIFACT_SHA256=$HYSTERIA_ARTIFACT_SHA256
|
HYSTERIA_ARTIFACT_SHA256=$HYSTERIA_ARTIFACT_SHA256
|
||||||
|
HYSTERIA_SHA_SOURCE=${HYSTERIA_SHA_SOURCE:-unknown}
|
||||||
HYSTERIA_RESOLUTION=$HYSTERIA_RESOLUTION
|
HYSTERIA_RESOLUTION=$HYSTERIA_RESOLUTION
|
||||||
HYSTERIA_RESOLVED_AT=$HYSTERIA_RESOLVED_AT
|
HYSTERIA_RESOLVED_AT=$HYSTERIA_RESOLVED_AT
|
||||||
EOF
|
EOF
|
||||||
@@ -264,8 +316,11 @@ resolve_and_verify_hysteria() {
|
|||||||
case "$HYSTERIA_CHANNEL" in
|
case "$HYSTERIA_CHANNEL" in
|
||||||
stable)
|
stable)
|
||||||
resolve_hysteria_from_upstream
|
resolve_hysteria_from_upstream
|
||||||
# sha256 считается локально от скачанного артефакта
|
# Ожидаемая сумма приходит из upstream hashes.txt, а не из локального
|
||||||
|
# файла: проверяется происхождение артефакта, а не только целостность.
|
||||||
HYSTERIA_ARTIFACT_SHA256=""
|
HYSTERIA_ARTIFACT_SHA256=""
|
||||||
|
HYSTERIA_SHA_SOURCE=""
|
||||||
|
resolve_expected_sha_from_upstream
|
||||||
;;
|
;;
|
||||||
pinned)
|
pinned)
|
||||||
log_info "Using pinned Hysteria lock file (offline): $HYSTERIA_LOCK_FILE"
|
log_info "Using pinned Hysteria lock file (offline): $HYSTERIA_LOCK_FILE"
|
||||||
|
|||||||
Reference in New Issue
Block a user