312 lines
17 KiB
TypeScript
312 lines
17 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
|
||
import {
|
||
assertEnvTransportable,
|
||
formatEnvAssignment,
|
||
isEnvTransportable,
|
||
parseEnvFile,
|
||
renderEnvFile
|
||
} from "../src/lib/envFile";
|
||
import {
|
||
parseRuntimeEnv,
|
||
renderRuntimeEnv,
|
||
runtimeEnvEntries,
|
||
validateRuntimeEnvTransport
|
||
} from "../src/config/env";
|
||
import { baselineConfig, envText } from "./fixtures";
|
||
|
||
/**
|
||
* Формат /etc/hy2xs/hy2xs.env.
|
||
*
|
||
* У файла ДВА читателя, и один из них не наш: он объявлен `EnvironmentFile=` в
|
||
* юните hy2xs-admin, то есть его разбирает systemd. Пока значения писались
|
||
* интерполяцией, а читались `split("=")` с `trim()`, форматом это не являлось —
|
||
* совпадение поведения держалось на том, что в значениях не встречалось ни
|
||
* пробелов по краям, ни кавычек, ни обратных слешей. Продукт при этом обещает
|
||
* оператору, что набор символов пароля не ограничен, а краевой пробел — часть
|
||
* значения.
|
||
*
|
||
* Проверяется поэтому не «работает на наших данных», а совпадение с правилами
|
||
* systemd (src/basic/env-file.c) на тех самых значениях, где прежний разбор
|
||
* расходился.
|
||
*/
|
||
|
||
describe("разбор повторяет правила systemd", () => {
|
||
test("у незакавыченного значения срезаются краевые пробелы", () => {
|
||
// Ровно это делает состояние VALUE: `value[last_value_whitespace] = 0`.
|
||
// Отсюда и требование закавычивать пароль: иначе его хвост теряет systemd,
|
||
// а не наш парсер.
|
||
expect(parseEnvFile("KEY= value \n")).toEqual({ KEY: "value" });
|
||
});
|
||
|
||
test("в двойных кавычках краевые пробелы сохраняются", () => {
|
||
expect(parseEnvFile('KEY=" value "\n')).toEqual({ KEY: " value " });
|
||
});
|
||
|
||
test("в двойных кавычках снимается escape только у SHELL_NEED_ESCAPE", () => {
|
||
// systemd: `если символ из тех, что нуждаются в экранировании, — снять
|
||
// экранирование; иначе оставить обратный слеш на месте, как делает shell`.
|
||
expect(parseEnvFile('KEY="a\\"b"\n')).toEqual({ KEY: 'a"b' });
|
||
expect(parseEnvFile('KEY="a\\\\b"\n')).toEqual({ KEY: "a\\b" });
|
||
expect(parseEnvFile('KEY="a\\$b"\n')).toEqual({ KEY: "a$b" });
|
||
expect(parseEnvFile('KEY="a\\`b"\n')).toEqual({ KEY: "a`b" });
|
||
// `n` в SHELL_NEED_ESCAPE не входит: обратный слеш сохраняется.
|
||
expect(parseEnvFile('KEY="a\\nb"\n')).toEqual({ KEY: "a\\nb" });
|
||
});
|
||
|
||
test("подстановки переменных в значении нет", () => {
|
||
// env-файл не является shell-скриптом: `$` — обычный символ.
|
||
expect(parseEnvFile('KEY="$HOME ${OTHER}"\n')).toEqual({ KEY: "$HOME ${OTHER}" });
|
||
});
|
||
|
||
test("в одинарных кавычках всё literal, escape там нет", () => {
|
||
expect(parseEnvFile("KEY='a\\b\"c $d'\n")).toEqual({ KEY: 'a\\b"c $d' });
|
||
});
|
||
|
||
test("незакавыченное значение обрабатывает обратный слеш как escape", () => {
|
||
expect(parseEnvFile("KEY=a\\ b\n")).toEqual({ KEY: "a b" });
|
||
// Экранированный перевод строки склеивает строки.
|
||
expect(parseEnvFile("KEY=a\\\nb\n")).toEqual({ KEY: "ab" });
|
||
});
|
||
|
||
test("комментарии и пустые строки пропускаются", () => {
|
||
expect(parseEnvFile("# comment\n\n; other\n KEY=value\n")).toEqual({ KEY: "value" });
|
||
});
|
||
|
||
test("пустое значение остаётся пустой строкой", () => {
|
||
expect(parseEnvFile("KEY=\n")).toEqual({ KEY: "" });
|
||
});
|
||
|
||
test("значение без перевода строки в конце файла не теряется", () => {
|
||
expect(parseEnvFile("KEY=value")).toEqual({ KEY: "value" });
|
||
});
|
||
|
||
// Единственное намеренное расхождение с systemd: он такую строку молча
|
||
// отбрасывает. Молчаливая потеря строки из hy2xs.env означала бы установку с
|
||
// настройкой, которую оператор задал, а продукт не увидел.
|
||
test("строка без знака равенства — отказ, а не пропуск", () => {
|
||
expect(() => parseEnvFile("HY2XS_UI_PORT\n")).toThrow(/invalid env line/);
|
||
});
|
||
|
||
test("незакрытая кавычка — отказ, а не значение до конца файла", () => {
|
||
expect(() => parseEnvFile('KEY="value\n')).toThrow(/unterminated env value/);
|
||
});
|
||
});
|
||
|
||
describe("запись обратима разбором", () => {
|
||
// Значения, на которых прежняя запись теряла или искажала содержимое. Каждое
|
||
// из них — законный пароль администратора: набор символов не ограничен.
|
||
const values = [
|
||
"simple-value",
|
||
" краевые пробелы ",
|
||
"trailing space ",
|
||
" leading space",
|
||
'кавычка " внутри',
|
||
"обратный слеш \\ внутри",
|
||
"и то и другое \\\" вместе",
|
||
"доллар $HOME и backtick `id`",
|
||
"апостроф ' внутри",
|
||
"решётка # и точка с запятой ;",
|
||
"50 mbps",
|
||
"равно = внутри значения",
|
||
"пароль-администратора",
|
||
"😀😀😀😀😀😀"
|
||
];
|
||
|
||
test("любое значение переживает render -> parse побайтово", () => {
|
||
for (const value of values) {
|
||
const rendered = renderEnvFile([["HY2XS_ADMIN_INITIAL_PASSWORD", value]]);
|
||
expect(parseEnvFile(rendered).HY2XS_ADMIN_INITIAL_PASSWORD).toBe(value);
|
||
}
|
||
});
|
||
|
||
test("кавычки ставятся только там, где они нужны", () => {
|
||
// Обычные значения обязаны остаться побайтово прежними: релизные гейты и
|
||
// инструкции оператора ищут строки вида `^HY2XS_UI_PORT=8080$`.
|
||
expect(formatEnvAssignment("HY2XS_UI_PORT", "8080")).toBe("HY2XS_UI_PORT=8080");
|
||
expect(formatEnvAssignment("HY2XS_ADMIN_USER", "hy2xsadmin")).toBe("HY2XS_ADMIN_USER=hy2xsadmin");
|
||
expect(formatEnvAssignment("HY2XS_HYSTERIA_BANDWIDTH_UP", "50 mbps")).toBe(
|
||
"HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps"
|
||
);
|
||
expect(formatEnvAssignment("HY2XS_TLS_CERT_PATH", "/etc/hysteria/server.crt")).toBe(
|
||
"HY2XS_TLS_CERT_PATH=/etc/hysteria/server.crt"
|
||
);
|
||
expect(formatEnvAssignment("HY2XS_DOMAIN", "")).toBe("HY2XS_DOMAIN=");
|
||
|
||
// А необычные — получают кавычки и экранирование.
|
||
expect(formatEnvAssignment("K", "trailing ")).toBe('K="trailing "');
|
||
expect(formatEnvAssignment("K", 'a"b')).toBe('K="a\\"b"');
|
||
expect(formatEnvAssignment("K", "a\\b")).toBe('K="a\\\\b"');
|
||
});
|
||
|
||
test("экранируются ТОЛЬКО обратный слеш и двойная кавычка", () => {
|
||
// Оба входят в SHELL_NEED_ESCAPE, поэтому разворачиваются обратно
|
||
// одинаково во всех изданиях systemd — в том числе в тех, где escape в
|
||
// двойных кавычках снимался безусловно. `$` и backtick трогать нельзя:
|
||
// подстановки в env-файле нет, и лишний слеш стал бы частью значения.
|
||
expect(formatEnvAssignment("K", "a$b`c")).toBe('K="a$b`c"');
|
||
});
|
||
|
||
test("непредставимое значение — отказ записи, а не потеря части секрета", () => {
|
||
// Отвергается документированное множество EnvironmentFile, а не то, что
|
||
// нам не нравится.
|
||
for (const rejected of [
|
||
String.fromCodePoint(0x0000),
|
||
String.fromCodePoint(0xfdd0),
|
||
String.fromCodePoint(0xffff),
|
||
String.fromCodePoint(0x10ffff),
|
||
]) {
|
||
expect(() =>
|
||
assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`)
|
||
).toThrow(/documented systemd EnvironmentFile contract/);
|
||
expect(() =>
|
||
formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`)
|
||
).toThrow(/documented systemd EnvironmentFile contract/);
|
||
}
|
||
});
|
||
|
||
// Одиночный суррогат — единственный случай, где без проверки не было бы даже
|
||
// отказа: `TextEncoder` молча заменил бы его на U+FFFD, то есть в файл уехал
|
||
// бы ДРУГОЙ секрет, а не сломанный.
|
||
test("одиночный суррогат отвергается, а не подменяется на U+FFFD", () => {
|
||
const lone = String.fromCharCode(0xd800);
|
||
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(
|
||
/documented systemd EnvironmentFile contract/
|
||
);
|
||
});
|
||
|
||
// Домен транспорта — ЧУЖОЕ документированное множество systemd.
|
||
//
|
||
// Управляющие символы формат несёт: внутри двойных кавычек перевод строки
|
||
// накапливается как обычный байт и переживает round-trip. Запрещает их
|
||
// контракт учётных данных, а не транспорт, и приписывать этот запрет формату
|
||
// было бы неправдой — именно так проверка и пропустила noncharacters, о
|
||
// которых ничего не знала.
|
||
test("управляющие символы формат несёт: их запрещает контракт, а не транспорт", () => {
|
||
for (const control of ["\n", "\r", "\t", String.fromCodePoint(0x7f), String.fromCodePoint(0x85)]) {
|
||
const value = `abcde${control}fghij`;
|
||
expect(isEnvTransportable(value)).toBe(true);
|
||
const rendered = renderEnvFile([["HY2XS_ADMIN_INITIAL_PASSWORD", value]]);
|
||
expect(parseEnvFile(rendered).HY2XS_ADMIN_INITIAL_PASSWORD).toBe(value);
|
||
}
|
||
|
||
// Публичный контракт EnvironmentFile запрещает U+FEFF. Реализация v257.13
|
||
// случайно пропускает его; продукт не закрепляет это расхождение.
|
||
expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(false);
|
||
});
|
||
|
||
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
|
||
// множество systemd, а не окрестность подозрительных значений.
|
||
test("соседи noncharacters принимаются", () => {
|
||
for (const accepted of [0xfdcf, 0xfdf0, 0xfffd, 0x10fffd, 0x1f600]) {
|
||
expect(isEnvTransportable(String.fromCodePoint(accepted))).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("пароль администратора доезжает до админки неизменным", () => {
|
||
// Главный барьер файла. Продукт объявляет краевой пробел частью пароля;
|
||
// прежняя цепочка теряла его дважды — при записи (systemd срезал бы хвост
|
||
// незакавыченного значения) и при чтении (собственный `trim()`).
|
||
const passwords = [
|
||
"abcde ",
|
||
" abcde",
|
||
'пароль "в кавычках"',
|
||
"back\\slash",
|
||
"d$llar and `tick`",
|
||
"апостроф ' и решётка #",
|
||
" "
|
||
];
|
||
|
||
test("render -> parse сохраняет значение целиком", () => {
|
||
for (const password of passwords) {
|
||
const config = baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: null });
|
||
const rendered = renderRuntimeEnv({ ...config, adminInitialPassword: password });
|
||
expect(parseRuntimeEnv(rendered).adminInitialPassword).toBe(password);
|
||
}
|
||
});
|
||
|
||
test("значение, записанное оператором в кавычках, читается как есть", () => {
|
||
// Оператор правит hy2xs.env руками: закавыченная запись обязана работать и
|
||
// у нас, и у systemd одинаково.
|
||
const config = parseRuntimeEnv(
|
||
envText({ HY2XS_ADMIN_INITIAL_PASSWORD: '"пароль с пробелом на конце "' })
|
||
);
|
||
expect(config.adminInitialPassword).toBe("пароль с пробелом на конце ");
|
||
});
|
||
|
||
test("незакавыченное значение теряет краевые пробелы — как и у systemd", () => {
|
||
// Это НЕ дефект, а точное повторение чужих правил: оператор, записавший
|
||
// пароль без кавычек, получит ровно тот пароль, который увидит админка.
|
||
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: "abcdef " }));
|
||
expect(config.adminInitialPassword).toBe("abcdef");
|
||
});
|
||
});
|
||
|
||
describe("непредставимая конфигурация отвергается до первой мутации", () => {
|
||
// Проверка транспорта жила ТОЛЬКО внутри renderRuntimeEnv, то есть
|
||
// срабатывала на шаге «write runtime env» — уже после bootstrap оркестратора,
|
||
// установки пакетов и раскладки файловой системы. Read-only
|
||
// `preflight-install` при этом говорил PASS: он зовёт parseRuntimeEnv и
|
||
// ничего не рендерит. Детерминированно известная ошибка конфигурации роняла
|
||
// операцию, оставив за собой изменённый хост.
|
||
|
||
test("parseRuntimeEnv отвергает значение, которое systemd не загрузит", () => {
|
||
const noncharacter = String.fromCodePoint(0xfdd0);
|
||
expect(() =>
|
||
parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: `"abcde${noncharacter}"` }))
|
||
).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 и никаких проверок содержимого раньше не имел вовсе.
|
||
test("проверяется каждое значение, а не только пароль администратора", () => {
|
||
const noncharacter = String.fromCodePoint(0xffff);
|
||
for (const key of [
|
||
"HY2XS_ADMIN_CON_PASS",
|
||
"HY2XS_HYSTERIA_BANDWIDTH_UP",
|
||
"HY2XS_ACME_EMAIL"
|
||
]) {
|
||
expect(() => parseRuntimeEnv(envText({ [key]: `"value${noncharacter}"` }))).toThrow(
|
||
/documented systemd EnvironmentFile contract/
|
||
);
|
||
}
|
||
});
|
||
|
||
// Список пар — один на запись и на проверку. Пока он существовал только
|
||
// внутри рендера, единственным способом узнать, что конфигурация не
|
||
// запишется, было её записать.
|
||
test("проверка и запись ходят по одному списку пар", () => {
|
||
const config = baselineConfig();
|
||
const entries = runtimeEnvEntries(config);
|
||
const rendered = renderRuntimeEnv(config);
|
||
|
||
expect(entries.length).toBeGreaterThan(20);
|
||
for (const [key] of entries) {
|
||
expect(rendered).toContain(`\n${key}=`);
|
||
}
|
||
expect(() => validateRuntimeEnvTransport(config)).not.toThrow();
|
||
});
|
||
|
||
// Гарантия целиком: всё, что parseRuntimeEnv принял, обязано записаться.
|
||
test("принятая конфигурация записывается без отказа", () => {
|
||
const config = parseRuntimeEnv(
|
||
envText({ HY2XS_ADMIN_INITIAL_PASSWORD: '"пароль с пробелом "' })
|
||
);
|
||
expect(() => renderRuntimeEnv(config)).not.toThrow();
|
||
});
|
||
});
|