fix(utf8): закрыть lossy-границы конфигурации и API

This commit is contained in:
2026-09-07 17:25:27 +05:00
parent ab788725cf
commit af9f476658
38 changed files with 759 additions and 159 deletions
+76 -54
View File
@@ -1,74 +1,96 @@
import { join } from "node:path";
import type { CommonOptions } from "../types/context";
import { InvalidUtf8Error, readText, writeTextAtomic } from "../lib/fs";
import { info, setOperationContext } from "../lib/log";
import { runMutating } from "../lib/process";
import { runMutating, runReadOnlyArgvStatus } from "../lib/process";
import { redactEnv, redactLogText, redactYaml } from "../lib/redaction";
function shellEscapeSingleQuotes(value: string): string {
return value.replaceAll("'", "'\\''");
}
const unavailable = (reason: string): string =>
`[HY2XS: источник не включён в диагностику: ${reason}]\n`;
async function writeDiagnostic(outDir: string, name: string, content: string): Promise<void> {
await writeTextAtomic(join(outDir, name), content, { mode: 0o600, owner: "root", group: "root" });
}
/**
* Вывод сначала попадает в память, редактируется и лишь затем записывается.
* В staging-каталоге ни на мгновение нет сырого journal/systemctl-вывода с
* machine token.
*/
async function collectCommand(outDir: string, name: string, argv: string[]): Promise<void> {
try {
const result = await runReadOnlyArgvStatus(argv);
const combined = [result.stdout, result.stderr]
.filter(Boolean)
.join(result.stdout && result.stderr ? "\n" : "");
const status = result.exitCode === 0 ? "" : `[exit code: ${result.exitCode}]\n`;
const truncated = result.stdoutTruncated || result.stderrTruncated
? "[HY2XS: вывод обрезан по безопасному пределу 8 МиБ на поток]\n"
: "";
await writeDiagnostic(outDir, name, redactLogText(`${status}${truncated}${combined}`));
} catch (error) {
info(`diagnostics: ${name} пропущен: ${error instanceof Error ? error.message : String(error)}`);
await writeDiagnostic(outDir, name, unavailable("команда недоступна"));
}
}
/**
* Конфигурация никогда не копируется в staging как есть. Повреждённый UTF-8
* не декодируется с заменой: в бандл попадает только безопасное объяснение без
* исходных байтов.
*/
async function collectFile(
outDir: string,
name: string,
source: string,
sanitize: (content: string) => string
): Promise<void> {
await writeDiagnostic(outDir, name, await prepareDiagnosticFile(source, sanitize));
}
/** Чистая граница «прочитать строго -> санитизировать -> вернуть текст». */
export async function prepareDiagnosticFile(
source: string,
sanitize: (content: string) => string
): Promise<string> {
try {
const raw = await readText(source);
return sanitize(raw);
} catch (error) {
const reason = error instanceof InvalidUtf8Error ? "некорректный UTF-8" : "файл недоступен";
info(`diagnostics: ${source} пропущен: ${error instanceof Error ? error.message : String(error)}`);
return unavailable(reason);
}
}
export async function diagnosticsCollect(_options: CommonOptions): Promise<void> {
const opId = `diag-${Date.now().toString(36)}`;
setOperationContext(opId);
const outDir = `/var/log/hy2xs/diagnostics/${opId}`;
const archive = `/var/log/hy2xs/diagnostics/${opId}.tar.gz`;
await runMutating`mkdir -p ${outDir}`;
await runMutating`install -d -m 0700 ${outDir}`;
await runMutating`sh -c ${`systemctl status hysteria-server > '${shellEscapeSingleQuotes(`${outDir}/systemd-hysteria.txt`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`systemctl status hy2xs-admin > '${shellEscapeSingleQuotes(`${outDir}/systemd-admin.txt`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`journalctl -u hysteria-server -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-hysteria.log`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`journalctl -u hy2xs-admin -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-admin.log`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`nft list ruleset > '${shellEscapeSingleQuotes(`${outDir}/nftables.ruleset`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`uname -a > '${shellEscapeSingleQuotes(`${outDir}/uname.txt`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`cat /etc/os-release > '${shellEscapeSingleQuotes(`${outDir}/os-release.txt`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`cp -a /etc/hysteria/post-install.env '${shellEscapeSingleQuotes(`${outDir}/post-install.env`)}' 2>/dev/null || true`}`;
await runMutating`sh -c ${`cp -a /etc/hy2xs/hy2xs.env '${shellEscapeSingleQuotes(`${outDir}/hy2xs.env`)}' 2>/dev/null || true`}`;
await runMutating`sh -c ${`cp -a /etc/hysteria/config.yaml '${shellEscapeSingleQuotes(`${outDir}/hysteria-config.yaml`)}' 2>/dev/null || true`}`;
await runMutating`sh -c ${`cp -a /var/lib/hy2xs/install-state.json '${shellEscapeSingleQuotes(`${outDir}/install-state.json`)}' 2>/dev/null || true`}`;
await runMutating`sh -c ${`ss -ltnup > '${shellEscapeSingleQuotes(`${outDir}/ss-ltnup.txt`)}' 2>&1 || true`}`;
await collectCommand(outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]);
await collectCommand(outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]);
await collectCommand(outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]);
await collectCommand(outDir, "journal-admin.log", ["journalctl", "-u", "hy2xs-admin", "-n", "300", "--no-pager"]);
await collectCommand(outDir, "nftables.ruleset", ["nft", "list", "ruleset"]);
await collectCommand(outDir, "uname.txt", ["uname", "-a"]);
await collectCommand(outDir, "ss-ltnup.txt", ["ss", "-ltnup"]);
try {
const envRaw = await Bun.file(`${outDir}/hy2xs.env`).text();
await Bun.write(`${outDir}/hy2xs.env`, redactEnv(envRaw));
} catch {
// noop
}
await collectFile(outDir, "os-release.txt", "/etc/os-release", redactLogText);
await collectFile(outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv);
await collectFile(outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv);
await collectFile(outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml);
await collectFile(outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText);
try {
const postInstallRaw = await Bun.file(`${outDir}/post-install.env`).text();
await Bun.write(`${outDir}/post-install.env`, redactEnv(postInstallRaw));
} catch {
// noop
}
try {
const cfgRaw = await Bun.file(`${outDir}/hysteria-config.yaml`).text();
await Bun.write(`${outDir}/hysteria-config.yaml`, redactYaml(cfgRaw));
} catch {
// noop
}
// Журналы и вывод systemctl — такой же артефакт, покидающий сервер, как env
// и YAML. Раньше редактировались только последние два, а journal-admin.log
// копировался как есть — и уносил machine token, который админка логировала
// в составе RequestURI.
for (const logFile of [
"journal-hysteria.log",
"journal-admin.log",
"systemd-hysteria.txt",
"systemd-admin.txt"
]) {
try {
const raw = await Bun.file(`${outDir}/${logFile}`).text();
await Bun.write(`${outDir}/${logFile}`, redactLogText(raw));
} catch {
// noop
}
}
await runMutating`sh -c ${`tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`;
await runMutating`sh -c ${`umask 077; tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`;
await runMutating`chmod 0600 ${archive}`;
info(`diagnostics bundle collected: ${archive}`);
}
+5
View File
@@ -221,6 +221,11 @@ function normalizeSafeAbsolutePath(name: string, value: string, options?: { disa
}
export function parseRuntimeEnv(content: string): RuntimeConfig {
// Проверяется ИСХОДНЫЙ текст до разбора. Иначе запрещённый символ в
// комментарии, неизвестном ключе или перед первым ключом мог бы исчезнуть
// при нормализации, и preflight объявил бы повреждённый EnvironmentFile
// корректным. Нормализованные значения проверяются повторно ниже.
assertEnvTransportable("runtime EnvironmentFile", content);
const env = parseEnv(content);
const uiPort = parsePort("HY2XS_UI_PORT", env.HY2XS_UI_PORT, 8080);
+8 -9
View File
@@ -135,10 +135,10 @@ export function assertValidAdminUsername(name: string, value: string): string {
* создавалась: bcrypt отвечал ErrPasswordTooLong при старте админки, и оператор
* получал не отказ установки, а сервер без администратора.
*
* Запрет управляющих символов — требование транспорта. Значение уезжает в
* /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile; границей
* строки там является байт `\n` или `\r`, и пароль с ними не имеет
* представления, переживающего запись и чтение. См. lib/envFile.ts.
* Управляющие символы формат EnvironmentFile способен нести; их отвергает
* политика HY2XS, потому что невидимое значение нельзя надёжно повторить в
* однострочной форме входа. Транспортный домен (включая U+FEFF) проверяется
* отдельно в lib/envFile.ts.
*
* Набор символов сверх этого НЕ ограничивается: сервер его не ограничивает ни
* при установке, ни при смене пароля, и оркестратор не имеет права быть строже.
@@ -158,10 +158,9 @@ export function isValidAdminPassword(value: string): boolean {
let characters = 0;
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
// Продуктовая политика: управляющие символы Unicode целиком (Cc — C0, DEL,
// C1) и U+FEFF. Формат их несёт; запрещает их HY2XS, потому что ни один из
// них невозможно ни увидеть в поле ввода, ни повторить при следующем входе.
if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) {
// Продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
// DEL, C1). U+FEFF уже отвергнут транспортным доменом выше.
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
return false;
}
characters += 1;
@@ -179,7 +178,7 @@ export function assertValidAdminPassword(name: string, value: string): string {
throw new Error(
`invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` +
`до ${ADMIN_PASSWORD_MAX_LENGTH} символов Unicode, не длиннее ${ADMIN_PASSWORD_MAX_BYTES} байт ` +
`в UTF-8 и без управляющих символов; получено ${[...value].length} символов ` +
`в UTF-8, без управляющих символов и U+FEFF; получено ${[...value].length} символов ` +
`и ${Buffer.byteLength(value, "utf8")} байт. ` +
`Набор символов не ограничен, пробелы по краям являются частью пароля. ` +
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
+9 -6
View File
@@ -293,13 +293,16 @@ function describeCodePoint(code: number): string {
}
/**
* Домен значений, которые systemd СМОЖЕТ загрузить из EnvironmentFile.
* Документированный домен значений EnvironmentFile.
*
* Это чужое множество, а не наша политика, и оно проверяется отдельно именно
* поэтому: нарушение здесь — не «некрасивое значение», а НЕзагруженный файл
* окружения и, следовательно, юнит, который не стартует.
*
* Перед тем как принять пару, systemd прогоняет ключ и значение через
* Публичная документация systemd дополнительно запрещает U+FEFF. Реализация
* v257.13 по ошибке пропускает его из-за маски в `unichar_is_valid`; HY2XS
* следует документированному контракту, а не случайному расхождению одной
* версии реализации. Перед тем как принять пару, systemd прогоняет ключ и значение через
* `utf8_is_valid` (src/basic/env-file.c, `check_utf8ness_and_warn`), и отказ там
* возвращает `-EINVAL`. `utf8_is_valid` отвергает встроенный NUL и всё, что не
* является Unicode scalar value, а `unichar_is_valid` (src/basic/utf8.c) сверх
@@ -321,7 +324,7 @@ function describeCodePoint(code: number): string {
export function isEnvTransportable(value: string): boolean {
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (code === 0) {
if (code === 0 || code === 0xfeff) {
return false;
}
if (code >= 0xd800 && code <= 0xdfff) {
@@ -359,9 +362,9 @@ export function assertEnvTransportable(name: string, value: string): string {
continue;
}
throw new Error(
`${name} contains ${describeCodePoint(code)}, which systemd refuses to load from an ` +
`EnvironmentFile: значение обязано быть валидным UTF-8 из Unicode scalar values, ` +
`без NUL, без суррогатов и без noncharacters (U+FDD0..U+FDEF и *FFFE/*FFFF). ` +
`${name} contains ${describeCodePoint(code)}, which is forbidden by the documented systemd ` +
`EnvironmentFile contract: значение обязано быть валидным UTF-8 из Unicode scalar values, ` +
`без NUL, U+FEFF, суррогатов и noncharacters (U+FDD0..U+FDEF и *FFFE/*FFFF). ` +
`Файл окружения с таким значением не загрузится, и юнит не стартует.`
);
}
+30 -1
View File
@@ -27,8 +27,37 @@ export async function dirExists(path: string): Promise<boolean> {
return st?.isDirectory() ?? false;
}
export class InvalidUtf8Error extends Error {
readonly path: string;
constructor(path: string, cause: unknown) {
super(`файл ${path} содержит некорректную последовательность UTF-8`, { cause });
this.name = "InvalidUtf8Error";
this.path = path;
}
}
/** Явное replacement-декодирование только для диагностического вывода команд. */
export function decodeUtf8Lossy(bytes: Uint8Array): string {
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
/**
* Читает текстовые файлы без молчаливой подмены повреждённых байтов на U+FFFD.
*
* `Blob.text()` выполняет обычное декодирование UTF-8 с replacement mode. Для
* конфигурации и секретов это недопустимо: другое байтовое значение не должно
* незаметно превратиться в корректную строку. `ignoreBOM: true` намеренно
* сохраняет начальный BOM как U+FEFF, чтобы транспортный валидатор увидел и
* отверг его по публичному контракту EnvironmentFile.
*/
export async function readText(path: string): Promise<string> {
return await Bun.file(path).text();
const bytes = await Bun.file(path).bytes();
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
} catch (error) {
throw new InvalidUtf8Error(path, error);
}
}
export async function writeText(path: string, data: string, mode?: number): Promise<void> {
+7 -2
View File
@@ -1,10 +1,15 @@
import { readText } from "./fs";
import { InvalidUtf8Error, readText } from "./fs";
import { runReadOnly } from "./process";
export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> {
try {
return (await readText(`${packageDir}/metadata/${file}`)).trim();
} catch {
} catch (error) {
// Отсутствующая metadata допускает fallback, повреждённая — нет: иначе
// битый пакет выдаёт себя за пакет с корректным запасным значением.
if (error instanceof InvalidUtf8Error) {
throw error;
}
return fallback;
}
}
+70
View File
@@ -35,6 +35,7 @@
*/
import { assertMutationAllowed } from "./guard";
import { decodeUtf8Lossy } from "./fs";
import { info } from "./log";
function shellQuote(value: unknown): string {
@@ -190,8 +191,77 @@ export type MutationResult = {
exitCode: number;
stdout: string;
stderr: string;
stdoutTruncated?: boolean;
stderrTruncated?: boolean;
};
async function readDiagnosticPipe(
stream: ReadableStream<Uint8Array>,
maxBytes: number
): Promise<{ text: string; truncated: boolean }> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let captured = 0;
let truncated = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const remaining = maxBytes - captured;
if (remaining > 0) {
// slice, а не subarray: иначе маленький сохранённый view удерживает в
// памяти весь большой backing buffer и формально обходит лимит.
const chunk = value.byteLength <= remaining ? value : value.slice(0, remaining);
chunks.push(chunk);
captured += chunk.byteLength;
}
if (value.byteLength > remaining) {
truncated = true;
}
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(captured);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return { text: decodeUtf8Lossy(bytes), truncated };
}
/**
* Наблюдение готовым argv с сохранением ненулевого кода возврата.
* Диагностический вывод декодируется lossy намеренно; конфигурационные файлы
* всегда проходят через строгий readText.
*/
export async function runReadOnlyArgvStatus(
argv: string[],
maxOutputBytes = 8 * 1024 * 1024
): Promise<MutationResult> {
assertArgv(argv, "runReadOnlyArgvStatus");
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1) {
throw new Error("runReadOnlyArgvStatus: maxOutputBytes must be a positive integer");
}
const subprocess = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([
readDiagnosticPipe(subprocess.stdout, maxOutputBytes),
readDiagnosticPipe(subprocess.stderr, maxOutputBytes),
subprocess.exited
]);
return {
exitCode,
stdout: stdout.text,
stderr: stderr.text,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated
};
}
/**
* Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска.
*
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { prepareDiagnosticFile } from "../src/commands/diagnostics";
import { runReadOnlyArgvStatus } from "../src/lib/process";
import { redactEnv } from "../src/lib/redaction";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
async function source(bytes: Uint8Array): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "hy2xs-diagnostics-"));
directories.push(directory);
const path = join(directory, "hy2xs.env");
await writeFile(path, bytes);
return path;
}
describe("безопасная подготовка diagnostics", () => {
test("секрет редактируется до выдачи текста на запись", async () => {
const path = await source(new TextEncoder().encode("HY2XS_ADMIN_CON_PASS=top-secret\n"));
const prepared = await prepareDiagnosticFile(path, redactEnv);
expect(prepared).toContain("<redacted>");
expect(prepared).not.toContain("top-secret");
});
test("повреждённый UTF-8 заменяется безопасной причиной, а не U+FFFD", async () => {
const path = await source(new Uint8Array([0x53, 0x45, 0x43, 0x52, 0x45, 0x54, 0x3d, 0xff]));
const prepared = await prepareDiagnosticFile(path, redactEnv);
expect(prepared).toContain("некорректный UTF-8");
expect(prepared).not.toContain("SECRET=");
expect(prepared).not.toContain("");
});
test("вывод внешней команды ограничен по памяти и помечен как обрезанный", async () => {
const result = await runReadOnlyArgvStatus(
[process.execPath, "-e", `process.stdout.write("x".repeat(128))`],
16
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("x".repeat(16));
expect(result.stdoutTruncated).toBe(true);
});
});
+20 -10
View File
@@ -148,7 +148,8 @@ describe("запись обратима разбором", () => {
});
test("непредставимое значение — отказ записи, а не потеря части секрета", () => {
// Отвергается то, что НЕ ЗАГРУЗИТ systemd, а не то, что нам не нравится.
// Отвергается документированное множество EnvironmentFile, а не то, что
// нам не нравится.
for (const rejected of [
String.fromCodePoint(0x0000),
String.fromCodePoint(0xfdd0),
@@ -157,10 +158,10 @@ describe("запись обратима разбором", () => {
]) {
expect(() =>
assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`)
).toThrow(/systemd refuses to load/);
).toThrow(/documented systemd EnvironmentFile contract/);
expect(() =>
formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`)
).toThrow(/systemd refuses to load/);
).toThrow(/documented systemd EnvironmentFile contract/);
}
});
@@ -172,11 +173,11 @@ describe("запись обратима разбором", () => {
expect(new TextEncoder().encode(lone)).toEqual(new Uint8Array([0xef, 0xbf, 0xbd]));
expect(isEnvTransportable(lone)).toBe(false);
expect(() => assertEnvTransportable("HY2XS_ADMIN_CON_PASS", `abcde${lone}`)).toThrow(
/systemd refuses to load/
/documented systemd EnvironmentFile contract/
);
});
// Домен транспорта — ЧУЖОЕ множество, и он не шире и не уже множества systemd.
// Домен транспорта — ЧУЖОЕ документированное множество systemd.
//
// Управляющие символы формат несёт: внутри двойных кавычек перевод строки
// накапливается как обычный байт и переживает round-trip. Запрещает их
@@ -191,10 +192,9 @@ describe("запись обратима разбором", () => {
expect(parseEnvFile(rendered).HY2XS_ADMIN_INITIAL_PASSWORD).toBe(value);
}
// U+FEFF формат тоже несёт: 0xFEFF & 0xFFFE === 0xFEFE, и unichar_is_valid
// его принимает. Комментарий `/* BOM */` в исходнике systemd относится к
// U+xFFFE и является его собственной неточностью.
expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(true);
// Публичный контракт EnvironmentFile запрещает U+FEFF. Реализация v257.13
// случайно пропускает его; продукт не закрепляет это расхождение.
expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(false);
});
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
@@ -260,6 +260,16 @@ describe("непредставимая конфигурация отвергае
).toThrow();
});
test("исходный U+FEFF отвергается до разбора, даже вне используемого значения", () => {
for (const source of [
`\uFEFF${envText()}`,
`${envText()}\n# comment \uFEFF\n`,
`${envText()}\nUNKNOWN_KEY="value\uFEFF"\n`
]) {
expect(() => parseRuntimeEnv(source)).toThrow(/U\+FEFF/);
}
});
// Ограничение принадлежит ФОРМАТУ, а не полю пароля: любой параметр сломал бы
// загрузку юнита тем же способом. HY2XS_ADMIN_CON_PASS проходит через
// requireValue и никаких проверок содержимого раньше не имел вовсе.
@@ -271,7 +281,7 @@ describe("непредставимая конфигурация отвергае
"HY2XS_ACME_EMAIL"
]) {
expect(() => parseRuntimeEnv(envText({ [key]: `"value${noncharacter}"` }))).toThrow(
/systemd refuses to load/
/documented systemd EnvironmentFile contract/
);
}
});
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { InvalidUtf8Error } from "../src/lib/fs";
import { readPackageValue } from "../src/lib/packageMeta";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
async function packageDir(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "hy2xs-metadata-"));
directories.push(directory);
await mkdir(join(directory, "metadata"));
return directory;
}
describe("metadata пакета", () => {
test("отсутствующее необязательное значение использует fallback", async () => {
const directory = await packageDir();
expect(await readPackageValue(directory, "missing", "unknown")).toBe("unknown");
});
test("повреждённый UTF-8 не маскируется fallback-значением", async () => {
const directory = await packageDir();
await writeFile(join(directory, "metadata", "package.version"), new Uint8Array([0x31, 0xff]));
await expect(readPackageValue(directory, "package.version", "unknown")).rejects.toBeInstanceOf(
InvalidUtf8Error
);
});
});
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { InvalidUtf8Error, readText } from "../src/lib/fs";
import { disableReadOnlyGuard } from "../src/lib/guard";
import { preflightInstall } from "../src/commands/preflight-install";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
async function fixture(bytes: Uint8Array): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "hy2xs-utf8-"));
directories.push(directory);
const path = join(directory, "config.env");
await writeFile(path, bytes);
return path;
}
describe("строгое чтение текстовых файлов", () => {
for (const [name, bytes] of [
["байт FF", new Uint8Array([0x61, 0xff, 0x62])],
["оборванная последовательность C3", new Uint8Array([0x61, 0xc3])],
["UTF-8-кодирование суррогата", new Uint8Array([0x61, 0xed, 0xa0, 0x80])]
] as const) {
test(`отвергает ${name} без U+FFFD`, async () => {
const path = await fixture(bytes);
await expect(readText(path)).rejects.toBeInstanceOf(InvalidUtf8Error);
});
}
test("сохраняет начальный BOM как U+FEFF для транспортной проверки", async () => {
const path = await fixture(new Uint8Array([0xef, 0xbb, 0xbf, 0x41, 0x3d, 0x31, 0x0a]));
expect(await readText(path)).toBe("\uFEFFA=1\n");
});
test("не путает настоящий U+FFFD с ошибкой декодирования", async () => {
const text = "A=abcde\n";
const path = await fixture(new TextEncoder().encode(text));
expect(await readText(path)).toBe(text);
});
test("byte-level отказ preflight не изменяет каталог пакета", async () => {
for (const bytes of [
new Uint8Array([0xff]),
new Uint8Array([0xc3]),
new Uint8Array([0xed, 0xa0, 0x80])
]) {
const path = await fixture(bytes);
const packageDir = join(path, "..");
const before = await readdir(packageDir);
try {
await expect(
preflightInstall({
packageDir,
sourceConfigPath: path,
runtimeConfigPath: path,
nonInteractive: true,
skipFirewall: false,
skipServiceStart: false,
skipSmoke: false
})
).rejects.toBeInstanceOf(InvalidUtf8Error);
} finally {
disableReadOnlyGuard();
}
expect(await readdir(packageDir)).toEqual(before);
}
});
});
+2 -1
View File
@@ -8,6 +8,7 @@
*/
import { parseUpstreamHashes } from "../src/build/hysteriaRelease";
import { readText } from "../src/lib/fs";
function fail(message: string): never {
console.error(`[hy2xs-build] ERROR: ${message}`);
@@ -47,7 +48,7 @@ function parseArgs(argv: string[]): { file: string; asset: string } {
const { file, asset } = parseArgs(Bun.argv.slice(2));
try {
const text = await Bun.file(file).text();
const text = await readText(file);
process.stdout.write(`${parseUpstreamHashes(text, asset)}\n`);
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
@@ -12,6 +12,7 @@
import { parseRuntimeEnv } from "../src/config/env";
import { HYSTERIA_OBFS_TYPES } from "../src/config/profile";
import { readText } from "../src/lib/fs";
import { hysteriaConfigTemplatePath, renderHysteriaConfig } from "../src/steps/config";
import type { RuntimeContext } from "../src/types/context";
@@ -138,7 +139,7 @@ async function main(): Promise<void> {
overrides.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT = options.trafficStatsPort;
}
const sourceEnv = await Bun.file(options.configPath).text();
const sourceEnv = await readText(options.configPath);
const config = parseRuntimeEnv(overrideEnv(sourceEnv, overrides));
const context = {
@@ -160,7 +161,7 @@ async function main(): Promise<void> {
hysteriaResolution: "compat-gate"
} satisfies RuntimeContext;
const template = await Bun.file(options.templatePath).text();
const template = await readText(options.templatePath);
await Bun.write(options.outPath, renderHysteriaConfig(context, template));
}