import { describe, expect, test } from "bun:test"; import { assertEnvTransportable, formatEnvAssignment, parseEnvFile, renderEnvFile } from "../src/lib/envFile"; import { parseRuntimeEnv, renderRuntimeEnv } 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("управляющий символ — отказ записи, а не потеря части значения", () => { expect(() => assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", "a\nb")).toThrow( /control character/ ); expect(() => formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", "a\tb")).toThrow( /control character/ ); }); }); 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"); }); });