Разбор предыдущего прохода со сверкой по исходникам 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>
74 KiB
A. Тесты слоя сборки
Часть набора проверок HY2XS. Карта всех частей — docs/testing/README.md.
A. Builder layer tests
Проверяем
- builder запускается на Debian 13 amd64 build host
- итоговый пакет собирается без target-side шагов
- bundled HY2XS admin реально входит в пакет
- package metadata / build id присутствуют
- compiled Bun/TypeScript orchestrator artifact присутствует
- в пакет не попадает build-мусор
- builder сам доставляет отсутствующие build-зависимости
- builder проверяет версии Go/Bun/Node.js/pnpm
- builder пишет версии toolchain в metadata
- builder прогоняет
bun testиgo testдо упаковки
A1. Latest-stable resolver
Фикстуры и ожидаемое поведение (orchestrator/test/hysteria-release.test.ts):
| Сценарий | Ожидание |
|---|---|
stable app/v2.12.2 |
выбирается |
prerelease app/v2.13.0 |
игнорируется |
draft app/v2.14.0 |
игнорируется |
чужое семейство тегов (core/, docs/) |
игнорируется |
тег без префикса app/ |
игнорируется |
app/v2.9.10 против app/v2.9.2 |
выбирается 2.9.10 (числовое сравнение, не строковое) |
отсутствует hysteria-linux-amd64 |
ошибка |
дублирующийся hysteria-linux-amd64 |
ошибка, а не случайный выбор |
| non-https URL артефакта | ошибка |
| невалидный semver в теге | игнорируется |
| пустой список релизов | понятная ошибка |
| несовпадение SHA-256 | сборка падает |
| сетевая ошибка / rate limit | понятная ошибка с подсказкой про GITHUB_TOKEN и HYSTERIA_CHANNEL=pinned |
Отдельно проверяется, что latest stable — это именно stable, а не максимальная строка или самый свежий тег.
A2. Release rollover
Ключевой acceptance-критерий модели «latest на сборке»:
Сегодня: latest = 2.12.2 → пакет A закрепляет 2.12.2
Завтра: latest = 2.12.3 → пакет B закрепляет 2.12.3
Повторная установка пакета A всё равно ставит 2.12.2
Проверяется на двух уровнях:
- резолвер даёт разный результат на разных снимках upstream (
orchestrator/test/release-rollover.test.ts); - install-time код не импортирует резолвер, не обращается к
api.github.comи не использует movinglatest— это утверждение проверяется тестом и acceptance-шагом сборки.
A3. Compatibility gate
- скачанный артефакт проходит проверку SHA-256;
hysteria versionсовпадает с разрешённой версией;- реальный бинарник принимает канонический конфиг HY2XS для Gecko;
- то же для Salamander;
- при несовместимости падает сборка с сообщением
BUILD FAILED: unsupported Hysteria stable vX.Y.Z, а не установка у пользователя.
A4. Конфигурационный контракт (unit)
Таблица orchestrator/test/env.test.ts:
| Вход | Ожидание |
|---|---|
| значение не задано | gecko |
gecko |
принято |
salamander |
принято |
| неизвестный тип | отклонено |
Gecko (регистр) |
отклонено |
gecko max < min |
отклонено |
gecko max > 2048 |
отклонено |
gecko max == 2048 |
принято |
неположительный/нецелый min |
отклонено |
| пустой obfs-пароль | автогенерация, а не пустое значение в конфиге |
HY2XS_CONFIG_SCHEMA_VERSION=1 |
отклонено с указанием на чистую установку |
HY2XS_CONFIG_SCHEMA_VERSION отсутствует |
отклонено как legacy-конфигурация |
HY2XS_CONFIG_SCHEMA_VERSION= (пусто) |
отклонено как legacy-конфигурация |
HY2XS_CONFIG_SCHEMA_VERSION=2 |
принято |
Отсутствие маркера схемы отклоняется намеренно: до v1 этого поля не существовало, поэтому именно пустое значение — самый вероятный признак конфигурации 0.x. Любой fallback здесь молча превращал бы legacy-конфиг в якобы валидный.
Отдельно — round-trip parse(render(config)) == config. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации».
A4a. Формат env-файла совпадает с systemd
orchestrator/test/env-file.test.ts. У hy2xs.env два читателя, и один из них
не наш: файл объявлен EnvironmentFile= в юните hy2xs-admin. Поэтому
проверяется не «работает на наших данных», а совпадение с правилами systemd
(src/basic/env-file.c) на тех значениях, где прежний разбор расходился:
| Вход | Ожидание |
|---|---|
KEY= value |
value — незакавыченное значение теряет краевые пробелы, как и у systemd |
KEY=" value " |
value — в кавычках сохраняются |
KEY="a\"b" |
a"b |
KEY="a\\b" |
a\b |
KEY="a\nb" |
a\nb — n не входит в SHELL_NEED_ESCAPE, слеш сохраняется |
KEY="$HOME" |
$HOME — подстановок в env-файле нет |
KEY='a\b' |
a\b — в одинарных кавычках escape нет вовсе |
строка без = |
отказ (намеренное расхождение: systemd её отбрасывает молча) |
| незакрытая кавычка на EOF | отказ (второе намеренное расхождение: systemd принял бы накопленное) |
И обратимость: любое значение — с краевыми пробелами, кавычками, обратными
слешами, $, `, #, эмодзи — переживает render -> parse побайтово, а
обычные значения (8080, /etc/hysteria/server.crt, 50 mbps) остаются без
кавычек, чтобы релизные гейты и инструкции оператора продолжали работать.
Отдельно проверяется домен значений — чужое множество, а не наша политика:
| Вход | Ожидание |
|---|---|
NUL, U+FDD0, U+FDEF, U+FFFE, U+FFFF, U+1FFFF, U+10FFFF |
отказ ЗАПИСИ: utf8_is_valid отвергает такое значение, и EnvironmentFile не загрузится |
одиночный суррогат U+D800 |
отказ — иначе TextEncoder молча заменил бы его на U+FFFD, то есть подменил бы секрет |
U+FDCF, U+FDF0, U+FFFD, U+10FFFD, U+1F600 |
принимаются: правило описывает диапазон, а не окрестность |
\n, \r, \t, U+007F, U+0085, U+FEFF |
формат их НЕСЁТ и round-trip сохраняет; запрещает их контракт учётных данных, а не транспорт |
A4b. Непригодная конфигурация отвергается до первой мутации
Там же. validateRuntimeEnvTransport вызывается из parseRuntimeEnv, поэтому
preflight-install и install видят отказ одинаково — до bootstrap
оркестратора, apt и раскладки файловой системы. Проверяется:
parseRuntimeEnvотвергает значение вне домена systemd;- проверяется КАЖДОЕ значение файла, а не только пароль администратора
(
HY2XS_ADMIN_CON_PASS,HY2XS_HYSTERIA_BANDWIDTH_UP,HY2XS_ACME_EMAIL); - запись и проверка ходят по одному списку
runtimeEnvEntries; - всё, что
parseRuntimeEnvпринял, записывается без отказа.
Рендер конфига (orchestrator/test/render-config.test.ts):
- Gecko рендерит только gecko-подблок;
- Salamander рендерит только salamander-подблок;
- в конфиге никогда нет двух подтипов obfs одновременно;
- шаблон не содержит захардкоженного типа обфускации;
- пароль с пробелами и спецсимволами экранируется;
- YAML-инъекция через пароль отклоняется даже в обход env-валидации.
A5. Граница установки и поколение (unit)
orchestrator/test/clean-host.test.ts:
- чистый хост проходит;
- каждый маркер по отдельности останавливает установку;
- список покрывает состояние, юниты, бинарник Hysteria и наследие 0.x;
- пути из конфигурации (
HY2XS_INSTALL_DIR,HY2XS_DATA_DIR,HY2XS_LOG_DIR) попадают в список, а не только значения по умолчанию; - всё, что удаляет
purge-v0.sh, покрыто маркерами clean-host: два списка описывают одну границу и не имеют права разъезжаться; - bootstrap-пути (
/usr/local/lib/hy2xs,/usr/local/lib/hy2xs/package,/usr/local/bin/hy2xs-orchestrator) остаются маркерами без исключений: их создаёт оркестратор уже после проверки чистоты хоста, поэтому «мягкой» версии списка для PHASE 1 больше не существует; - эти пути берутся из
config/profile.ts, а не из копий строк: шаг, который их создаёт, и контракт, который на них отказывает, обязаны читать одно значение; - сообщение перечисляет найденные маркеры и говорит, что хост не изменён.
orchestrator/test/install-boundary.test.ts:
- под read-only guard недоступны
writeText,writeTextAtomic,ensureDirи всеrunMutating*-раннеры; - read-only раннеры под guard'ом продолжают работать: разделение API — это не запрет наблюдения, а запрет мутации;
- классификация отказа зависит от ownership-флагов и фазы, а не от текста ошибки;
fatal_pre_applyнедостижим ни при одном взведённом флаге, включаяstateTouched: записанныйinstall-state.jsonуже делает хост изменённым;- частично выполненная запись маркера (отказ на
chownпосле успешногоwrite) тоже даёт post-apply: флаг взводится до записи, а не после неё; - начатая (не обязательно завершённая) установка пакетов уже даёт
fatal_post_apply— регрессия на сценарий «PHASE 0 прошла, apt-get упал, установщик заявил, что ничего не тронул»; - начатый bootstrap (
bootstrapTouched) тоже даётfatal_post_apply: раскладку выполняет оркестратор, и она учитывается наравне с остальными шагами.
orchestrator/test/install-sequence.test.ts — порядок фаз, который иначе
проверяется только на живом сервере:
preflight()в режиме install отказывается работать без явногоcheckCleanHost, и отказ наступает до любой работы с системой;- clean-host запрашивается ровно один раз за операцию и до первой записи
install-state — регрессия на сценарий, где повторный preflight после
installDepsопознавал собственныйinstall-state.jsonкак маркер чужой установки и валил каждую чистую установку; - проход capabilities явно отказывается от clean-host;
bootstrapTouchedвзводится передbootstrapRuntime, а сам bootstrap идёт доinstallDeps;- дальнейшая установка работает от установленного runtime-пакета;
diagnosticsCollectобёрнута вtry/catch, иcatchстоит до отката: диагностика — best effort, откат — обязателен;- в
package/install.shне осталось ни одной мутирующей команды, и он передаёт управление оркестратору черезexec; - классификация отказа
reconfigure/repairидёт по ownership-флагам, а не по регулярному выражению над текстом ошибки.
orchestrator/test/install-state.test.ts:
- маркер текущего поколения принимается;
- маркер без полей поколения отклоняется, несмотря на
installed: true; - чужой
product,release_lineилиconfig_schema_versionотклоняются; - записываемый маркер всегда несёт идентификацию поколения;
- незавершённая установка подсказывает
repair --allow-partial-state.
A5a. Обязательный откат (unit)
orchestrator/test/rollback-mandatory.test.ts — поведение механизма проверяется
настоящим внедрением отказа в стадию, проводка команд к нему — разбором
исходника (поднять systemd и nftables в этой среде нельзя):
- при отказе первой стадии отката выполняются все последующие;
- отказавшие стадии перечисляются по именам и в порядке объявления;
- откат не бросает даже при отказе всех стадий: наружу обязана уйти исходная ошибка операции, а не проблема внутри восстановления;
- не-
Errorпричина (брошенная строка) не роняет откат; persistFailureStateне пробрасывает отказ записи наружу — это и был P0: падение записи маркера отменяло откат целиком;- в обработчике ошибки
installиreconfigureне осталось незащищённой записи состояния (advanceInstallState/markPhaseголымawait); - откат в обеих командах идёт через
runRollbackStages, а не цепочкойawait; - внутри
rollbackCurrentStateни одна команда не обрывает следующие: отказsystemctl daemon-reloadотменял перезапуск сервисов строкой ниже, то есть восстановленные unit-файлы так и не применялись.
A5c. Целостность резервных копий (unit)
orchestrator/test/backup-integrity.test.ts:
- копия каждой операции адресуется своим каталогом, разные
op-idне пересекаются; - разные пути дают разные имена файлов копии, и имя не выходит за пределы каталога;
- отсутствовавший файл записан явно (
present: false), а не выведен из неудачиcp; - манифест переживает сериализацию без потерь;
- разбор строгий: манифест чужой операции, неизвестная версия, битый JSON, запись без пути, без признака существования или без имени копии — отклоняются. «Поле не разобралось, будем считать, что файла не было» означало бы удаление существующего файла при откате;
- копирование в
reconfigureи вfirewallне глушит ошибки, факт создания копии проверяется, а копия снимается до первой мутации; - маркер готовности firewall (
prepared) ставится после проверенных копий; - восстановление firewall не глушит ошибки
cp/nft, а резервные копии удаляются только после подтверждённого успеха — иначе сохраняются вместе с сообщениемmanual recovery data preserved at ….
A5d. Порядок фиксации успеха (unit)
orchestrator/test/commit-ordering.test.ts:
- снятие таймера автоотката и удаление резервных копий — разные операции
(
disarmFirewallRollback/cleanupFirewallRollback), объединённаяcancelFirewallRollbackне вернулась ни в один вызов; disarmне удаляет копии;- порядок в
installиreconfigureодинаков:disarm→ долговечная записьinstalled→cleanup; - успешный smoke фиксируется отдельной фазой до снятия таймера;
- уборка после точки фиксации выполняется best-effort.
A5e. Транзакционность rollback guard (unit)
orchestrator/test/firewall-guard.test.ts:
- маркер
auto-rollback-firedсоздаётся rollback-скриптом первым действием — до проверкиpreparedи до первой попытки восстановления, в том числе когда восстанавливать нечего. Без этого «guard сработал» недоказуемо: транзиентные юниты systemd после выполнения исчезают, иsystemctl stopдля них неотличим от успешного снятия взведённого таймера; - скрипт не маскирует ошибки (
|| true,2>/dev/null), не используетset -eи возвращает накопленныйrc: каждый сообщённый отказ поднимает код возврата, поэтому частичное восстановление уходит вfailed, а не в молчаливый0; rcобъявляется до создания маркера, а ранний выход возвращает его, а не жёсткий0. Инвариант фиксации верен только при условии «guard способен записать маркер»: покаrc=0стояло после, отказ записи (заполненный tmpfs/run, read-only ФС) не влиял ни на что — скрипт восстанавливал прежний firewall, завершался нулём, и операция фиксировала успех после реально сработавшего отката. Теперь у факта два канала: маркер и отказ юнита;- маркер создаётся
touch, а не: >file: двоеточие — special builtin POSIX, и ошибка перенаправления на нём обязана завершить неинтерактивный shell целиком, то есть в dash скрипт умер бы до восстановления firewall; - поведенчески проверяется ранний путь скрипта — он заканчивается до первой команды восстановления и потому безопасен для запуска: при доступном каталоге маркер создаётся и выход нулевой, при недоступном — выход ненулевой;
- скрипт не трогает
nftables.service: у негоExecStop=nft flush ruleset, и остановка сервиса стёрла бы только что восстановленные правила; - скрипт разбирается настоящим shell-парсером. Парсер принимается только после двусторонней проверки — он обязан принять заведомо корректный скрипт и отвергнуть заведомо сломанный, иначе тест ничего не проверяет;
- небезопасный ключ операции отвергается: он служит именем каталога, именем systemd-юнита и подставляется в текст скрипта;
- снятие guard проверяет маркер с обеих сторон остановки, подтверждается
ActiveStateобоих юнитов, и на пути фиксации успеха допускает единственное состояние —inactive; на пути восстановленияfailedтоже допустим; - сработавший guard опознаётся по типу ошибки: ошибка с тем же текстом, но другого типа классифицируется по владению, как и прежде;
- эффективный firewall сверяется семантически (фрагмент, entrypoint,
загруженная таблица), и проверка выполняется read-only раннерами — та же
проверка идёт в
doctor; - состояние
nftables.serviceснимается до первой мутации и восстанавливается стадиями, идущими до применения ruleset; - candidate-файлы убираются после успеха и best-effort при откате;
- ключ операции считается одной функцией: install писал в маркер сырой
ISO-timestamp, и путь
/run/hy2xs/rollback/<op_id>из runbook не существовал; - команда взведения guard проверяется как значение, а не грепом по
исходнику:
buildArmGuardArgvвозвращает готовый argv, и тест сверяет его целиком — имя юнита с явным суффиксом,--on-active=45s,--timer-property=AccuracySec=1s,--timer-property=RemainAfterElapse=no. Небезопасный ключ операции отвергается до запуска: shell в этой команде не участвует, поэтому единственная защита — отказ; - барьер покоя проверяется поведенчески, с подставляемым наблюдателем
systemd (
SystemdUnitProbe), без systemd и без Linux:- взведённый таймер (
active/waiting) и идущий прямо сейчас откат (activating) запрещают операцию сPendingRecoveryError; - отказ
list-unitsи отказshowна любом отдельном юните даютGuardStateUnknownError: отсутствие ответа systemd — отсутствие наблюдения, а не наблюдение покоя. Прежний тест закреплял обратное (return [];в тексте функции) и потому пережил инверсию смысла: строка была на месте, а решение стало неверным; - оба отказа имеют общего предка
OperationBarrierError; - покой — ровно
inactiveиfailed;maintenance,refreshingи любое незнакомое состояние блокируют операцию, потому что политика перечисляет безопасные состояния, а не опасные; *.timerвSubState=elapsedсчитается покоем:TIMER_ELAPSEDв systemd отображается вUNIT_ACTIVE, и без этой ветки отработавший таймер блокировал быrepairнавсегда. У сервиса тот жеSubStateничего не значит;- разбор вывода
list-unitsнаходит имя юнита и когда первой колонкой идёт маркер●(состояниеfailed) — иначе терялся бы именно аварийно сработавший guard.
- взведённый таймер (
A5f. Взаимное исключение операций (unit)
orchestrator/test/operation-lock.test.ts:
- второй захват при живом держателе отказывает, и отказ называет держателя — команду, PID и время начала;
- замок снимается в
finallyи после отказа операции: иначе первая же неудачная установка заблокировала бы сервер до перезагрузки; - замок мёртвого держателя переиспользуется, временный файл переиспользования не остаётся на диске;
- непонятое содержимое замка не снимается автоматически: оно не доказывает отсутствие операции, и сомнение трактуется в пользу отказа;
readLockHolderотличает «замка нет» от «замок нечитаем»;- захват под read-only guard отказывает, наблюдение — разрешено. Замок берётся до включения guard, и проверка существует, чтобы перенос захвата внутрь читающей фазы отказал громко, а не записал файл молча;
- барьер покоя вызывается дважды — до захвата и уже под замком, — а отказ второй проверки снимает замок за собой. Замок сам по себе гарантии не даёт: он защищает production paths, пока жив держатель, а rollback guard переживает свой процесс;
- политика CLI закреплена структурно:
install/reconfigure/repair/doctorвызываются только под замком,status/diagnosticsего не берут, но сообщают об идущей операции, аpreflight-installотказывает до собственных проверок.
A5b. Долговечная запись маркера (unit)
orchestrator/test/atomic-write.test.ts:
-
содержимое заменяется целиком, а не дописывается поверх прежнего;
-
при отказе записи по целевому пути остаётся прежний полный документ;
-
временный файл не выживает ни при успехе, ни при отказе подстановки;
-
права выставляются точно, независимо от umask (
0600,0644); -
ensureDirприводит права существующего каталога к объявленным:mkdirэтого не делает, поэтому «создать» и «права такие, как объявлено» — два разных действия; -
и запись, и создание каталога проходят через read-only guard;
-
persistInstallStateпод guard'ом отказывает: единственный писатель маркера обязан идти через guarded-примитивы, иначе PHASE 0 смогла бы создать/var/lib/hy2xs, и «read-only» перестало бы быть правдой ровно для того файла, по которому clean-host принимает решение. -
ensureDirсообщает, был ли каталог фактически создан: родитель синхронизируется только при создании, иначе долговечность записиhy2xsв/var/libосталась бы необеспеченной, и после потери питания мог исчезнуть весь каталог вместе с маркером.
Наличие самих fsync проверяется приёмкой сборки по исходнику: из
пользовательского процесса их не наблюдать, а без них rename() даёт
атомарность видимости без долговечности.
A6. Редактирование секретов (unit)
orchestrator/test/redaction.test.ts:
- machine token не переживает редакцию серверного конфига — регрессия на
построчное правило
auth:, оставлявшее нетронутымauth.http.url; - obfs-пароль не переживает редакцию;
- результат остаётся валидным YAML;
- несекретные поля сохраняются: диагностика должна оставаться полезной;
- неизвестное поле с секретоподобным именем вырезается;
acme.dns.configвырезается целиком;- невалидный YAML не роняет редакцию и всё равно чистится;
- секрет внутри URL-значения в env вырезается, даже если имя ключа несекретное
(
HY2_AUTH_URL); - URL под произвольным именем ключа теряет встроенные учётные данные и секретные query-параметры, но сохраняет адрес; то же для URL внутри списка;
redactLogTextвырезает machine token из строки journald, сохраняя host, port и path; ловит секрет и вне URL; не трогает обычные строки; сохраняет хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где редактировались env и YAML, аjournal-admin.logкопировался как есть.
A7. Machine token в журналах (unit)
apps/middleware/log_test.go — запрос
/internal/hysteria/auth?access_token=SUPER_SECRET_SENTINEL:
- sentinel не появляется в журнале ни в каком виде;
- в журнале есть
reqPath, поляreqUriнет; - имя query-параметра сохраняется (
reqQueryKeys), значение — нет; - пустой список параметров в журнал не пишется;
- то же правило действует на операторских маршрутах, а не только на машинном.
apps/service/log_sanitize_test.go — тот же санитайз на стороне админки: журнал
Hysteria покидает сервер через ExportLog, а HY2_AUTH_URL несёт
access_token, который upstream волен упомянуть в сообщении об ошибке.
A8. Инвариант публичного endpoint (unit)
orchestrator/test/network-endpoint.test.ts — проба подменяет и DNS, и список
локальных адресов, поэтому тест не зависит ни от сети, ни от интерфейсов машины
разработчика.
| Сценарий | Результат |
|---|---|
| A-запись == текущий публичный IPv4 | PASS |
| A-запись == старый IPv4 | FAIL, в тексте оба адреса |
| A-запись отсутствует | FAIL |
| A == текущий + чужой | FAIL |
| у сервера 2 публичных IP, DNS использует один | PASS |
PUBLIC_HOST — правильный IPv4-литерал |
PASS |
PUBLIC_HOST — устаревший IPv4-литерал |
FAIL |
DOMAIN совпадает, отдельный PUBLIC_HOST устарел |
FAIL |
PUBLIC_HOST совпадает, отдельный TLS-домен устарел |
FAIL |
| нет ни одного локального публичного IPv4 | FAIL |
HY2XS_PUBLIC_ENDPOINT_POLICY = strict / warn / off |
fail / warn / skip |
| отсутствие A-записи при любой политике | FAIL |
| отказ резолвера (SERVFAIL/таймаут/отказ) при любой политике | FAIL, отдельный текст |
Отдельно проверяется классификация IPv4. Список исключений приведён к IANA
Special-Purpose Address Registry: приватные, CGNAT, link-local, multicast,
reserved, benchmarking (198.18/15), 6to4-anycast и документационные
диапазоны (192.0.2/24, 198.51.100/24, 203.0.113/24) не считаются
публичным адресом сервера. Регрессия: 203.0.113.5 из RFC-примеров раньше
проходил проверку как обычный публичный адрес. Границы проверяются с обеих
сторон — 172.32.0.0, 192.0.1.1, 198.20.0.1 и 203.0.112.255 считаются
публичными.
Отказ резолвера отделён от отсутствия записи: ENODATA/ENOTFOUND/NXDOMAIN
— это «нет A-записи» и чинится в DNS-панели, всё остальное — «резолвер не
ответил» и чинится в /etc/resolv.conf. Раньше оба случая печатались как
«has no A-record», и при сломанном резолвере оператор шёл править запись,
которая была на месте. Фатальны оба: без ответа резолвера проверка не выполнена,
а не «выполнена с замечанием».
A9. Регистрация маршрутов (unit)
apps/router/router_test.go — единственное место, где ошибка проявляется
паникой при старте сервиса, а не ответом с кодом. Конфликт с
wildcard-маршрутом фронтенда или дублирующая регистрация обнаружились бы иначе
только на живом сервере.
- контур маршрутов собирается без паники;
- machine-auth зарегистрирован ровно на
constant.HysteriaMachineAuthPath; - операторский и auth API — под
constant.AdminAPIBase; - ни один маршрут не начинается со старого пространства имён;
- удалённые маршруты (включая
exportConfig/importConfigиgetConfig) не вернулись; - пространство
/api/configзакрыто: в нём ровно четыре маршрута, и любой новый обязан быть добавлен в тест осознанно; /healthzна месте.
A9a. Доступ к таблице config (unit)
apps/model/constant/config_test.go — allowlist как структура, а не как
соглашение:
- ни один внутренний ключ не читается и не записывается через API;
JWT_SECRET,PEER_SECRET_KEY,PEER_SECRET_ENCRYPTION_KEYиHYSTERIA2_TRAFFIC_STATS_SECRETпоимённо объявлены внутренними;- пользовательские настройки остаются доступными;
- множество записываемых ключей — подмножество читаемых;
- неизвестный ключ закрыт по умолчанию: забытый при denylist ключ был бы сразу публичным.
apps/controller/config_test.go — то же на уровне HTTP:
- чтение и запись каждого секрета отклоняются;
- секрет, спрятанный среди разрешённых ключей, отклоняет весь запрос;
- отказ наступает до обращения к базе (тест работает без SQLite — сам факт, что обработчик не падает, это и доказывает);
- ключи оркестратора отклоняются с указанием владельца, а не общим «нет такого
ключа»: оператор должен быть отправлен к
hy2xs-orchestrator reconfigure; - удалённые ключи (
HYSTERIA2_ENABLE,HYSTERIA2_CONFIG,HYSTERIA2_TRAFFIC_TIME,HYSTERIA2_CONFIG_REMARK) отклоняются как неизвестные — проверка идёт по строковым литералам, потому что соответствующих констант в коде уже нет и появиться они не должны.
Атомарность партии проверяется на настоящей SQLite: без базы утверждение «партия не применилась частично» бессмысленно, поскольку предметом утверждения является именно состояние базы.
- разрешённый ключ первым, запрещённый вторым → запрос отклонён, значение первого ключа в базе не изменилось;
- невалидное cron-выражение → отказ, значение в базе не изменилось;
- один ключ дважды в партии → отказ (какое из двух значений считать намерением оператора, определить нельзя);
- корректная партия → значение сохранено, расписание применено к планировщику, число его записей не выросло.
Порядок в первом тесте принципиален. Предыдущая версия ставила запрещённый ключ первым и до второго элемента не доходила, поэтому проходила и на реализации, которая проверяла и записывала настройки в одном цикле.
A9b. Планировщик (unit)
apps/service/cron_scheduler_test.go — планировщик как собственность процесса:
- четыре последовательные смены расписания не увеличивают число записей планировщика (главная регрессия: раньше каждая смена добавляла целый дублирующий набор джоб, а старое расписание продолжало работать);
- пустое выражение снимает джобу сброса, непустое возвращает её — без перезапуска процесса;
- невалидное выражение не меняет планировщик и не снимает действующую джобу;
- набор валидных и невалидных выражений проверяется тем же парсером, что и
runtime: то, что
cronумеет, обязано приниматься, остальное — отклоняться; - невалидное значение в базе не роняет старт: на панели висит
/internal/hysteria/auth, и отказ старта из-за строки расписания положил бы подключения пользователей. Фиксированные джобы поднимаются, сброс отключён, в журнале ERROR, и настройка чинится через API без перезапуска; - второй
InitCronповерх работающего отклоняется; StopCronидемпотентен и оставляет планировщик пустым.
A9c. Токены и пароли (unit)
apps/service/jwt_test.go:
- round-trip: claims, включая
token_version, переживают выписку и разбор; - токен, подписанный другим HMAC-алгоритмом тем же ключом, отклоняется
(прежний
keyfuncне смотрел наtoken.Methodвовсе); - токен без
expотклоняется, истёкший отклоняется отдельным сообщением; - токен с чужим
issuerотклоняется даже при совпадении ключа; - пустой
JWT_SECRET— отказ и на выписку, и на разбор, а не подпись ключом нулевой длины.
apps/util/encrypt_test.go:
HashPasswordвыдаёт bcrypt и солит: два хеша одного пароля различаются;- вход по несолёному SHA-224 (формат предыдущего поколения) невозможен;
- любая не-bcrypt строка в поле хеша отклоняется;
HashPasswordследует ОБЩЕМУ контракту, а не собственной проверке. Раньше здесь стоялоlen(strings.TrimSpace(password)) < 6— третья копия правила, и она расходилась с двумя остальными: значениеabcdeсчиталось корректным всеми дверями продукта и не могло быть захешировано, а верхней границы у копии не было вовсе;- всё, что контракт принял, обязано хешироваться. Проверяются граничные
значения (36 x
я= 72 байта, 18 x😀= 72 байта): именно здесь расхождение с bcrypt и проявлялось.
A9c1. Контракт учётных данных администратора (unit)
Контракт объявлен один раз в apps/credential/admin.go — в leaf-пакете, потому
что его зовут и util.HashPassword, и слой данных при создании первой учётной
записи, а service импортирует util.
apps/credential/admin_test.go — сам контракт:
-
набор символов логина закреплён ФАКТИЧЕСКИМ множеством: сужение уронит тест, а не вход администратора на живом сервере;
-
границы пароля проверяются таблицей, и она включает случаи, где границы в символах и в байтах расходятся:
значение символов байт результат 64 x a64 64 принят 36 x я36 72 принят (граница bcrypt) 37 x я37 74 отвергнут 18 x 😀18 72 принят (граница bcrypt) 19 x 😀19 76 отвергнут 64 x я64 128 отвергнут последняя строка — исходный дефект: прежний тест требовал ПРИЁМА этого значения, то есть закреплял как ожидаемое ровно то, на чём продукт ломался;
-
пробел по краям — часть пароля, шесть пробелов являются корректным паролем;
-
управляющие символы Unicode целиком, то есть
Cc:\n,\r,\t,NUL,DELи C1 (U+0085,U+009F). Раньше проверялись только C0 и DEL, а документация обещала «без управляющих символов» — то есть была шире кода; -
значения вне домена systemd (
U+FDD0,U+FDEF,U+FFFE,U+FFFF,U+1FFFF,U+10FFFF, невалидный UTF-8) отвергаются: с ними/etc/hy2xs/hy2xs.envне загрузится и юнит не стартует; -
соседи запрещённых диапазонов (
U+FDCF,U+FDF0,U+FFFD) принимаются: правило описывает множество systemd, а не окрестность подозрительных значений; -
U+FEFFотвергается политикой продукта, а не форматом:0xFEFF & 0xFFFE == 0xFEFE, и systemd такое значение принимает. Отдельный тестTestEnvTransportDomainMatchesSystemdзакрепляет, что домен транспорта не шире и не уже множества systemd, аTestProductPolicyIsWiderThanTransportDomain— что политика и домен остаются различимы.
apps/controller/validator_test.go — ПРОВОДКА, а не контракт: теги
credentialStr и adminPassword прогоняются через production-валидатор и
обязаны отвечать так же, как функции контракта, на тех же граничных значениях.
apps/controller/auth_test.go:
- ни один тег валидации ни в одном DTO не ссылается на незарегистрированное правило (обход исходников, а не проверка одного экземпляра);
- границы пароля не стоят рядом с правилом: тег умеет считать только символы, а у пароля есть ещё граница в байтах, которую тегом не выразить;
- пароль в 72 байта пускает в панель, а на символ длиннее — получает конверт
валидации с причиной
admin_password_formatна полеpass, а не системную ошибку из bcrypt; - пароль не триммится:
bootstrap-passwordиbootstrap-password— разные пароли.
apps/dao/bootstrap_admin_test.go — bootstrap-путь на настоящей SQLite:
- пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом, и вход обрезанным значением невозможен;
- пароль вне контракта роняет старт с текстом, называющим переменную и файл, а не сообщением bcrypt;
- пароль ровно в 72 байта проходит установку целиком.
apps/util/rand_test.go — отсутствие modulo bias: на выборке 200 000 символов
частоты первых восьми символов алфавита не отличаются от остальных более чем на
5%. Прежняя реализация давала здесь отношение 1.25.
A9d. Пир установщика (unit)
apps/service/peer_bootstrap_guard_test.go — защита действует во всех путях
записи, а не только в импорте:
- смена секрета и переименование
bootstrap-admin-peerотклоняются, состояние в базе не меняется; - переименование обычного пира в зарезервированное имя отклоняется;
- создание пира с зарезервированным именем отклоняется;
- отключение и изменение квоты разрешены;
- удаление разрешено: это осознанное действие оператора, и расхождения
между базой и
bootstrap-admin.secretоно не создаёт.
A9e. Жизненный цикл пира установщика (unit, настоящая SQLite)
apps/dao/bootstrap_peer_test.go — проверяется не функция, а поведение сервиса
при перезапуске: дефект, ради которого написан этот файл, проявлялся только на
ВТОРОМ запуске, поэтому каждый тест прогоняет полную последовательность
InitSqlAt дважды на одной базе.
- первый запуск создаёт пира и выставляет отметку
BOOTSTRAP_PEER_SEEDED; - обычный перезапуск не пересоздаёт пира и не плодит дублей (
idтот же, запись ровно одна); - удаление переживает перезапуск: после
DELETEи рестарта пир не возвращается, хотяHY2XS_ADMIN_CON_PASSостаётся в окружении; - то же после трёх перезапусков подряд;
- отключённый пир сохраняет
disabled = 1и свойsecret_digest; - отметка и пир пишутся одной транзакцией: при конфликте
UNIQUE(name)внутри транзакции отметка не остаётся выставленной; - отсутствие
HY2XS_ADMIN_CON_PASSна чистой базе — отказ старта; - перезапуск установленного сервиса без этой переменной проходит штатно;
HYSTERIA2_TRAFFIC_STATS_SECRET: пустой env при пустой базе — отказ старта, сгенерированного токена в базе не появляется; токен, уже согласованный ранее, принимается без переменной.
A9f. Резервная копия пиров (unit)
apps/service/peer_export_backup_test.go:
includeSecrets=trueна исправных данных отдаёт секрет каждого пира;- нерасшифровываемый секрет хотя бы одного пира отклоняет весь запрос, сообщение называет пира, частичное содержимое не возвращается;
- пир вовсе без шифртекста — тот же отказ;
includeSecrets=falseповреждённых данных не замечает и пустойsecretотдаёт штатно: это и есть смысл безопасного режима.
A9g. Слой данных: «нет записи» против «база не ответила» (unit)
apps/dao/config_test.go:
UpdateConfigпо отсутствующей строке — отказ, а не тихий успех: UPDATE без совпавших строк не является ошибкой SQL, и раньше оператор получал подтверждение изменения, которого не произошло, а планировщик тут же получал новое расписание;UpdateConfigне создаёт строк: это работаUpsertConfigValue;- транзакционная партия откатывается целиком, если одна из строк отсутствует;
GetConfig/GetPeerвозвращаютErrConfigNotFound/ErrPeerNotFound, отличимые черезerrors.IsотErrStorage.
apps/cmd/reset_test.go — единственный оставшийся потребитель, который склеивал
эти два ответа:
- на пустой базе
reset-adminсоздаёт учётную запись, bcrypt-хеш подходит к напечатанному паролю,force_password_changeвыставлен; - поверх существующей записи обновление идёт на месте: тот же
id, новый пароль, увеличенныйtoken_version, прежний пароль больше не действует; - при отказе чтения (
ErrStorage) сброс останавливается: вторая учётная запись не создаётся, существующая не меняется, напечатанный пароль не действует. База в этом тесте полностью работоспособна — воспроизводится ровно транзиентный отказ («database is locked»), при котором прежний код уходил в ветку создания и оставлял на сервере вторую рабочую учётку с уже напечатанным паролем; - при
ErrAdminUserNotFoundсоздание по-прежнему выполняется: строгость к отказу хранилища не имеет права сломать штатный путь восстановления; - непригодный для bcrypt пароль останавливает сброс, а не пишет пустую строку
в
password_hash— раньше ошибка хеширования проглатывалась (hash, _ := util.HashPassword(...)), и команда восстановления доступа молча его отбирала:VerifyPasswordотклоняет всё, что не bcrypt; - sentinel'ы разных таблиц несут одинаковый текст (
WrongPasswordуезжает в ответ Hysteria и менять его нельзя), поэтому проверяется именно различимость черезerrors.Is, а не по строке.
A10. Импорт пиров (unit)
apps/service/peer_import_test.go:
- выгрузка, сделанная
ExportPeer, принимается без правок; - имя проверяется теми же правилами, что и при обычном создании пира: длина, набор символов, отсутствие пробелов и переводов строки;
bootstrap-admin-peerне может быть импортирован ни по имени, ни поauthId: его секрет продублирован в/etc/hy2xs/bootstrap-admin.secret;- диапазоны
quotaBytes,expiresAt,maxDevices,disabled,bannedUntil, счётчиков трафика и длины секрета проверяются; - sentinel-значения (
quotaBytes = -1,maxDevices = 0) остаются валидными; - дубликаты имени и
authIdвнутри одной партии отклоняются; - партия сверх лимита отклоняется;
- невалидная последняя запись отклоняет весь файл: импорт применяется целиком или не применяется вовсе.
apps/service/peer_import_tx_test.go — та же гарантия уже на уровне базы, на
настоящей SQLite. Валидация не даёт применить испорченный файл, но она ничего
не говорит о конфликте с тем, что УЖЕ лежит в базе:
- валидная партия применяется целиком;
- cross-conflict откатывается полностью: пусть в базе есть
A(auth_id=aaa, name=alice1)иB(auth_id=bbb, name=bob123), а файл несёт(auth_id=aaa, name=bob123)— поиск найдёт A поauth_idи попытается переименовать её вbob123, прямо вUNIQUE(name). Записи, шедшие в файле до конфликтной, не должны остаться применёнными. Тест дополнительно убеждается, что отказ пришёл из базы, а не из валидации; - дубликат
auth_idна вставке ведёт себя так же; - отказ валидации не доходит до базы вовсе;
- пир установщика защищён и внутри транзакции;
- обновление без секрета в файле не перезаписывает существующий секрет.
apps/controller/peer_test.go — разбор загруженного файла:
- файл с хвостовым JSON-документом отклоняется:
json.Decoderчитает первый документ и останавливается, поэтому раньше оператор видел «импорт выполнен», а вторая половина файла молча не применялась; - неизвестные поля и файл не с расширением
.jsonотклоняются; - корректный одиночный документ доходит до базы и создаёт пира.
A10a. Отзыв учётных данных и сходимость сессий (unit)
apps/service/peer_secret_rotation_test.go — контракт «новое поколение
credentials получает новую идентичность сессий». Проверяется поведение, а не
наличие поля:
- сессия, установленная по отозванному секрету ПОСЛЕ успешного
/kick, завершается очередным циклом учёта. Сценарий воспроизводится буквально: авторизация по старому секрету удерживается внутриGET /online, за это время выполняется полная ротация с/kick→ 200, затем авторизация отпускается и возвращает старыйauthId— то есть соединение регистрируется уже после разрыва. Ни одна операция при этом не отказала; сходимость даёт то, что старое поколение стало orphan; - то же после
/kick→ 500; - повторная отправка того же секрета рвёт сессию (повтор отзыва), но
идентичность не меняет:
secret_digestтот же; - секрет из одних пробелов не меняет ничего — ни digest, ни
auth_id, ни сессии; прежде правило было записано двумя разными условиями, и такой секрет записывался бы в базу, не разрывая сессий; - импорт: новый секрет при прежнем
authIdв файле и при совпадении по имени ротирует идентичность; свой новыйauthIdиз файла не подменяется; повторный импорт того же файла идентичность не трогает; newPeerAuthIDвыдаёт идентификатор той же формы, что и создание пира, и проходит собственную проверку продукта (peerAuthIDPattern).
apps/service/cron_test.go дополнительно доказывает, что старое поколение
уходит в /kick именно как сессия без строки в базе.
A10b. Правдивая диагностика (unit)
apps/service/hysteria2_state_test.go — матрица двух независимых источников:
| systemd | Traffic Stats API | что обязано быть показано |
|---|---|---|
active |
отвечает | служба работает, API доступен, картина подключений реальная |
inactive |
молчит | оба факта согласованы |
unknown |
отвечает | «состояние неизвестно» + API доступен + картина подключений реальная |
active |
отказывает | служба работает, API недоступен, состояние данных — error |
Третья строка — главная регрессия: прежний путь показывал здесь «служба остановлена» и «API доступен» одновременно, причём второе — не сходив в API.
Отдельно проверяется разбор ответа systemctl is-active: active, inactive,
failed, activating, deactivating, пробелы, многострочный вывод, пустой
ответ и незнакомое слово — последнее означает unknown, а не «остановлена».
apps/service/peer_access_test.go — PagePeer отвечает
onlineState: unavailable и не теряет список пиров, когда Traffic Stats API
недоступен; при доступном API признак ok, а online/onlineDevices в строках
отражают фактический ответ.
apps/util/exec_probe_test.go — ExecProbe отличает ненулевой код возврата
(ответ команды) от невозможности запустить процесс. Требует рабочего bash,
поэтому вне Linux пропускается.
A10c. Журнал Hysteria в фактическом формате upstream (unit)
apps/service/journal_test.go — записи собираются так же, как их пишет zap с
EncoderConfig upstream:
- числовое
time(epoch millis, дробное —EpochMillisTimeEncoderделит наносекунды на миллисекунду) разбирается; прежний разбор падал на каждой такой строке и показывал оператору сырой JSON; - структурный контекст (
addr,id,error,listen,tx, …) сохраняется и дописывается к сообщению в устойчивом (алфавитном) порядке; - числа печатаются без экспоненты, вложенные объекты — компактным JSON в одну строку;
- секреты вырезаются и из сообщения, и из контекста, а адрес остаётся читаемым;
- не-JSON строка и JSON без
msgне теряются; - запись без собственных
level/timeдобирает их из journald; MESSAGE, отданный journald массивом байт (сообщение не является корректным UTF-8), больше не выбрасывает всю запись.
A10d. Проекция конфига на production-профиль (unit)
apps/service/hysteria2_profile_test.go:
- канонический конфиг оркестратора читается целиком и расхождений не даёт;
- отсутствующая секция остаётся отсутствующей — в частности,
trafficStatsне превращается в выдуманный:9999; - явное
falseотличается от «не задано»; - секции вне профиля перечисляются поимённо и по порядку, включая неизвестные HY2XS — они считаются по сырому YAML, а не по типизированной модели;
- ответ, сериализованный так, как его получит браузер, не содержит ни пароля обфускации, ни секрета Traffic Stats API, ни machine token, ни учётных данных outbound; при этом диагностические факты сохранены («пароль задан», адрес auth-URL, имена параметров ACME DNS);
- отсутствующий файл конфига — отказ, а не пустой профиль.
tools/test/frontend-contract.test.ts закрепляет ту же границу со стороны
панели: имя ACME DNS-провайдера показывается как фактическое значение профиля
(text(profile.acme.dnsProvider)), тип поля — обычная строка, а собственного
списка провайдеров в панели нет. Проверка перечисляет их поимённо
(cloudflare, duckdns, …, namedotcom) и требует, чтобы ни один не
встречался в исходнике страницы: реестр в UI был бы вторым экземпляром
upstream-списка, который умеет от него отстать — ровно так namedotcom
пришлось выпиливать вручную после его удаления в Hysteria 2.11.0.
apps/service/hysteria2_export_test.go дополнен якорями YAML: секрет за
&anchor/*alias вырезается и по ссылке, и в самом объявлении; URL с учётными
данными за якорем — тоже; ссылка на составной узел редактируется целиком;
рекурсивная ссылка (alias на предка) не зацикливает санитайзер и не отказывает —
yaml.v3 строит на ней действительно циклический граф узлов.
A7. Контракт версий (build)
Шаг verify_versions_contract (tools/build/lib/versions.sh) роняет сборку до
создания tarball при рассинхроне versions.env с PACKAGE_VERSION,
packageManager обоих package.json, схемой в package/config/hy2xs.env,
константами, скомпилированными в оркестратор, директивой go в apps/go.mod,
metadata пакета и версией, которую сообщает собранный hy2xs-admin.
Разбор upstream hashes.txt покрыт orchestrator/test/hysteria-release.test.ts:
реальный формат релиза, отсутствие путаницы hysteria-linux-amd64 с
hysteria-linux-amd64-avx, форма sha256:<hex>, верхний регистр,
противоречивые записи, отсутствие нужной строки.
A11. Проверка зависимостей на уязвимости (build)
tools/build/lib/security.sh — обязательный шаг между тестами админки и записью
metadata. Подробности в docs/02; здесь важно
поведение при отказе:
Код govulncheck |
Трактовка |
|---|---|
0 |
чисто |
3 |
найдены вызываемые уязвимости → сборка падает |
| иное | отказ самого инструмента → сборка падает отдельным сообщением |
Последняя строка существенна: ненулевой код неизвестной природы нельзя
трактовать как «уязвимостей нет». По той же причине недоступность реестра npm
для pnpm audit — это отказ проверки, а не её отрицательный результат.
pnpm audit проверяет весь lock-граф frontend, а не production-подграф:
build tooling исполняется на build-машине и порождает production-бандл, поэтому
уязвимость в нём уезжает в артефакт. Приёмка сборки следит, чтобы --prod не
вернулся в гейт.
A11a. Обязательные тесты (build)
Гейт тестов устроен так же, как гейт зависимостей: аварийного выхода нет, результат виден по готовому артефакту.
| Шаг сборки | Что запускается |
|---|---|
run_orchestrator_tests |
bun x tsc --noEmit, bun test |
bundle_ui |
pnpm run typecheck до сборки bundle |
run_admin_tests |
go vet ./..., go test ./... |
Приёмка проверяет:
- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs
(место для истории —
CHANGELOG.md); metadata/package.envсодержитtests_gate=true;- утверждение о прогоне выставляется после самого прогона, а не до него;
write_metadataотказывается писать метаданные, если хотя бы один из двух прогонов не подтверждён.
A12. Приёмка проверяет код, а не упоминания
Два контракта приёмки на снимке до этого патча гарантированно роняли сборку на корректном коде, и оба — по одной причине: они искали подстроку там, где подстрока обязана присутствовать.
| Проверка | Что ловила на самом деле |
|---|---|
verify_api_namespace_contract |
grep -rlF '/hui' возвращал apps/router/router_test.go (регрессионный тест, который ПЕРЕЧИСЛЯЕТ legacy-префикс, чтобы доказать его отсутствие) и сам versions.sh, где эта строка стоит в тексте проверки |
| «peer import не выходит за транзакцию» | source.slice(start) брал файл от начала applyPeerImportEntry и до конца, захватывая ExistPeerName и UpdatePeerLastConnectionAt — обычные операции вне импорта, которым глобальное соединение положено |
Первая падала на шаге versions contract — шестым из четырнадцати, до резолва Hysteria. Вторая не была замечена только потому, что сборка до неё не доходила.
Отсюда правило и помощники code_without_comments / code_mentions_in в
acceptance.sh: проверка смотрит на код, а комментарий, объясняющий, почему
чего-то больше нет, обязан называть это по имени и не должен ломать сборку.
Отсутствие legacy-маршрута доказывает не grep по исходникам, а
TestRouterHasNoLegacyNamespace на таблице маршрутов собранного роутера —
и существование этого теста само проверяется контрактом.