Files

312 lines
17 KiB
TypeScript
Raw Permalink 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 {
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();
});
});