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(); }); });