fix(env): контракт был шире домена, который принимает systemd

Разбор предыдущего прохода со сверкой по исходникам 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>
This commit is contained in:
2026-09-06 23:38:57 +05:00
parent 65042ee335
commit ab788725cf
16 changed files with 860 additions and 88 deletions
+80
View File
@@ -126,6 +126,15 @@ Debian 13. Тема: **разница между «поднялось» и «р
переживающим произвольное значение, хотя его читает не только оркестратор, но и
systemd.
Шестнадцатый проход — разбор пятнадцатого, со сверкой по исходникам systemd
v257.13, то есть той самой линии, что стоит на Debian 13. Тема та же и на слой
глубже: **контракт, объявленный шире, чем его принимает чужая сторона**.
Пятнадцатый проход сделал транспорт lossless для значений, которые systemd
принимает, но не спросил, какие значения он принимает вообще. Вместе с этим
закрыты рецепт восстановления доступа, ставший опасным ровно оттого, что файл
научился нести произвольные значения, и порядок, при котором детерминированная
ошибка конфигурации обнаруживалась уже после мутаций хоста.
### Исправлено — вход в панель
- **`POST /api/auth/login` паниковал до проверки учётных данных.** На
@@ -191,6 +200,77 @@ systemd.
ставится в `service.Login` сразу после успешной проверки пароля; отказ записи
вход не отменяет, но попадает в журнал.
### Исправлено — домен значений файла окружения
- **Контракт не знал, какие значения systemd вообще принимает.** Перед тем как
принять пару, 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`.
Пароль `abcde` + `U+FDD0` — шесть символов, восемь байт, ни одного
управляющего — проходил панель, оркестратор, DTO и хеширование, записывался в
`hy2xs.env`, и после этого админка не поднималась: тот же класс дефекта, ради
уничтожения которого контракт и существует, только слоем ниже. Введён
`IsEnvTransportableText` (Go) / `isEnvTransportable` (TS), повторяющий
множество systemd точно — не шире и не уже.
- **Одиночный суррогат подменял бы секрет молча.** Строка JavaScript вправе
содержать непарный суррогат, а `TextEncoder` заменяет его на `U+FFFD`. Без
отдельной проверки в файл уехал бы ДРУГОЙ пароль, а не отказ. Панель и
оркестратор отвергают такие значения явно.
- **Проверка транспорта приписывала формату чужие запреты.** Она отвергала C0 и
DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри
двойных кавычек перевод строки накапливается как обычный байт и переживает
round-trip. Именно эта подмена и позволила проверке не знать про
noncharacters. Домен транспорта и политика продукта теперь разделены и
проверяются отдельными тестами.
- **Политика была шире кода ровно на C1.** Документация обещала «без
управляющих символов», а проверялись только `U+0000..U+001F` и `U+007F`.
Запрещена категория `Cc` целиком, плюс `U+FEFF` — последний отдельным
решением продукта, а не форматом: `0xFEFF & 0xFFFE` это `0xFEFE`, и systemd
такое значение принимает (комментарий `/* BOM */` в его исходнике относится к
`U+xFFFE` и является неточностью самого systemd).
- **Порт автомата расходился с upstream на `\<CR>`.** В состоянии
`DOUBLE_QUOTE_VALUE_ESCAPE` systemd пишет `c != '\n'`, а не проверку на любой
перевод строки (в `VALUE_ESCAPE` — наоборот, `strchr(NEWLINE, c)`). Порт
съедал и `\<LF>`, и `\<CR>`, то есть прочитал бы такое значение иначе, чем
тот, для кого файл в конечном счёте написан.
- **Комментарий обещал одно намеренное расхождение с systemd, а их два.** Кроме
строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла, тогда
как systemd принимает накопленное. Оба расхождения fail-closed и теперь
названы оба.
### Исправлено — рецепт восстановления выполнял env-файл как код
- **`set -a; . /etc/hy2xs/hy2xs.env` в инструкции «Забыт пароль
администратора».** Строка стала опасной ровно тогда, когда файл научился нести
произвольные значения. Для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(…)"`
буквальное значение: подстановок в `EnvironmentFile=` нет вовсе. Но `.`
обрабатывает файл bash, а bash внутри двойных кавычек выполняет подстановку
команд — от root, прямо в рецепте восстановления доступа. Причём соседний
раздел той же страницы уже правильно запрещал `source`/`eval` для
`bootstrap-admin.secret`: документ запрещал действие и тут же его предлагал.
Рецепт читает нужные значения как ДАННЫЕ; поставлен гейт приёмки, запрещающий
возврат `source`/`.`/`eval` над этими файлами в командах документации и в
скриптах.
### Исправлено — отказ приходил после мутаций хоста
- **Проверка транспорта жила только внутри `renderRuntimeEnv`.** Она срабатывала
на шаге «write runtime env» — уже после bootstrap оркестратора, установки
пакетов и раскладки файловой системы, — а read-only `preflight-install`
говорил PASS: он зовёт `parseRuntimeEnv` и ничего не рендерит.
Детерминированно известная ошибка конфигурации роняла операцию, оставив за
собой изменённый хост, что прямо противоречит контракту PHASE 0.
`validateRuntimeEnvTransport` вызывается теперь из `parseRuntimeEnv` и
проходит по ВСЕМ парам `runtimeEnvEntries`: ограничение принадлежит формату, а
не полю пароля, и `HY2XS_ADMIN_CON_PASS` сломал бы загрузку юнита так же.
### Исправлено — контракт пароля администратора
- **Граница пароля не совпадала с границей bcrypt.** Предел был объявлен в 64