ab788725cf
Разбор предыдущего прохода со сверкой по исходникам systemd v257.13 — той самой линии, что стоит на Debian 13. Тема та же и слоем глубже: контракт, объявленный шире, чем его принимает чужая сторона. Прошлый проход сделал транспорт lossless для значений, которые systemd принимает, но не спросил, какие значения он принимает вообще. 1. Домен значений файла окружения Перед тем как принять пару, systemd прогоняет ключ и значение через utf8_is_valid (src/basic/env-file.c, check_utf8ness_and_warn), и отказ там возвращает -EINVAL — то есть НЕзагруженный EnvironmentFile= и юнит, который не стартует, а не предупреждение. unichar_is_valid (src/basic/utf8.c) отвергает суррогаты, U+FDD0..U+FDEF и все code points вида *FFFE/*FFFF, а сам utf8_is_valid — встроенный NUL и невалидный UTF-8. Пароль "abcde" + U+FDD0 — шесть символов, восемь байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование, записывался в hy2xs.env, и после этого админка не поднималась. Тот же класс дефекта, ради уничтожения которого контракт и существует, только слоем ниже. Введён IsEnvTransportableText (Go) / isEnvTransportable (TS), повторяющий множество systemd точно — не шире и не уже. Отдельно отвергаются одиночные суррогаты: строка JavaScript вправе их содержать, а TextEncoder молча заменяет непарный суррогат на U+FFFD, то есть без проверки в файл уехал бы ДРУГОЙ секрет, а не отказ. Заодно разделены домен транспорта и политика продукта. Проверка отвергала C0 и DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри двойных кавычек перевод строки накапливается как обычный байт и переживает round-trip. Именно эта подмена и позволила проверке не знать про noncharacters. Политика HY2XS теперь запрещает категорию Cc целиком (была шире кода ровно на C1) плюс U+FEFF — последний отдельным решением продукта, а не форматом: 0xFEFF & 0xFFFE это 0xFEFE, и systemd такое значение принимает. 2. Рецепт восстановления выполнял env-файл как код В docs/operations/12, раздел «Забыт пароль администратора», стояло `set -a; . /etc/hy2xs/hy2xs.env; set +a`. Строка стала опасной ровно тогда, когда файл научился нести произвольные значения. Для systemd HY2XS_ADMIN_INITIAL_PASSWORD="$(...)" — буквальное значение: подстановок в EnvironmentFile= нет вовсе. Но `.` обрабатывает файл bash, а bash внутри двойных кавычек выполняет подстановку команд — от root, прямо в рецепте восстановления доступа. Соседний раздел той же страницы при этом уже правильно запрещал source/eval для bootstrap-admin.secret: документ запрещал действие и тут же его предлагал. Рецепт читает нужные значения как ДАННЫЕ. Поставлен гейт приёмки, запрещающий возврат source/./eval над этими файлами в командах документации и в скриптах; гейт смотрит только внутрь ```-блоков, чтобы объяснение, называющее убранную конструкцию по имени, его не роняло. 3. Отказ приходил после мутаций хоста Проверка транспорта жила только внутри renderRuntimeEnv, то есть срабатывала на шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов и раскладки файловой системы, — а read-only preflight-install говорил PASS: он зовёт parseRuntimeEnv и ничего не рендерит. Детерминированно известная ошибка конфигурации роняла операцию, оставив за собой изменённый хост, что прямо противоречит контракту PHASE 0. validateRuntimeEnvTransport вызывается теперь из parseRuntimeEnv и проходит по ВСЕМ парам runtimeEnvEntries: ограничение принадлежит формату, а не полю пароля, и HY2XS_ADMIN_CON_PASS сломал бы загрузку юнита так же. 4. Точность порта автомата и его описания - в состоянии DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не проверку на любой перевод строки (в VALUE_ESCAPE — наоборот, strchr(NEWLINE, c)). Порт съедал и \<LF>, и \<CR>; - комментарий обещал одно намеренное расхождение с systemd, а их два: кроме строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла. Оба fail-closed и теперь названы оба. Тесты: граничная таблица во всех слоях дополнена значениями вне домена (U+FDD0, U+FDEF, U+FFFE, U+FFFF, U+1FFFF, U+10FFFF, невалидный UTF-8), соседями диапазонов (U+FDCF, U+FDF0, U+FFFD, U+10FFFD), C1 и U+FEFF, одиночным суррогатом. Добавлены TestEnvTransportDomainMatchesSystemd (домен не шире и не уже) и TestProductPolicyIsWiderThanTransportDomain (домен и политика различимы), а также проверки fail-closed порядка: parseRuntimeEnv отвергает непригодную конфигурацию, проверяются все значения файла, запись и проверка ходят по одному списку пар. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
302 lines
17 KiB
TypeScript
302 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("непредставимое значение — отказ записи, а не потеря части секрета", () => {
|
||
// Отвергается то, что НЕ ЗАГРУЗИТ systemd, а не то, что нам не нравится.
|
||
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(/systemd refuses to load/);
|
||
expect(() =>
|
||
formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`)
|
||
).toThrow(/systemd refuses to load/);
|
||
}
|
||
});
|
||
|
||
// Одиночный суррогат — единственный случай, где без проверки не было бы даже
|
||
// отказа: `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(
|
||
/systemd refuses to load/
|
||
);
|
||
});
|
||
|
||
// Домен транспорта — ЧУЖОЕ множество, и он не шире и не уже множества 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);
|
||
}
|
||
|
||
// U+FEFF формат тоже несёт: 0xFEFF & 0xFFFE === 0xFEFE, и unichar_is_valid
|
||
// его принимает. Комментарий `/* BOM */` в исходнике systemd относится к
|
||
// U+xFFFE и является его собственной неточностью.
|
||
expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(true);
|
||
});
|
||
|
||
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
|
||
// множество 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();
|
||
});
|
||
|
||
// Ограничение принадлежит ФОРМАТУ, а не полю пароля: любой параметр сломал бы
|
||
// загрузку юнита тем же способом. 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(
|
||
/systemd refuses to load/
|
||
);
|
||
}
|
||
});
|
||
|
||
// Список пар — один на запись и на проверку. Пока он существовал только
|
||
// внутри рендера, единственным способом узнать, что конфигурация не
|
||
// запишется, было её записать.
|
||
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();
|
||
});
|
||
});
|