/** * Формат файлов `KEY=VALUE`, которые продукт пишет и читает: /etc/hy2xs/hy2xs.env * и /etc/hy2xs/bootstrap-admin.secret. * * Зачем этот модуль существует. У hy2xs.env ДВА читателя, и один из них не наш: * файл объявлен `EnvironmentFile=` в юните hy2xs-admin, то есть его разбирает * systemd. Пока оркестратор писал значения интерполяцией * * `HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}` * * а читал их построчным `split("=")` с `trim()`, форматом это не являлось — * это было совпадение поведения на значениях, у которых нет ни пробелов по * краям, ни кавычек, ни обратных слешей. Продукт при этом ОБЕЩАЕТ оператору, * что набор символов пароля не ограничен, а пробел по краям — часть значения. * Обещание не выполнялось ни одним из двух читателей: * * - systemd у НЕ закавыченного значения срезает пробелы по краям и трактует * `\` как escape (src/basic/env-file.c, состояние VALUE); * - собственный парсер срезал пробелы своим `trim()`. * * То есть пароль с краевым пробелом терялся ещё до запуска админки, а пароль с * обратным слешем приезжал изменённым. * * Поэтому здесь ровно две функции, и они обратны друг другу: * * parseEnvFile — разбор по правилам systemd; * formatEnvAssignment — запись, которую systemd разберёт обратно побайтово. * * Правила разбора не выдуманы и не выведены из документации: они повторяют * конечный автомат `parse_env_file_internal` из systemd/src/basic/env-file.c. * Существенны четыре его свойства: * * 1. у НЕ закавыченного значения срезаются пробелы в конце, `\` уводит в * escape, `\<перевод строки>` склеивает строки; * 2. в одинарных кавычках всё literal до закрывающей кавычки — escape там * НЕТ (это отличие от sh); * 3. в двойных кавычках `\` уводит в escape, и escape «разворачивается» * только для SHELL_NEED_ESCAPE — то есть для `"`, `\`, `` ` `` и `$`; * для любого другого символа обратный слеш СОХРАНЯЕТСЯ вместе с ним; * 4. подстановки переменных в env-файле нет вовсе: `$` внутри значения — * обычный символ. * * Из (3) и (4) следует кодирование, которое переживает любое издание systemd: * двойные кавычки и экранирование ТОЛЬКО `\` и `"`. Оба входят в * SHELL_NEED_ESCAPE, поэтому разворачиваются одинаково и в действующем * издании, и в тех, где escape в двойных кавычках снимался безусловно. */ /** Символы, которые systemd считает границей строки. */ const NEWLINE = "\n\r"; /** Символы, которые systemd считает пробельными. */ const WHITESPACE = " \t\n\r"; /** Начало комментария — только в позиции, где ожидается имя переменной. */ const COMMENTS = "#;"; /** * SHELL_NEED_ESCAPE из systemd: внутри двойных кавычек обратный слеш перед этими * символами снимается, перед любым другим — сохраняется. */ const SHELL_NEED_ESCAPE = '"\\`$'; type State = | "PRE_KEY" | "KEY" | "PRE_VALUE" | "VALUE" | "VALUE_ESCAPE" | "SINGLE_QUOTE_VALUE" | "DOUBLE_QUOTE_VALUE" | "DOUBLE_QUOTE_VALUE_ESCAPE" | "COMMENT" | "COMMENT_ESCAPE"; /** * Разбирает содержимое env-файла ровно так, как это делает systemd. * * Строка без `=` — ОШИБКА, а не пропуск. systemd такую строку молча * отбрасывает, и здесь это единственное намеренное расхождение: молчаливая * потеря строки из /etc/hy2xs/hy2xs.env означала бы установку с настройкой, * которую оператор задал, а продукт не увидел. Расхождение в сторону отказа * безопасно — оно останавливает установку там, где её можно починить. */ export function parseEnvFile(content: string): Record { const result: Record = {}; let state: State = "PRE_KEY"; let key = ""; let value = ""; let lastKeyWhitespace = -1; let lastValueWhitespace = -1; let line = 1; const flush = (stripValueWhitespace: boolean): void => { const name = lastKeyWhitespace < 0 ? key : key.slice(0, lastKeyWhitespace); const raw = stripValueWhitespace && lastValueWhitespace >= 0 ? value.slice(0, lastValueWhitespace) : value; if (name !== "") { result[name] = raw; } key = ""; value = ""; lastKeyWhitespace = -1; lastValueWhitespace = -1; }; for (const c of content) { switch (state) { case "PRE_KEY": if (COMMENTS.includes(c)) { state = "COMMENT"; } else if (!WHITESPACE.includes(c)) { state = "KEY"; lastKeyWhitespace = -1; key += c; } break; case "KEY": if (NEWLINE.includes(c)) { // Имя без `=`. systemd молча отбрасывает такую строку; мы называем её. throw new Error(`invalid env line ${line}: ${key.trim()}`); } else if (c === "=") { state = "PRE_VALUE"; lastValueWhitespace = -1; } else { if (!WHITESPACE.includes(c)) { lastKeyWhitespace = -1; } else if (lastKeyWhitespace < 0) { lastKeyWhitespace = key.length; } key += c; } break; case "PRE_VALUE": if (NEWLINE.includes(c)) { state = "PRE_KEY"; line += 1; flush(false); } else if (c === "'") { state = "SINGLE_QUOTE_VALUE"; } else if (c === '"') { state = "DOUBLE_QUOTE_VALUE"; } else if (c === "\\") { state = "VALUE_ESCAPE"; } else if (!WHITESPACE.includes(c)) { state = "VALUE"; value += c; } break; case "VALUE": if (NEWLINE.includes(c)) { state = "PRE_KEY"; line += 1; flush(true); } else if (c === "\\") { state = "VALUE_ESCAPE"; lastValueWhitespace = -1; } else { if (!WHITESPACE.includes(c)) { lastValueWhitespace = -1; } else if (lastValueWhitespace < 0) { lastValueWhitespace = value.length; } value += c; } break; case "VALUE_ESCAPE": state = "VALUE"; // Экранированный перевод строки — склейка строк, и он съедается целиком. if (!NEWLINE.includes(c)) { value += c; } else { line += 1; } break; case "SINGLE_QUOTE_VALUE": // Escape внутри одинарных кавычек НЕТ: всё до закрывающей кавычки // приезжает как есть. Это отличие от sh, и именно поэтому кодирование // ниже использует двойные кавычки — в одинарных нельзя записать сам // апостроф. if (c === "'") { state = "PRE_VALUE"; } else { if (NEWLINE.includes(c)) { line += 1; } value += c; } break; case "DOUBLE_QUOTE_VALUE": if (c === '"') { state = "PRE_VALUE"; } else if (c === "\\") { state = "DOUBLE_QUOTE_VALUE_ESCAPE"; } else { if (NEWLINE.includes(c)) { line += 1; } value += c; } break; case "DOUBLE_QUOTE_VALUE_ESCAPE": state = "DOUBLE_QUOTE_VALUE"; if (SHELL_NEED_ESCAPE.includes(c)) { value += c; } else if (!NEWLINE.includes(c)) { // Обратный слеш СОХРАНЯЕТСЯ вместе с символом — «как делает // настоящий shell», по формулировке самого systemd. value += "\\" + c; } else { line += 1; } break; case "COMMENT": if (c === "\\") { state = "COMMENT_ESCAPE"; } else if (NEWLINE.includes(c)) { state = "PRE_KEY"; line += 1; } break; case "COMMENT_ESCAPE": state = "COMMENT"; if (NEWLINE.includes(c)) { line += 1; } break; } } // Хвост без перевода строки на конце файла. switch (state) { case "KEY": throw new Error(`invalid env line ${line}: ${key.trim()}`); case "PRE_VALUE": flush(false); break; case "VALUE": flush(true); break; case "VALUE_ESCAPE": case "SINGLE_QUOTE_VALUE": case "DOUBLE_QUOTE_VALUE": case "DOUBLE_QUOTE_VALUE_ESCAPE": // Незакрытая кавычка — испорченный файл, а не значение до конца файла. // systemd в этом месте отдаёт то, что успел накопить; для конфигурации, // от которой зависит доступ в панель, «что успели накопить» — не ответ. throw new Error(`unterminated env value for ${key.trim()}`); default: break; } return result; } /** * Значения, которые можно записать без кавычек. * * Набор намеренно узкий и не выведен из правил systemd: цель — чтобы уже * существующие строки файла (пути, порты, домены, `50 mbps`, base64url-секреты) * остались побайтово прежними, а всё хоть сколько-нибудь необычное уезжало в * кавычки. Одиночные пробелы ВНУТРИ значения разрешены, по краям — нет: именно * краевые systemd и срезает. */ const UNQUOTED_SAFE_VALUE = /^[A-Za-z0-9_\-.\/:@,=+%]+(?: [A-Za-z0-9_\-.\/:@,=+%]+)*$/; /** * Проверяет, что значение вообще представимо в этом формате. * * Управляющих символов формат не несёт: перевод строки — граница записи, а не * данные. Отказ здесь громкий намеренно — молчаливая потеря части секрета * означала бы установку, после которой невозможно войти, и причину, которой * негде увидеться. */ export function assertEnvTransportable(name: string, value: string): string { for (const character of value) { const code = character.codePointAt(0) ?? 0; if (code < 0x20 || code === 0x7f) { throw new Error( `${name} contains a control character (U+${code.toString(16).toUpperCase().padStart(4, "0")}): ` + `формат KEY=VALUE, который читают systemd и оркестратор, управляющих символов не несёт` ); } } return value; } /** * Собирает строку `KEY=VALUE`, которую systemd разберёт обратно побайтово. * * Кавычки ставятся только когда они нужны, и это не косметика: пока запись * остаётся прежней для обычных значений, релизные гейты и инструкции оператора, * ищущие строку `grep '^HY2XS_UI_PORT=8080$'`, продолжают работать, а изменение * формата видно ровно там, где оно что-то чинит. */ export function formatEnvAssignment(key: string, value: string): string { assertEnvTransportable(key, value); if (value === "" || UNQUOTED_SAFE_VALUE.test(value)) { return `${key}=${value}`; } // Экранируются ТОЛЬКО `\` и `"`. Оба входят в SHELL_NEED_ESCAPE, поэтому // разворачиваются обратно одинаково во всех изданиях systemd. Backtick и `$` // внутри двойных кавычек — обычные символы: подстановки в env-файле нет. const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); return `${key}="${escaped}"`; } /** Готовый файл из пар, каждая — через formatEnvAssignment. */ export function renderEnvFile(entries: readonly (readonly [string, string])[]): string { return `${entries.map(([key, value]) => formatEnvAssignment(key, value)).join("\n")}\n`; }