#!/usr/bin/env bash set -euo pipefail # code_without_comments отдаёт содержимое файла без строк-комментариев. # # Приёмка обязана проверять КОД, а не упоминания. Комментарий, объясняющий, # почему чего-то больше нет, обязан называть это по имени — иначе он бесполезен, # — и проверка, ищущая просто подстроку, начинает падать ровно на той # документации, которая описывает выполненную ею же работу. # # Урок в этом файле уже дважды оплачен: скан versions contract ловил сам себя на # строке '/hui', а dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ # удалённые маршруты, чтобы доказать их отсутствие. Помощник существует, чтобы # третьего раза не было. # # Вид комментария выбирается по расширению файла. # # Раньше отбрасывались только строки, начинающиеся с `//`, поэтому сканы по # tools/build/*.sh не работали вовсе: там комментарии начинаются с решётки, и # объяснение «почему этого больше нет» считалось за код. # # Решётка при этом отбрасывается ТОЛЬКО в shell. В шаблоне Vue строка вполне # может начинаться с `#default="scope"` — это сокращение для v-slot, а не # комментарий, и общий фильтр молча выбрасывал бы её из скана. code_without_comments() { case "$1" in *.sh) grep -vE '^[[:space:]]*#' "$1" 2>/dev/null || true ;; *) grep -v '^[[:space:]]*//' "$1" 2>/dev/null || true ;; esac } # code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ. code_mentions_in() { local needle="$1" shift local file hits="" while IFS= read -r file; do [ -n "$file" ] || continue if code_without_comments "$file" | grep -qF -- "$needle"; then hits="${hits}${hits:+ }${file}" fi done </dev/null || true) EOF printf '%s' "$hits" } run_fix20_acceptance_subset() { local package_dir="$1" [ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir" log_step "Acceptance: package layout sanity" [ -x "$package_dir/install.sh" ] || fail "acceptance: install.sh is missing or not executable" [ -x "$package_dir/orchestrator/hy2xs-orchestrator" ] || fail "acceptance: orchestrator artifact is missing" log_step "Acceptance: project license is shipped with the package" [ -f "$package_dir/LICENSE" ] || fail "acceptance: LICENSE is missing from the package" grep -q 'GNU AFFERO GENERAL PUBLIC LICENSE' "$package_dir/LICENSE" \ || fail "acceptance: packaged LICENSE must be AGPL-3.0-only" grep -q '^license=AGPL-3.0-only$' "$package_dir/metadata/package.env" \ || fail "acceptance: package metadata must declare license=AGPL-3.0-only" log_step "Acceptance: orchestrator CLI help path" "$package_dir/orchestrator/hy2xs-orchestrator" diagnostics collect --package-dir "$package_dir" >/dev/null 2>&1 || true log_step "Acceptance: firewall mode defaults in config" grep -q '^HY2XS_FIREWALL_MODE=' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FIREWALL_MODE missing in runtime config" log_step "Acceptance: baseline domain/public host/ssh defaults" grep -q '^HY2XS_DOMAIN=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_DOMAIN must default to fi.api.withen.pro" grep -q '^HY2XS_PUBLIC_HOST=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_PUBLIC_HOST must default to fi.api.withen.pro" grep -q '^HY2XS_SSH_PORT=2323$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_SSH_PORT must default to 2323" log_step "Acceptance: force password change production default" grep -q '^HY2XS_FORCE_PASSWORD_CHANGE=false$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FORCE_PASSWORD_CHANGE must default to false" log_step "Acceptance: config schema version is declared" # Значение берётся из versions.env, а не пишется числом: захардкоженная # двойка означала бы, что при переходе на schema 3 нужно помнить ещё и про # эту строку. Источник истины у схемы ровно один. grep -q "^HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}\$" "$package_dir/config/hy2xs.env" \ || fail "acceptance: packaged baseline must declare HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}" log_step "Acceptance: public endpoint policy is declared and strict by default" grep -q '^HY2XS_PUBLIC_ENDPOINT_POLICY=strict$' "$package_dir/config/hy2xs.env" \ || fail "acceptance: packaged baseline must default to HY2XS_PUBLIC_ENDPOINT_POLICY=strict" log_step "Acceptance: fresh install defaults to Gecko obfuscation" grep -q '^HY2XS_HYSTERIA_OBFS_TYPE=gecko$' "$package_dir/config/hy2xs.env" \ || fail "acceptance: fresh installations must default to HY2XS_HYSTERIA_OBFS_TYPE=gecko" log_step "Acceptance: obfs type is not hardcoded in the Hysteria template" grep -q '{{OBFS_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \ || fail "acceptance: hysteria template must render the obfs block from the orchestrator" ! grep -Eq '^\s*type:\s*(gecko|salamander)\s*$' "$package_dir/templates/hysteria/config.yaml.tpl" \ || fail "acceptance: hysteria template must not hardcode an obfs type" log_step "Acceptance: modern server baseline is present in the template" grep -q 'disableLossCompensation: {{DISABLE_LOSS_COMPENSATION}}' "$package_dir/templates/hysteria/config.yaml.tpl" \ || fail "acceptance: bandwidth.disableLossCompensation missing from hysteria template" grep -q '{{CONGESTION_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \ || fail "acceptance: congestion block missing from hysteria template" grep -q '{{QUIC_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \ || fail "acceptance: quic block missing from hysteria template" log_step "Acceptance: post-install env derives obfs type from resolved context" grep -q '^HY2_OBFS_TYPE={{OBFS_TYPE}}$' "$package_dir/templates/env/post-install.env.tpl" \ || fail "acceptance: post-install env must render the resolved obfs type, not a second set of defaults" ! grep -Eq '^HY2_OBFS_TYPE=(gecko|salamander)$' "$package_dir/templates/env/post-install.env.tpl" \ || fail "acceptance: post-install env must not hardcode an obfs type" log_step "Acceptance: production defaults are declared in exactly one module" grep -q 'DEFAULT_HYSTERIA_OBFS_TYPE' orchestrator/src/config/profile.ts \ || fail "acceptance: the default obfs type must be declared in orchestrator/src/config/profile.ts" local default_declarations default_declarations="$(grep -rl 'DEFAULT_HYSTERIA_OBFS_TYPE\s*[:=]' orchestrator/src \ | grep -v 'orchestrator/src/config/profile.ts' || true)" [ -z "$default_declarations" ] \ || fail "acceptance: the default obfs type must not be re-declared in: $default_declarations" log_step "Acceptance: runtime env is derived from config, not literals" ! grep -Eq 'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' orchestrator/src/config/env.ts \ || fail "acceptance: renderRuntimeEnv must not print a hardcoded obfs type" grep -q 'HY2XS_HYSTERIA_OBFS_TYPE=\${config.hysteriaObfsType}' orchestrator/src/config/env.ts \ || fail "acceptance: renderRuntimeEnv must derive the obfs type from the parsed config" ! grep -Eq '\|\|\s*"(gecko|salamander)"' orchestrator/src/config/env.ts \ || fail "acceptance: env.ts must not carry its own obfs fallback default" log_step "Acceptance: package metadata records how the Hysteria version was chosen" grep -q '^hysteria_resolution=' "$package_dir/metadata/package.env" \ || fail "acceptance: metadata must record hysteria_resolution" grep -q '^hysteria_resolved_at=' "$package_dir/metadata/package.env" \ || fail "acceptance: metadata must record hysteria_resolved_at" grep -q '^hysteria_compat_gate=true$' "$package_dir/metadata/package.env" \ || fail "acceptance: release packages must be built with the Hysteria compatibility gate enabled" grep -Eq '^hysteria_artifact_url=https://github\.com/HyNetworks/hysteria/' "$package_dir/metadata/package.env" \ || fail "acceptance: Hysteria artifact must come from the canonical HyNetworks upstream" log_step "Acceptance: install-time never resolves a moving latest" ! grep -rq 'api.github.com' orchestrator/src/commands orchestrator/src/steps \ || fail "acceptance: install-time code must not query the upstream release API" ! grep -rq 'download.hysteria.network' orchestrator/src \ || fail "acceptance: install-time code must not use the moving latest download URL" log_step "Acceptance: smoke verifies config semantics, not substrings" grep -q 'assertHysteriaConfigMatchesProfile' orchestrator/src/steps/smoke.ts \ || fail "acceptance: smoke must verify the effective config semantically" log_step "Acceptance: admin export preserves unknown upstream fields and strips secrets" grep -q 'ExportHysteria2ConfigYaml' apps/controller/config.go \ || fail "acceptance: hysteria config export must go through the sanitizing raw-YAML path" grep -q 'GetRawHysteria2Config' apps/service/hysteria2_export.go \ || fail "acceptance: export must read the raw YAML instead of the typed model" log_step "Acceptance: frontend ACME registry matches current upstream" # Ищем именно предлагаемое значение, а не упоминание в комментарии. ! grep -q '"namedotcom"' apps/frontend/src/views/hysteria/list/index.vue \ || fail "acceptance: namedotcom was removed upstream in Hysteria 2.11.0 and must not be offered" local provider for provider in cloudflare duckdns gandi godaddy namecheap njalla porkbun vultr; do grep -q "\"${provider}\"" apps/frontend/src/views/hysteria/list/index.vue \ || fail "acceptance: ACME DNS provider ${provider} is missing from the UI registry" done log_step "Acceptance: systemd unit production env" grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing" log_step "Acceptance: docs matrix markers" grep -q 'Fix20 production matrix' docs/11-testing-and-acceptance.md || fail "acceptance: fix20 matrix section missing" log_step "Acceptance: machine auth URL in templates" grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template" grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}$' "$package_dir/templates/env/post-install.env.tpl" || fail "acceptance: machine token missing in post-install HY2_AUTH_URL" log_step "Acceptance: smoke auth checks are tokenized" grep -q 'unexpected auth status without machine token' orchestrator/src/steps/smoke.ts || fail "acceptance: missing 403 negative smoke for auth without machine token" # Проверяется контракт, а не литерал URL. # # Регрессия приёмки: здесь стоял grep по строке # `hysteria2/auth?access_token=${...}`. После переезда machine-auth на # /internal/hysteria/auth и централизации пути в профиле такой строки в # smoke.ts не существует — приёмка падала на корректном коде, причём в самом # конце сборки, внутри verify_archive. Единственный источник истины у пути # один, поэтому и проверять нужно обращение к нему. grep -q 'HYSTERIA_MACHINE_AUTH_PATH' orchestrator/src/steps/smoke.ts \ || fail "acceptance: smoke must take the machine-auth path from the production profile" grep -q 'hysteriaMachineAuthUrl(' orchestrator/src/steps/smoke.ts \ || fail "acceptance: smoke auth URL must be built by the production helper (tokenized)" ! grep -Eq 'access_token=' orchestrator/src/steps/smoke.ts \ || fail "acceptance: smoke must not assemble the machine token into a URL by hand" log_step "Acceptance: bootstrap peer can pass auth smoke" grep -q 'quota := int64(-1)' apps/dao/sqlite.go || fail "acceptance: bootstrap peer quota must be unlimited (-1), otherwise install auth smoke fails" log_step "Acceptance: frontend type checking is a release gate" # Проверка типов SFC-шаблонов была фикцией: vue-tsc 0.35 не находил ничего, а # на современном том же коде — 142 ошибки. Инвариант обязан жить в сборке, а # не в намерении. "$BUN_BIN" -e ' const pkg = require("./apps/frontend/package.json"); const scripts = pkg.scripts || {}; if (!scripts.typecheck || !scripts.typecheck.includes("vue-tsc")) { throw new Error("apps/frontend: скрипт typecheck должен запускать vue-tsc"); } if (scripts["build:prod"].includes("vue-tsc")) { throw new Error("build:prod снова совмещает сборку и проверку типов: ошибка типов обнаружится только после production bundle"); } const major = Number((pkg.devDependencies["vue-tsc"] || "").replace(/^\D*/, "").split(".")[0]); if (!Number.isInteger(major) || major < 3) { throw new Error("vue-tsc должен быть версии 3 и выше: 0.x проверку шаблонов не выполняет"); } ' || fail "acceptance: контракт проверки типов frontend нарушен" grep -q '"\$PNPM_BIN" run typecheck' tools/build/lib/package.sh \ || fail "acceptance: сборка не запускает проверку типов frontend" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8"); const check = source.indexOf("run typecheck"); const build = source.indexOf("run build:prod"); if (check < 0 || build < 0) throw new Error("не найдены шаги typecheck/build:prod"); if (check > build) throw new Error("проверка типов идёт после сборки bundle"); ' || fail "acceptance: проверка типов обязана предшествовать сборке bundle" log_step "Acceptance: local SVG icons are rendered from an in-repo sprite" # vite-plugin-svg-icons убран: он не обновлялся с 2022 года и тянул svgo 2.8, # postcss 5.2.18 и image-size 0.5.5, у последней advisory прямо сообщает # `Patched versions: <0.0.0` — исправленной версии не существует. ! grep -q '"vite-plugin-svg-icons"' apps/frontend/package.json \ || fail "acceptance: неподдерживаемый vite-plugin-svg-icons вернулся в зависимости" ! grep -rq 'virtual:svg-icons-register' apps/frontend/src \ || fail "acceptance: виртуальный модуль удалённого плагина иконок вернулся" grep -q 'registerSvgIcons' apps/frontend/src/main.ts \ || fail "acceptance: спрайт локальных иконок не регистрируется при старте приложения" # Каждая иконка обязана давать symbol с viewBox. # # Без viewBox `` не знает систему координат и рисует иконку в натуральную # величину, обрезая её по размеру родительского svg. Три иконки из # семнадцати его не объявляют — для них viewBox синтезируется из width/height, # как это делал заменённый плагин. Проверка сторожит именно ассеты: иконка, # добавленная без обоих способов задать координаты, иначе сломала бы # отрисовку молча. "$BUN_BIN" -e ' const fs = require("node:fs"); const dir = "apps/frontend/src/assets/icons"; const files = fs.readdirSync(dir).filter((f) => f.endsWith(".svg")); if (files.length === 0) throw new Error("каталог локальных иконок пуст"); const broken = []; for (const file of files) { const raw = fs.readFileSync(`${dir}/${file}`, "utf8"); const openTag = raw.replace(/<\?xml[\s\S]*?\?>/gi, "").replace(//gi, "").match(/]*>/i); if (!openTag) { broken.push(`${file}: нет корневого `); continue; } const hasViewBox = /viewBox="[^"]+"/i.test(openTag[0]); const hasSize = /width="[\d.]+[a-z%]*"/i.test(openTag[0]) && /height="[\d.]+[a-z%]*"/i.test(openTag[0]); if (!hasViewBox && !hasSize) broken.push(`${file}: нет ни viewBox, ни пары width/height`); } if (broken.length) throw new Error("иконки без системы координат:\n" + broken.join("\n")); ' || fail "acceptance: локальная иконка не даёт корректный для спрайта" log_step "Acceptance: frontend i18n does not touch Pinia at module import" ! grep -q 'useAppStore' apps/frontend/src/lang/index.ts || fail "acceptance: lang/index.ts must not import/use Pinia store" log_step "Acceptance: env rendering maps machine token and fails on unresolved placeholders" grep -q 'HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret' orchestrator/src/steps/env.ts || fail "acceptance: writePostInstallEnv must pass HYSTERIA_API_SECRET" grep -q 'template render failed: unresolved placeholders' orchestrator/src/lib/fs.ts || fail "acceptance: renderTemplate must fail on unresolved placeholders" run_clean_install_acceptance "$package_dir" } # Приёмка политики clean-install-only и связанных с ней инвариантов. run_clean_install_acceptance() { local package_dir="$1" log_step "Acceptance: installer runs a read-only preflight before touching the host" grep -q 'preflight-install' "$package_dir/install.sh" \ || fail "acceptance: install.sh must run the read-only preflight before mutating the host" grep -q 'PHASE 0' "$package_dir/install.sh" \ || fail "acceptance: install.sh must document the read-only phase boundary" grep -q 'preflightInstall' orchestrator/src/cli.ts \ || fail "acceptance: orchestrator must expose the preflight-install command" log_step "Acceptance: the read-only phase is enforced by a guard, not by convention" grep -q 'enableReadOnlyGuard' orchestrator/src/commands/preflight-install.ts \ || fail "acceptance: preflight-install must enable the read-only guard" grep -q 'assertMutationAllowed' orchestrator/src/lib/fs.ts \ || fail "acceptance: fs writes must be guarded during the read-only phase" grep -q 'assertMutationAllowed' orchestrator/src/lib/process.ts \ || fail "acceptance: mutating runners must be guarded during the read-only phase" log_step "Acceptance: preflight passes before the first install-state write" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const preflight = source.indexOf("await preflight(context"); const state = source.indexOf("await advanceInstallState("); if (preflight < 0 || state < 0) { throw new Error("could not locate preflight/advanceInstallState in install.ts"); } if (preflight > state) { throw new Error("install writes install-state before preflight"); } ' || fail "acceptance: install must not write install-state before a successful preflight" log_step "Acceptance: rollback is ownership-aware, not message-driven" grep -q 'classifyFailure(ownership' orchestrator/src/commands/install.ts \ || fail "acceptance: failure classification must be driven by ownership, not by the error message" grep -q 'systemd units were not deployed by this operation' orchestrator/src/commands/install.ts \ || fail "acceptance: rollback must never stop services it did not deploy" log_step "Acceptance: a touched install-state already makes the failure post-apply" # Регрессия: classifyFailure не учитывал маркер установки, поэтому падение # apt-get объявлялось «на сервере ничего не изменено», rollback пропускался, # а install-state.json оставался на хосте и ломал следующую установку. grep -q 'ownership.stateTouched' orchestrator/src/commands/install.ts \ || fail "acceptance: classifyFailure must account for a touched install-state" ! grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \ || fail "acceptance: stateWritten вернулся; флаг обязан называться stateTouched и взводиться до записи" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const body = source.slice(source.indexOf("export function classifyFailure")); const preApply = body.indexOf("return \"fatal_pre_apply\""); const stateTouched = body.indexOf("ownership.stateTouched"); if (preApply < 0 || stateTouched < 0) { throw new Error("could not locate classifyFailure branches"); } if (stateTouched > preApply) { throw new Error("stateTouched is checked after the fatal_pre_apply fallback"); } ' || fail "acceptance: fatal_pre_apply must be unreachable once install-state was touched" log_step "Acceptance: the install-state flag is raised before the write, like every other one" # Запись маркера — это mkdir, write и chown. Отказ последней оставляет файл на # диске, поэтому флаг обязан отвечать на вопрос «сюда мы могли влезть», а не # «запись удалась». "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const start = source.indexOf("async function advanceInstallState"); if (start < 0) throw new Error("advanceInstallState is missing"); // Границей тела служит следующее объявление верхнего уровня. const rest = source.slice(start + 1); const end = rest.search(/\n(export )?(async )?function /); const body = end < 0 ? rest : rest.slice(0, end); const flag = body.indexOf("ownership.stateTouched = true"); const write = body.indexOf("await writeInstallState("); if (flag < 0 || write < 0) throw new Error("could not locate the flag and the write"); if (flag > write) throw new Error("stateTouched is raised after writeInstallState"); ' || fail "acceptance: the install-state ownership flag must be raised before the write" log_step "Acceptance: mutating ownership flags are raised before the step, not after" # Флаг «шаг завершился» отвечает не на тот вопрос: apt-get умеет изменить # систему и упасть. Каждый флаг обязан стоять ПЕРЕД своим await. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const steps = [ ["depsTouched", "await installDeps("], ["filesystemTouched", "await prepareFilesystem("], ["uiTouched", "await deployUi("], ["hysteriaTouched", "await installHysteria("], ["configTouched", "await generateConfig("], ["unitsTouched", "await deploySystemd("], ["firewallTouched", "await applyFirewall("], ["postInstallTouched", "await writePostInstallEnv("], ["bootstrapSecretTouched", "await ensureBootstrapAdminSecret("] ]; for (const [flag, call] of steps) { const flagAt = source.indexOf("ownership." + flag + " = true"); const callAt = source.indexOf(call); if (flagAt < 0) throw new Error("missing ownership flag: " + flag); if (callAt < 0) throw new Error("missing step call: " + call); if (flagAt > callAt) throw new Error(flag + " is raised after " + call); } ' || fail "acceptance: ownership flags must be raised before the mutating step they cover" log_step "Acceptance: the read-only phase has no universal runner to slip through" # Пока существовал один `run`, под которым жили и `ss -ltn`, и `useradd`, # guard держался на внимательности автора правки. grep -q 'export async function runReadOnly' orchestrator/src/lib/process.ts \ || fail "acceptance: process.ts must expose an explicit read-only runner" grep -q 'export async function runMutating' orchestrator/src/lib/process.ts \ || fail "acceptance: process.ts must expose an explicit mutating runner" ! grep -rEq '(^|[^A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \ || fail "acceptance: the pre-split runner names must not come back" # Порог считается от числа мутирующих раннеров, а не задан константой: новый # раннер обязан приносить с собой и проверку guard'а, а не проходить под # запасом, оставленным предыдущей правкой. local mutating_runners guarded_runners mutating_runners="$(grep -c 'export async function runMutating' orchestrator/src/lib/process.ts || true)" guarded_runners="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)" [ "$mutating_runners" -ge 5 ] \ || fail "acceptance: набор мутирующих раннеров неожиданно сократился ($mutating_runners)" [ "$guarded_runners" -gt "$mutating_runners" ] \ || fail "acceptance: every mutating runner must ask the read-only guard for permission" log_step "Acceptance: doctor is read-only by runtime invariant, not by convention" # Принудительный skipServiceStart закрывал ровно одну ИЗВЕСТНУЮ мутацию. # Всё остальное в smoke держалось на выборе раннера автором правки: читающие # команды (`test -s`, `grep -q`, `stat`, `sudo -u ... test`, `nft -c`) шли # через мутирующий namespace, поэтому настоящая мутация, случайно добавленная # в smoke, ничем бы от них не отличалась и была бы разрешена в doctor молча. grep -q 'enableReadOnlyGuard' orchestrator/src/commands/doctor.ts \ || fail "acceptance: doctor обязан выполняться под read-only guard, а не только выставлять skipServiceStart" grep -q 'disableReadOnlyGuard' orchestrator/src/commands/doctor.ts \ || fail "acceptance: doctor обязан снимать guard в finally" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/doctor.ts", "utf8"); const enable = source.indexOf("enableReadOnlyGuard("); const work = source.indexOf("await readText(options.sourceConfigPath)"); const finallyAt = source.indexOf("} finally {"); const disable = source.indexOf("disableReadOnlyGuard()"); if (enable < 0 || work < 0 || finallyAt < 0 || disable < 0) { throw new Error("не найдены включение guard, работа и finally"); } if (enable > work) throw new Error("guard включается после начала работы"); if (disable < finallyAt) throw new Error("guard снимается не в finally"); ' || fail "acceptance: guard doctor обязан охватывать весь проход и сниматься в finally" # В smoke допустим ровно один мутирующий вызов — рестарт под флагом, который # doctor выставляет принудительно. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8"); const calls = source.split(/\r?\n/) .filter((line) => !line.trimStart().startsWith("//")) .filter((line) => /\brunMutating[A-Za-z]*[`(]/.test(line)); if (calls.length !== 1) { throw new Error("мутирующих вызовов в smoke: " + calls.length + "\n" + calls.join("\n")); } if (!calls[0].includes("systemctl restart hysteria-server hy2xs-admin")) { throw new Error("единственный мутирующий вызов smoke — не рестарт сервисов: " + calls[0]); } ' || fail "acceptance: наблюдение в smoke обязано выполняться read-only раннерами" # Пауза между двумя чтениями не является операцией над хостом. ! grep -Eq 'runMutating[A-Za-z]*`sleep' orchestrator/src/steps/smoke.ts \ || fail "acceptance: ожидание между попытками снова выполняется мутирующим раннером" log_step "Acceptance: reset-admin tells a storage failure apart from a missing admin" # Склейка ErrStorage и ErrAdminUserNotFound уводила команду в ветку СОЗДАНИЯ: # при транзиентном отказе чтения на сервере появлялась вторая рабочая учётка # с паролем, уже напечатанным на экран. grep -q 'errors.Is(err, dao.ErrAdminUserNotFound)' apps/cmd/reset.go \ || fail "acceptance: reset-admin обязан различать sentinel-ошибки слоя данных" ! code_without_comments apps/cmd/reset.go | grep -qF 'hash, _ :=' \ || fail "acceptance: ошибка хеширования пароля снова проглатывается, в password_hash уедет пустая строка" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("apps/cmd/reset.go", "utf8"); const start = source.indexOf("func resetAdminCredentials"); if (start < 0) throw new Error("resetAdminCredentials отсутствует"); const body = source.slice(start); if (!/default:\s*\n\s*return fmt\.Errorf/.test(body)) { throw new Error("неизвестная ошибка чтения не приводит к отказу"); } const save = body.indexOf("dao.SaveAdminUser("); const notFound = body.indexOf("errors.Is(err, dao.ErrAdminUserNotFound)"); if (save < 0 || notFound < 0) throw new Error("не найдены ветка создания и её условие"); if (notFound > save) throw new Error("создание учётной записи не ограничено веткой «записи нет»"); ' || fail "acceptance: создание администратора допустимо только при подтверждённом отсутствии записи" log_step "Acceptance: the public endpoint invariant lives in preflight, not only in doctor" grep -q 'assertPublicEndpoint' orchestrator/src/steps/preflight.ts \ || fail "acceptance: preflight must verify that the public endpoint resolves to this server" [ -f orchestrator/src/steps/networkEndpoint.ts ] \ || fail "acceptance: the public endpoint invariant module is missing" ! grep -rqE 'ifconfig\.me|ipify|checkip\.amazonaws' orchestrator/src \ || fail "acceptance: the server address must be resolved locally, not via an external service" grep -q 'networkInterfaces' orchestrator/src/steps/networkEndpoint.ts \ || fail "acceptance: local public IPv4 set must come from the host interfaces" log_step "Acceptance: purge and clean-host describe the same boundary" local purged_path for purged_path in /var/lib/hysteria /usr/local/lib/hy2xs /usr/local/bin/hy2xs-orchestrator /var/log/hy2xs; do grep -qF "$purged_path" tools/legacy/purge-v0.sh \ || fail "acceptance: purge-v0.sh no longer removes $purged_path" done grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \ || fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker" # Symlink оркестратора остаётся маркером, но путь объявлен в профиле: его # создаёт steps/bootstrap.ts, и две копии строки разошлись бы. grep -q 'ORCHESTRATOR_SYMLINK_PATH' orchestrator/src/steps/cleanHost.ts \ || fail "acceptance: clean-host must treat a leftover orchestrator symlink as a legacy marker" ! grep -q 'keep-hysteria-binary' tools/legacy/purge-v0.sh \ || fail "acceptance: --keep-hysteria-binary contradicts the installer clean-host contract" log_step "Acceptance: admin secrets never reach a persistent export file" # Экспорт формируется в памяти: os.Create в /var/lib/hy2xs-admin/export # оставлял на диске JSON с расшифрованными секретами пиров. [ ! -f apps/util/export.go ] \ || fail "acceptance: the file-based export helper came back" ! grep -rq 'ExportPathDir' apps/cmd apps/controller apps/service apps/dao apps/model apps/util \ || fail "acceptance: the persistent export directory came back" grep -q 'c.Data(200, "application/octet-stream", payload)' apps/controller/peer.go \ || fail "acceptance: peer export must be served from memory" log_step "Acceptance: peer import is validated as strictly as peer creation" grep -q 'ValidatePeerImportBatch' apps/service/peer.go \ || fail "acceptance: peer import must validate the whole batch before writing" grep -q 'DisallowUnknownFields' apps/controller/peer.go \ || fail "acceptance: peer import must reject unknown fields instead of silently defaulting" grep -q 'ReservedBootstrapPeerName' apps/service/peer_import.go \ || fail "acceptance: peer import must refuse to overwrite the installer-owned bootstrap peer" log_step "Acceptance: missing config schema marker is treated as legacy" grep -q 'HY2XS_CONFIG_SCHEMA_VERSION отсутствует' orchestrator/src/config/env.ts \ || fail "acceptance: a missing HY2XS_CONFIG_SCHEMA_VERSION must be rejected as legacy, not defaulted" log_step "Acceptance: reconfigure/repair verify the installation generation" grep -q 'assertCurrentGeneration' orchestrator/src/commands/reconfigure.ts \ || fail "acceptance: reconfigure/repair must verify release line and config schema in install-state" grep -q 'release_line' orchestrator/src/lib/installState.ts \ || fail "acceptance: install-state must carry the product release line" log_step "Acceptance: diagnostics redaction is structural" grep -q 'Bun.YAML.parse' orchestrator/src/lib/redaction.ts \ || fail "acceptance: YAML redaction must walk the document, not match lines" # Регрессия: правило `.replace(/(auth:\s*).*/gi, ...)` подставляло маркер в # заголовок mapping'а и оставляло вложенный auth.http.url с machine token. ! grep -qF 'replace(/(auth:' orchestrator/src/lib/redaction.ts \ || fail "acceptance: the line-based auth redaction rule leaked nested auth.http.url" log_step "Acceptance: dead updater/config-write routes stay removed" # Ищется регистрация маршрута (имя в кавычках), а не любое упоминание: # комментарий, объясняющий, почему маршрута нет, должен быть разрешён. # # Тесты исключены по той же причине, и это не послабление: router_test.go # ПЕРЕЧИСЛЯЕТ удалённые имена, потому что проверяет их отсутствие в таблице # маршрутов. Пока `*_test.go` попадал под скан, приёмка падала на собственном # регрессионном тесте — то есть добавление теста, закрепляющего удаление # маршрута, ломало сборку. local dead_route for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath exportConfig importConfig getConfig; do ! grep -rqF --include='*.go' --exclude='*_test.go' "${dead_route}\"" apps/router apps/controller \ || fail "acceptance: removed route ${dead_route} came back" ! grep -rqF "${dead_route}\"" apps/frontend/src/api \ || fail "acceptance: frontend still calls the removed route ${dead_route}" done log_step "Acceptance: e2e uses the production share URI generator" grep -q 'tools/share-uri' tools/test/e2e-hysteria.sh \ || fail "acceptance: e2e must build the share URI with production code" ! grep -q 'build_share_uri' tools/test/e2e-hysteria.sh \ || fail "acceptance: the bash share-URI implementation must not come back" log_step "Acceptance: upstream checksums are verified before the lock is written" grep -q 'resolve_expected_sha_from_upstream' tools/build/lib/hysteria.sh \ || fail "acceptance: the Hysteria artifact must be verified against upstream hashes.txt" log_step "Acceptance: toolchain checksums live in versions.env" grep -q '^BUN_LINUX_X64_SHA256=' versions.env \ || fail "acceptance: versions.env must pin the Bun x64 artifact" grep -q '^BUN_LINUX_X64_BASELINE_SHA256=' versions.env \ || fail "acceptance: versions.env must pin the Bun baseline artifact" grep -q '^GO_LINUX_AMD64_SHA256=' versions.env \ || fail "acceptance: versions.env must pin the Go toolchain archive" grep -q '^NODE_LINUX_X64_SHA256=' versions.env \ || fail "acceptance: versions.env must pin the Node.js archive" ! grep -q '^HYSTERIA_VERSION=' versions.env \ || fail "acceptance: versions.env declares the Hysteria policy, not a pinned version" log_step "Acceptance: admin version comes from the build contract" grep -q 'var Version = "dev"' apps/model/constant/system.go \ || fail "acceptance: admin version must be injected via ldflags, not hardcoded" grep -q 'constant.Version=v' tools/build/lib/package.sh \ || fail "acceptance: the build must inject the admin version from versions.env" log_step "Acceptance: legacy cleanup is a separate, explicit helper" [ -f tools/legacy/purge-v0.sh ] || fail "acceptance: legacy cleanup helper is missing" [ -f docs/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing" ! grep -q 'purge-v0' "$package_dir/install.sh" \ || fail "acceptance: the installer must never run destructive cleanup on its own" run_transaction_boundary_acceptance run_single_owner_acceptance "$package_dir" run_secret_channel_acceptance run_atomic_import_acceptance run_legacy_account_acceptance run_scheduler_lifecycle_acceptance run_dead_config_acceptance run_dependency_hygiene_acceptance "$package_dir" } # Планировщик принадлежит процессу и не перезапускает HTTP-сервер. run_scheduler_lifecycle_acceptance() { log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server" [ -f apps/service/cron_scheduler.go ] \ || fail "acceptance: модуль планировщика отсутствует" [ ! -f apps/middleware/cron.go ] \ || fail "acceptance: планировщик вернулся в middleware, где он создавался заново на каждый запуск сервера" # Ключевая регрессия: смена настройки не имеет права ронять HTTP-сервер. # Именно этот путь плодил планировщики — StopServer возвращал управление в # `for { runServer() }`, а InitCron создавал новый cron.New() и терял ссылку. ! code_without_comments apps/controller/config.go | grep -q 'StopServer' \ || fail "acceptance: смена настройки снова перезапускает HTTP-сервер вместо перепланирования джобы" grep -q 'RescheduleResetTraffic' apps/controller/config.go \ || fail "acceptance: расписание должно переноситься на месте, через RescheduleResetTraffic" # Точка входа не должна крутить сервер в цикле: цикл существовал только ради # внутрипроцессного перезапуска. ! grep -qE '^[[:space:]]*for[[:space:]]*\{' apps/cmd/cmd.go \ || fail "acceptance: цикл перезапуска сервера вернулся в точку входа" # Остановка планировщика обязана существовать и идти РАНЬШЕ закрытия базы. grep -q 'func StopCron' apps/service/cron_scheduler.go \ || fail "acceptance: у планировщика нет остановки, джобы переживут закрытие SQLite" grep -q 'defer service.StopCron()' apps/cmd/server.go \ || fail "acceptance: планировщик не останавливается при завершении сервиса" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("apps/cmd/server.go", "utf8"); const release = source.indexOf("defer releaseResource()"); const stopCron = source.indexOf("defer service.StopCron()"); if (release < 0 || stopCron < 0) throw new Error("не найдены defer releaseResource/StopCron"); // defer выполняются в обратном порядке регистрации: releaseResource должен // быть зарегистрирован РАНЬШЕ, чтобы отработать ПОЗЖЕ StopCron. if (release > stopCron) throw new Error("SQLite закроется раньше остановки планировщика"); ' || fail "acceptance: порядок остановки должен глушить планировщик до закрытия базы" # Сигналы: SIGTERM от systemd обязан приводить к штатному завершению. grep -q 'signal.NotifyContext' apps/cmd/server.go \ || fail "acceptance: сервис не обрабатывает сигнал завершения от systemd" grep -q 'syscall.SIGTERM' apps/cmd/server.go \ || fail "acceptance: SIGTERM не входит в набор сигналов завершения" log_step "Acceptance: cron expression is validated by the runtime parser before it is stored" grep -q 'cron.ParseStandard' apps/service/cron_scheduler.go \ || fail "acceptance: выражение должно проверяться тем же парсером, которым его разбирает планировщик" grep -q 'ValidateConfigValue' apps/controller/config.go \ || fail "acceptance: значения настроек должны проверяться до записи в базу" log_step "Acceptance: config batch is validated fully, then applied in one transaction" grep -q 'func WithConfigTx' apps/dao/config.go \ || fail "acceptance: у записи настроек нет транзакционной границы" grep -q 'sqliteDB.Transaction' apps/dao/config.go \ || fail "acceptance: WithConfigTx должен открывать настоящую транзакцию" grep -q 'service.UpdateConfigs(updates)' apps/controller/config.go \ || fail "acceptance: партия настроек должна применяться одним вызовом, а не по элементу" # Проверка обязана завершиться ДО первой записи. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("apps/controller/config.go", "utf8"); const validate = source.indexOf("service.ValidateConfigValue("); const apply = source.indexOf("service.UpdateConfigs(updates)"); if (validate < 0 || apply < 0) throw new Error("не найдены проверка и применение партии"); if (validate > apply) throw new Error("партия применяется раньше, чем проверяется"); ' || fail "acceptance: партия настроек обязана проверяться целиком до первой записи" } # Мёртвые ключи таблицы `config` не должны вернуться. run_dead_config_acceptance() { log_step "Acceptance: dead config keys stay removed" # Проверяется КОД: тесты перечисляют удалённые ключи, чтобы доказать их # отсутствие, а комментарии объясняют, почему ключей больше нет. И то и # другое обязано называть их по имени. local dead_key hits for dead_key in HYSTERIA2_ENABLE HYSTERIA2_CONFIG HYSTERIA2_TRAFFIC_TIME HYSTERIA2_CONFIG_REMARK; do hits="$(code_mentions_in "$dead_key" \ apps/model apps/router apps/controller apps/service apps/util apps/frontend/src)" [ -z "$hits" ] \ || fail "acceptance: удалённый ключ $dead_key вернулся в: $hits" done grep -q '006_drop_dead_config_keys' apps/dao/sqlite.go \ || fail "acceptance: миграция удаления мёртвых ключей пропала" # Серверный конфиг Hysteria читается только из файла: ветка «сначала SQLite» # была вторым источником истины и читалась ПЕРВОЙ. ! code_without_comments apps/service/config.go | grep -q 'Hysteria2Config)' \ || fail "acceptance: чтение серверного конфига снова обращается к таблице config" ! code_without_comments apps/service/hysteria2_export.go | grep -q 'Hysteria2Config)' \ || fail "acceptance: выгрузка серверного конфига снова обращается к таблице config" log_step "Acceptance: share URI remark is derived, not stored as dead state" grep -q 'func shareRemark' apps/service/hysteria2_api.go \ || fail "acceptance: имя профиля в share URI должно выводиться детерминированно" } # Гигиена зависимостей и bootstrap-учётных данных. run_dependency_hygiene_acceptance() { local package_dir="$1" log_step "Acceptance: bootstrap credentials are never invented or logged" # Ключевой канал утечки: сгенерированный пароль печатался в # /var/log/hy2xs/hy2xs-admin.log, а этот файл отдаётся кнопкой выгрузки. ! code_without_comments apps/dao/sqlite.go | grep -q 'Initial admin password' \ || fail "acceptance: bootstrap-пароль снова пишется в журнал" grep -q 'HY2XS_ADMIN_INITIAL_PASSWORD не задан' apps/dao/sqlite.go \ || fail "acceptance: отсутствие HY2XS_ADMIN_INITIAL_PASSWORD должно быть отказом старта" grep -q 'HY2XS_ADMIN_CON_PASS не задан' apps/dao/sqlite.go \ || fail "acceptance: отсутствие HY2XS_ADMIN_CON_PASS должно быть отказом создания bootstrap-пира" log_step "Acceptance: the admin log is sanitized on export, like the Hysteria one" grep -q 'func ExportAdminLog' apps/service/journal.go \ || fail "acceptance: журнал админки выгружается без санитайза" ! code_without_comments apps/controller/log.go | grep -q 'c.File(' \ || fail "acceptance: журнал снова отдаётся сырым файлом мимо санитайза" log_step "Acceptance: JWT stack is the maintained major line, with a fixed algorithm" local jwt_v3_hits jwt_v3_hits="$(code_mentions_in '"github.com/golang-jwt/jwt"' \ apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util)" [ -z "$jwt_v3_hits" ] \ || fail "acceptance: вернулась ветка golang-jwt/jwt v3 (GO-2025-3553 без исправленной версии) в: $jwt_v3_hits" grep -q 'golang-jwt/jwt/v5' apps/go.mod \ || fail "acceptance: golang-jwt/jwt/v5 отсутствует в графе зависимостей" grep -q 'jwt.WithValidMethods' apps/service/jwt.go \ || fail "acceptance: разбор токена должен ограничивать набор алгоритмов подписи" log_step "Acceptance: password storage has exactly one format" ! code_without_comments apps/util/encrypt.go | grep -q 'SHA224String' \ || fail "acceptance: вернулась проверка пароля по несолёному SHA-224" grep -q 'if !IsBcryptHash(storedHash)' apps/util/encrypt.go \ || fail "acceptance: VerifyPassword обязан отклонять всё, кроме bcrypt" log_step "Acceptance: the release is built with a dependency vulnerability gate" [ -f tools/build/lib/security.sh ] \ || fail "acceptance: модуль проверки зависимостей отсутствует" grep -q 'run_dependency_security_gate' tools/build/build.sh \ || fail "acceptance: сборка не запускает проверку зависимостей" grep -q '^GOVULNCHECK_VERSION=' versions.env \ || fail "acceptance: версия govulncheck должна быть частью контракта сборки" # По готовому tarball должно быть видно, что он проверялся — ровно как # hysteria_compat_gate. grep -q '^dependency_security_gate=true$' "$package_dir/metadata/package.env" \ || fail "acceptance: релизный пакет обязан собираться с включённой проверкой зависимостей" log_step "Acceptance: the npm gate covers the whole lock graph, not only production deps" # `--prod` не показывал уязвимости в vite/rollup, хотя они исполняются на # build-машине и порождают production-бандл. Ровно такой класс и был найден # (DOM clobbering в Rollup затрагивал генерируемый бандл). ! code_without_comments tools/build/lib/security.sh | grep -q 'audit --prod' \ || fail "acceptance: гейт зависимостей снова проверяет только production-подграф npm" code_without_comments tools/build/lib/security.sh \ | grep -q 'audit --audit-level "\$PNPM_AUDIT_LEVEL"' \ || fail "acceptance: гейт зависимостей обязан проверять весь lock-граф frontend" log_step "Acceptance: the release is built with a mandatory test gate" # SKIP_TESTS=true доходил до конца сборки и выдавал внешне неотличимый # production-артефакт: ни metadata, ни финальная приёмка не проверяли, что # тесты запускались. Глушил он при этом и `tsc --noEmit`, и `go vet`. local test_bypass_file for test_bypass_file in \ tools/build/build.sh \ tools/build/lib/package.sh \ tools/build/lib/security.sh \ tools/build/lib/versions.sh \ tools/build/lib/deps.sh \ tools/build/lib/verify.sh do [ -z "$(code_without_comments "$test_bypass_file" | grep -F -- 'SKIP_TESTS' || true)" ] \ || fail "acceptance: обход тестов SKIP_TESTS вернулся в $test_bypass_file" done ! grep -rq 'SKIP_TESTS' README.md docs tools/build/README.md \ || fail "acceptance: README/docs снова описывают обход тестов, которого нет в сборке; место для истории — CHANGELOG.md" grep -q '^tests_gate=true$' "$package_dir/metadata/package.env" \ || fail "acceptance: релизный пакет обязан собираться с пройденными тестами" # Утверждение обязано опираться на результат, а не на намерение. grep -q 'ORCHESTRATOR_TESTS_PASSED' tools/build/lib/package.sh \ || fail "acceptance: прогон тестов оркестратора не фиксируется результатом" grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \ || fail "acceptance: прогон тестов админки не фиксируется результатом" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8"); for (const [fn, flag] of [ ["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""], ["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""] ]) { const start = source.indexOf(fn); if (start < 0) throw new Error("не найдена функция " + fn); const rest = source.slice(start); // Границей тела служит закрывающая скобка в первой позиции строки. // Поиск регуляркой, а не подстрокой: файл может быть выгружен с CRLF. const end = rest.search(/\n\}[\r\n]/); const body = end < 0 ? rest : rest.slice(0, end); const set = body.indexOf(flag); const run = body.lastIndexOf("|| fail"); if (set < 0) throw new Error(fn + ": результат прогона не фиксируется"); if (set < run) throw new Error(fn + ": результат объявляется раньше, чем получен"); } ' || fail "acceptance: утверждение о пройденных тестах обязано следовать за прогоном" # У шага не должно быть обходов — ни объявленных, ни забытых. # # Раньше их было два: ALLOW_VULNERABLE_DEPENDENCIES=true писал в metadata # `accepted-risk`, SKIP_SECURITY_SCAN=true — `skipped`. Оба документировались # README и docs/02 как способ выпустить релиз, зная об уязвимости, и оба # гарантированно падали здесь же, строкой выше: приёмка требует буквально # `true`. Сборка проходила весь цикл и умирала на последнем шаге ради # операции, которую продукт в итоге запрещает. # # Противоречие закрыто в пользу строгой политики, и теперь это проверяется, а # не только описано. Ищется КОД: комментарий выше объясняет, почему обходов # нет, и обязан называть их по имени. # # Сканируются модули, которые могли бы обход вернуть, а НЕ весь tools/build. # Причина ровно та, из-за которой в этом файле дважды падала сборка на # корректном коде: скан по всему каталогу находил сам себя — строку # `for bypass_var in ALLOW_VULNERABLE_DEPENDENCIES SKIP_SECURITY_SCAN` ниже. # Это код, а не комментарий, поэтому code_without_comments тут не спасает. local bypass_var bypass_scan_file bypass_hits for bypass_var in ALLOW_VULNERABLE_DEPENDENCIES SKIP_SECURITY_SCAN; do for bypass_scan_file in \ tools/build/build.sh \ tools/build/lib/security.sh \ tools/build/lib/package.sh \ tools/build/lib/versions.sh \ tools/build/lib/deps.sh \ tools/build/lib/verify.sh do bypass_hits="$(code_without_comments "$bypass_scan_file" | grep -F -- "$bypass_var" || true)" [ -z "$bypass_hits" ] \ || fail "acceptance: обход проверки зависимостей $bypass_var вернулся в $bypass_scan_file: $bypass_hits" done done # И в документации тоже: описанный, но нерабочий аварийный выход — хуже, чем # его отсутствие. Именно из-за такого описания противоречие и прожило до # приёмки. # # У markdown нет строк-комментариев, поэтому code_without_comments здесь не # применим и правило простое: место для истории — CHANGELOG.md, он под скан не # попадает. README и docs описывают текущую политику, а не отменённую. ! grep -rq 'ALLOW_VULNERABLE_DEPENDENCIES\|SKIP_SECURITY_SCAN' README.md docs \ || fail "acceptance: README/docs снова описывают обход проверки зависимостей, которого нет в сборке; место для истории — CHANGELOG.md" # Единственное значение поля — true. `accepted-risk`/`skipped` не должны # вернуться даже как строковые литералы: их некому произвести. local dead_gate_value for dead_gate_value in accepted-risk skipped; do ! grep -rqF "DEPENDENCY_SECURITY_GATE=\"${dead_gate_value}\"" tools/build \ || fail "acceptance: у dependency_security_gate снова появилось значение ${dead_gate_value}" done log_step "Acceptance: versions.env is a developer contract, not only a build contract" # Сборка соблюдала versions.env, а машина разработчика не проверялась никак: # локальный Go 1.25 собирал проект успешно, пока релизный бинарь собирался на # 1.26.7 и наследовал ЕЁ stdlib. Проверялся не тот код, который уезжает в # production. local doctor_script for doctor_script in tools/dev/doctor.sh tools/dev/doctor.ps1; do [ -f "$doctor_script" ] \ || fail "acceptance: проверка среды разработки $doctor_script отсутствует" grep -q 'versions.env' "$doctor_script" \ || fail "acceptance: $doctor_script обязан читать контракт из versions.env, а не носить свои значения" done [ -x tools/dev/doctor.sh ] \ || fail "acceptance: tools/dev/doctor.sh не исполняемый" # Собственных версий у doctor быть не должно: второй список версий неизбежно # разъедется с versions.env. ! grep -qE '(GO|NODE|PNPM|BUN)_VERSION[[:space:]]*=[[:space:]]*[0-9]' tools/dev/doctor.sh \ || fail "acceptance: tools/dev/doctor.sh завёл собственные значения версий" log_step "Acceptance: the Go toolchain is on a supported line" # Go компилирует hy2xs-admin, поэтому его stdlib уезжает в production-бинарь. # На 1.21 govulncheck находил 17 вызываемых уязвимостей в одной только stdlib. local go_minor go_minor="$(grep -E '^GO_VERSION=' versions.env | head -n1 | cut -d= -f2 | cut -d. -f2)" [ "$go_minor" -ge 26 ] \ || fail "acceptance: GO_VERSION 1.${go_minor} вне поддерживаемой линии Go; production-бинарь унаследует её stdlib" } # PHASE 1 принадлежит оркестратору целиком. run_single_owner_acceptance() { local package_dir="$1" log_step "Acceptance: install.sh does not mutate the host at all" local mutation_hits mutation_hits="$(grep -nE '^[[:space:]]*(install|ln|cp|mv|rm|mkdir|chown|chmod|systemctl|apt-get|useradd|groupadd|nft|tee)[[:space:]]' \ "$package_dir/install.sh" || true)" [ -z "$mutation_hits" ] \ || fail "acceptance: install.sh must not mutate the host; PHASE 1 belongs to the orchestrator alone: $mutation_hits" grep -q 'exec .*install --package-dir' "$package_dir/install.sh" \ || fail "acceptance: install.sh must hand the mutating phase over via exec" log_step "Acceptance: the orchestrator owns its own bootstrap" [ -f orchestrator/src/steps/bootstrap.ts ] \ || fail "acceptance: the bootstrap step module is missing" grep -q 'ownership.bootstrapTouched' orchestrator/src/commands/install.ts \ || fail "acceptance: bootstrap must be covered by an ownership flag" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const flagAt = source.indexOf("ownership.bootstrapTouched = true"); const callAt = source.indexOf("await bootstrapRuntime("); const depsAt = source.indexOf("await installDeps("); if (flagAt < 0 || callAt < 0) throw new Error("bootstrap step is not wired into install"); if (flagAt > callAt) throw new Error("bootstrapTouched is raised after bootstrapRuntime"); if (callAt > depsAt) throw new Error("bootstrap must run before installDeps"); ' || fail "acceptance: bootstrap must be the first owned mutating step" log_step "Acceptance: bootstrap paths are declared once and are clean-host markers" grep -q 'ORCHESTRATOR_SYMLINK_PATH' orchestrator/src/steps/cleanHost.ts \ || fail "acceptance: clean-host must take bootstrap paths from the production profile" grep -qF '/usr/local/bin/hy2xs-orchestrator' orchestrator/src/config/profile.ts \ || fail "acceptance: the orchestrator symlink path must be declared in the profile" grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \ || fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker" log_step "Acceptance: clean-host is checked once, before the first mutation" # Регрессия: preflight вызывался дважды и оба раза проверял clean-host. # Ко второму разу на диске лежал собственный install-state.json, и каждая # чистая установка падала сразу после apt-get. ! grep -rq 'cleanHostPhase' orchestrator/src \ || fail "acceptance: the two-tier clean-host phase hack must not come back" grep -q 'checkCleanHost' orchestrator/src/steps/preflight.ts \ || fail "acceptance: preflight must take clean-host as an explicit decision" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const enabled = source.split("checkCleanHost: true").length - 1; const disabled = source.split("checkCleanHost: false").length - 1; if (enabled !== 1) throw new Error("clean-host must be requested exactly once, got " + enabled); if (disabled !== 1) throw new Error("the capabilities pass must opt out explicitly"); const at = source.indexOf("checkCleanHost: true"); const state = source.indexOf("await advanceInstallState("); if (at > state) throw new Error("clean-host is checked after the first install-state write"); ' || fail "acceptance: clean-host must be an entry condition, checked exactly once" log_step "Acceptance: diagnostics never block rollback" local command_file for command_file in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do grep -q 'catch (diagnosticsError)' "$command_file" \ || fail "acceptance: diagnostics must be best effort in $command_file" done "$BUN_BIN" -e ' const fs = require("node:fs"); for (const file of [ ["orchestrator/src/commands/install.ts", "await rollbackFailedInstall("], ["orchestrator/src/commands/reconfigure.ts", "await rollbackCurrentState()"] ]) { const source = fs.readFileSync(file[0], "utf8"); const diagnostics = source.indexOf("await diagnosticsCollect(options)"); const guard = source.indexOf("catch (diagnosticsError)"); const rollback = source.indexOf(file[1]); if (diagnostics < 0 || guard < 0 || rollback < 0) throw new Error("missing markers in " + file[0]); if (!(diagnostics < guard && guard < rollback)) { throw new Error("diagnostics is not guarded before rollback in " + file[0]); } } ' || fail "acceptance: a diagnostics failure must never cancel the rollback" log_step "Acceptance: persisting the failure state never blocks the rollback" # Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше. # Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она # падает ровно на заполненном диске и read-only ФС — там, где откат нужнее # всего. Пока она стояла обычным await, её отказ уносил управление наружу # мимо снятия firewall и остановки развёрнутых сервисов. [ -f orchestrator/src/lib/rollback.ts ] \ || fail "acceptance: модуль обязательного отката отсутствует" local rollback_command for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do grep -q 'persistFailureState(' "$rollback_command" \ || fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort" done "$BUN_BIN" -e ' const fs = require("node:fs"); for (const [file, write] of [ ["orchestrator/src/commands/install.ts", "await advanceInstallState("], ["orchestrator/src/commands/reconfigure.ts", "await markPhase("] ]) { const source = fs.readFileSync(file, "utf8"); const handler = source.slice(source.indexOf("} catch (error) {")); if (handler.length === 0) throw new Error("не найден обработчик ошибки в " + file); if (handler.includes(write)) { throw new Error("незащищённая запись состояния отказа в обработчике " + file); } const guarded = handler.indexOf("await persistFailureState("); if (guarded < 0) throw new Error("состояние отказа не обёрнуто в " + file); } ' || fail "acceptance: отказ записи состояния обязан продолжать откат, а не отменять его" log_step "Acceptance: rollback stages are independent, not a cancellable chain" # Каждая стадия отката — systemctl/cp/rm/nft, то есть умеет упасть сама. # Цепочка `await` означала, что отказ первой отменяет все следующие: в # reconfigure сервер оставался и с применённым сломанным firewall, и без # восстановленных из /etc/hy2xs/backups конфигов одновременно. grep -q 'export async function runRollbackStages' orchestrator/src/lib/rollback.ts \ || fail "acceptance: у отката нет механизма независимых стадий" for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do grep -q 'await runRollbackStages(stages)' "$rollback_command" \ || fail "acceptance: откат в $rollback_command снова выполняется отменяемой цепочкой" done # Команды восстановления не имеют права ГЛУШИТЬ собственные ошибки. # # Инвариант здесь обратный тому, что стоял раньше. До появления # runRollbackStages каждая команда несла `|| true` — это была единственная # защита от того, что отказ одной оборвёт следующие. Теперь непрерывность # обеспечивает стадийный раннер, а `|| true` стал маскировкой: стадия не # могла сообщить, что восстановление на самом деле не выполнилось, и # «restore configuration» никогда не попадала в список отказавших. "$BUN_BIN" -e ' const fs = require("node:fs"); const checks = [ ["orchestrator/src/commands/reconfigure.ts", "function restoreStages(", "async function rollbackCurrentState"], ["orchestrator/src/commands/reconfigure.ts", "async function backupCurrentState", "async function readBackupManifest"], ["orchestrator/src/commands/install.ts", "async function rollbackFailedInstall", "export async function install"], ["orchestrator/src/steps/firewall.ts", "async function backupFirewallState", "async function readNftablesServiceState"], // Остановка rollback guard — та же категория. Скан её не покрывал, // поэтому `systemctl stop ... || true` внутри stopRollbackTimer прожил // дольше всех остальных заглушённых ошибок отката. ["orchestrator/src/steps/firewall.ts", "async function stopRollbackGuard", "export async function disarmFirewallRollback"], // null как правая граница означает «до конца файла»: rollbackFirewallNow // объявлена последней, и придумывать ей искусственный якорь означало бы // ломать скан при каждой перестановке функций. ["orchestrator/src/steps/firewall.ts", "export async function rollbackFirewallNow", null] ]; for (const [file, from, to] of checks) { const source = fs.readFileSync(file, "utf8"); const start = source.indexOf(from); const end = to === null ? source.length : source.indexOf(to); if (start < 0 || end < 0) throw new Error("не найдены границы " + from + " в " + file); const muted = source.slice(start, end).split(/\r?\n/) .filter((line) => line.includes("runMutatingVisible`")) .filter((line) => line.includes("|| true") || line.includes("2>/dev/null")); if (muted.length) { throw new Error(from + " в " + file + " скрывает ошибки:\n" + muted.join("\n")); } } ' || fail "acceptance: команды отката и резервного копирования обязаны сообщать о своих отказах" log_step "Acceptance: rollback artifacts outlive the durable commit" # Ошибка порядка фиксации. cancelFirewallRollback снимала таймер И удаляла # резервные копии, а вызывалась ДО долговечной записи `phase: installed`. # Отказ этой записи приводил в обработчик ошибки, обязательный откат честно # запускался и сообщал «no HY2XS rollback markers found»: откатывать было # уже нечем. grep -q 'export async function disarmFirewallRollback' orchestrator/src/steps/firewall.ts \ || fail "acceptance: снятие таймера автоотката не отделено от удаления резервных копий" grep -q 'export async function cleanupFirewallRollback' orchestrator/src/steps/firewall.ts \ || fail "acceptance: удаление резервных копий firewall не выделено в отдельную операцию" ! grep -rq 'cancelFirewallRollback' orchestrator/src \ || fail "acceptance: объединённая cancelFirewallRollback вернулась; она удаляла копии до фиксации успеха" "$BUN_BIN" -e ' const fs = require("node:fs"); for (const [file, installed] of [ ["orchestrator/src/commands/install.ts", "await advanceInstallState(context, ownership, \"installed\")"], ["orchestrator/src/commands/reconfigure.ts", "await markPhase(context, \"installed\")"] ]) { const source = fs.readFileSync(file, "utf8"); const disarm = source.indexOf("disarmFirewallRollback(context)"); const commit = source.indexOf(installed); const cleanup = source.indexOf("cleanupFirewallRollback(context)"); if (disarm < 0 || commit < 0 || cleanup < 0) throw new Error("не найдены шаги фиксации в " + file); if (!(disarm < commit && commit < cleanup)) { throw new Error("порядок обязан быть disarm -> durable installed -> cleanup в " + file); } } ' || fail "acceptance: данные отката обязаны переживать долговечную фиксацию успеха" # Восстановление firewall не имеет права удалить копии, не восстановив. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8"); const body = source.slice(source.indexOf("export async function rollbackFirewallNow")); const failures = body.indexOf("if (failures.length > 0)"); const preserved = body.indexOf("manual recovery data preserved at"); const cleanup = body.indexOf("await cleanupFirewallBackupFiles(opId)"); if (failures < 0 || preserved < 0 || cleanup < 0) throw new Error("не найдены ветки восстановления"); if (!(failures < preserved && preserved < cleanup)) { throw new Error("резервные копии удаляются раньше проверки успеха восстановления"); } ' || fail "acceptance: копии удаляются только после подтверждённого восстановления firewall" log_step "Acceptance: reconfigure backups are operation-scoped and proven" # Копии всех операций лежали одним общим набором *.bak. При неудачном # копировании в операции B её откат восстанавливал файлы, сохранённые # операцией A, — возвращал сервер в более старое состояние и выглядел # успешным. [ -f orchestrator/src/lib/backupManifest.ts ] \ || fail "acceptance: манифест резервной копии отсутствует" grep -q 'backupDir(opId)' orchestrator/src/commands/reconfigure.ts \ || fail "acceptance: копия reconfigure снова не привязана к операции" grep -q 'parseManifest(await readText(path), opId)' orchestrator/src/commands/reconfigure.ts \ || fail "acceptance: откат читает копию без проверки принадлежности операции" ! grep -qF '/etc/hy2xs/backups/config.yaml.bak' orchestrator/src/commands/reconfigure.ts \ || fail "acceptance: вернулся общий набор *.bak, смешивающий операции" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/reconfigure.ts", "utf8"); const backup = source.indexOf("await backupCurrentState(context)"); for (const mutation of ["await generateConfig(context)", "await applyFirewall(context)"]) { const at = source.indexOf(mutation); if (at < 0) throw new Error("не найден шаг " + mutation); if (backup > at) throw new Error("копия снимается после мутации " + mutation); } const verify = source.indexOf("await fileExists(target)"); if (verify < 0) throw new Error("создание копии не проверяется"); ' || fail "acceptance: полная проверенная копия обязана предшествовать первой мутации" log_step "Acceptance: the install-state directory entry is durable too" # writeTextAtomic синхронизирует файл и его каталог. Но при первой установке # /var/lib/hy2xs создаётся прямо сейчас, и запись «hy2xs» в /var/lib # остаётся несинхронизированной: после потери питания мог исчезнуть весь # каталог вместе с маркером. grep -q 'return { created: !existed }' orchestrator/src/lib/fs.ts \ || fail "acceptance: ensureDir не сообщает о фактическом создании каталога" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8"); const start = source.indexOf("export async function ensureDir"); const body = source.slice(start, source.indexOf("export async function writeTextAtomic")); if (!/if \(!existed\) \{\s*\n\s*await syncDirectory\(dirname\(path\)\)/.test(body)) { throw new Error("родительский каталог не синхронизируется при создании"); } ' || fail "acceptance: создание каталога маркера обязано быть долговечным" log_step "Acceptance: doctor never triggers a peer state write over HTTP" # Guard защищает только процесс оркестратора и не способен запретить побочный # эффект в другом процессе. Успешная machine-auth заставляет админку # выполнить UPDATE peer.last_connection_at, поэтому проба с ВАЛИДНЫМ паролем # ограничена режимом install, а doctor работает в режиме reconfigure. "$BUN_BIN" -e ' const fs = require("node:fs"); const smoke = fs.readFileSync("orchestrator/src/steps/smoke.ts", "utf8"); const guard = smoke.indexOf("if (context.mode === \"install\")"); const validAuth = smoke.indexOf("\"auth valid credentials\""); const readsSecret = smoke.indexOf("cut -d= -f2-"); if (guard < 0 || validAuth < 0 || readsSecret < 0) throw new Error("не найдена проба успешной авторизации"); if (validAuth < guard || readsSecret < guard) { throw new Error("проба с валидными учётными данными вышла за пределы режима install"); } if (smoke.split("\"auth valid credentials\"").length - 1 !== 1) { throw new Error("проб успешной авторизации больше одной"); } const doctor = fs.readFileSync("orchestrator/src/commands/doctor.ts", "utf8"); if (!doctor.includes("mode: \"reconfigure\"") || doctor.includes("mode: \"install\"")) { throw new Error("doctor работает в режиме, который запускает пробу успешной авторизации"); } ' || fail "acceptance: диагностика не имеет права менять состояние пира через HTTP" log_step "Acceptance: the install-state marker has exactly one durable writer" # Раньше writeText в install и writeTextAtomic в reconfigure давали одному # файлу две разные гарантии, причём слабейшую — команде, которая его создаёт. [ -f orchestrator/src/lib/installStateWriter.ts ] \ || fail "acceptance: модуль записи маркера установки отсутствует" for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do grep -q 'await persistInstallState(record)' "$rollback_command" \ || fail "acceptance: $rollback_command пишет маркер установки мимо единственного писателя" ! grep -q 'writeText(INSTALL_STATE_PATH' "$rollback_command" \ || fail "acceptance: неатомарная перезапись маркера установки вернулась в $rollback_command" done # Долговечность, а не только атомарность: rename без fsync после потери # питания штатно отдаёт нулевой файл, а маркер — это метаданные восстановления. grep -q 'await handle.sync()' orchestrator/src/lib/fs.ts \ || fail "acceptance: временный файл подставляется без fsync данных" grep -q 'async function syncDirectory' orchestrator/src/lib/fs.ts \ || fail "acceptance: каталог не синхронизируется после подстановки" # Владелец обязан выставляться ДО подстановки: иначе существует окно, в # котором файл уже виден по целевому пути с чужими правами. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8"); const start = source.indexOf("export async function writeTextAtomic"); if (start < 0) throw new Error("writeTextAtomic отсутствует"); const body = source.slice(start); const chown = body.indexOf("chownByName(tmp"); const sync = body.indexOf("await handle.sync()"); const rename = body.indexOf("await rename(tmp, path)"); if (chown < 0 || sync < 0 || rename < 0) throw new Error("не найдены шаги атомарной записи"); if (!(chown < sync && sync < rename)) { throw new Error("порядок обязан быть: права/владелец -> fsync -> rename"); } ' || fail "acceptance: атомарная запись обязана выставлять владельца и синхронизировать до подстановки" # Каждый production-вызов обязан объявлять владельца явно: параметр # необязателен только ради тестов, которые пишут во временный каталог. "$BUN_BIN" -e ' const fs = require("node:fs"); const path = require("node:path"); const offenders = []; const walk = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full); continue; } if (!entry.name.endsWith(".ts")) continue; const source = fs.readFileSync(full, "utf8"); // Ищутся ВЫЗОВЫ, а не объявление: у самой функции параметр owner // необязателен, потому что тесты пишут во временный каталог, где // выставить root:root нельзя. let at = source.indexOf("await writeTextAtomic("); while (at >= 0) { const call = source.slice(at, at + 400); if (!call.includes("owner:")) offenders.push(full); at = source.indexOf("await writeTextAtomic(", at + 1); } } }; walk("orchestrator/src"); if (offenders.length) { throw new Error("вызовы без владельца: " + [...new Set(offenders)].join(", ")); } ' || fail "acceptance: production-запись обязана объявлять владельца файла" log_step "Acceptance: reconfigure classifies by ownership, not by message text" ! grep -qF '.test(message)' orchestrator/src/commands/reconfigure.ts \ || fail "acceptance: reconfigure must not classify failures by matching the error text" grep -q 'classifyReconfigureFailure(ownership)' orchestrator/src/commands/reconfigure.ts \ || fail "acceptance: reconfigure failure classification must be ownership-driven" } # Каналы утечки секретов: Config API и журналы. run_secret_channel_acceptance() { log_step "Acceptance: config API is an allowlist, not a denylist" grep -q 'IsPublicReadableConfigKey' apps/controller/config.go \ || fail "acceptance: config reads must go through the allowlist" grep -q 'IsPublicWritableConfigKey' apps/controller/config.go \ || fail "acceptance: config writes must go through the allowlist" # Ищется регистрация и вызов, а не имя: router_test.go обязан УПОМИНАТЬ # getConfig — он проверяет, что маршрут не вернулся. ! grep -rq 'controller.GetConfig' apps/router \ || fail "acceptance: the arbitrary-key getConfig route came back" ! grep -q 'func GetConfig(' apps/controller/config.go \ || fail "acceptance: the arbitrary-key getConfig handler came back" ! grep -rq 'export function getConfigApi' apps/frontend/src \ || fail "acceptance: the frontend client for getConfig came back" ! grep -rq 'getConfigApi(' apps/frontend/src \ || fail "acceptance: something still calls the removed getConfig client" local secret_key for secret_key in JwtSecret PeerSecretKey PeerSecretEncryptionKey Hysteria2TrafficStatsSecret; do grep -q "${secret_key}," apps/model/constant/config.go \ || fail "acceptance: ${secret_key} must be declared an internal config key" done # Секреты не имеют права оказаться в allowlist ни на чтение, ни на запись. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("apps/model/constant/config.go", "utf8"); const start = source.indexOf("publicReadableConfigKeys"); const end = source.indexOf("func IsPublicReadableConfigKey"); const body = source.slice(start, end); for (const key of ["JwtSecret", "PeerSecretKey", "PeerSecretEncryptionKey", "Hysteria2TrafficStatsSecret", "Hysteria2Config"]) { if (body.includes(key + ":")) throw new Error(key + " is in the public allowlist"); } ' || fail "acceptance: no secret key may appear in the public config allowlist" log_step "Acceptance: request logging never carries query values" # Проверяется КОД, а не упоминание: комментарий, объясняющий, почему логгер # больше не пишет RequestURI, обязан быть разрешён. Поэтому строки # комментариев отбрасываются до поиска. local log_code server_code log_code="$(grep -v '^[[:space:]]*//' apps/middleware/log.go || true)" server_code="$(grep -v '^[[:space:]]*//' apps/cmd/server.go || true)" printf '%s\n' "$log_code" | grep -q 'c.Request.URL.Path' \ || fail "acceptance: the request logger must log the path, not RequestURI" ! printf '%s\n' "$log_code" | grep -q 'RequestURI' \ || fail "acceptance: RequestURI carries the machine token in its query string" ! grep -rq 'ReqUri' apps/model/vo apps/frontend/src/api \ || fail "acceptance: the reqUri log field came back" log_step "Acceptance: the admin has exactly one HTTP log channel" ! printf '%s\n' "$server_code" | grep -q 'gin.Default()' \ || fail "acceptance: gin.Default() logs the query string to stdout and then to journald" printf '%s\n' "$server_code" | grep -q 'gin.New()' \ || fail "acceptance: the admin engine must be built with gin.New()" printf '%s\n' "$server_code" | grep -q 'gin.Recovery()' \ || fail "acceptance: gin.New() must still install the recovery middleware" log_step "Acceptance: exported logs are sanitized on both sides" grep -q 'SanitizeLogText' apps/service/journal.go \ || fail "acceptance: exported Hysteria journal must be sanitized" grep -q 'redactLogText' orchestrator/src/commands/diagnostics.ts \ || fail "acceptance: diagnostics bundle must sanitize collected journals" grep -q 'journal-admin.log' orchestrator/src/commands/diagnostics.ts \ || fail "acceptance: the admin journal must be part of the sanitized set" log_step "Acceptance: machine token comparison is constant time" grep -q 'subtle.ConstantTimeCompare' apps/middleware/machine_auth.go \ || fail "acceptance: the machine token must be compared in constant time" } # Импорт пиров: одна транзакция и ровно один JSON-документ. run_atomic_import_acceptance() { log_step "Acceptance: peer import is a single database transaction" grep -q 'WithPeerTx' apps/service/peer.go \ || fail "acceptance: peer import must apply the whole batch in one transaction" grep -q 'func WithPeerTx' apps/dao/peer.go \ || fail "acceptance: the peer transaction boundary is missing from the dao layer" grep -q 'sqliteDB.Transaction' apps/dao/peer.go \ || fail "acceptance: WithPeerTx must open a real database transaction" # Применение обязано идти по tx, а не по глобальному соединению. # # Регрессия приёмки. Здесь стояло `source.slice(start)` — то есть «весь файл # от начала applyPeerImportEntry и ДО КОНЦА». В этот кусок попадали все # функции, объявленные ниже, а среди них ExistPeerName (dao.GetPeer) и # UpdatePeerLastConnectionAt (dao.UpdatePeer) — обычные операции вне импорта, # которым глобальное соединение положено. Проверка гарантированно падала на # корректном коде и не была замечена только потому, что сборка до неё не # доходила: раньше неё падал versions contract на собственном скане '/hui'. # # Границей тела функции служит следующее объявление верхнего уровня. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8"); const start = source.indexOf("func applyPeerImportEntry"); if (start < 0) throw new Error("applyPeerImportEntry is missing"); const rest = source.slice(start + 1); const end = rest.indexOf("\nfunc "); const body = end < 0 ? rest : rest.slice(0, end); for (const call of ["dao.GetPeer(", "dao.SavePeer(", "dao.UpdatePeer("]) { if (body.includes(call)) throw new Error("peer import still writes outside the transaction: " + call); } ' || fail "acceptance: peer import must not bypass the transaction" log_step "Acceptance: peer import accepts exactly one JSON document" grep -q 'io.EOF' apps/controller/peer.go \ || fail "acceptance: peer import must verify that nothing follows the JSON document" grep -q 'exactly one JSON document' apps/controller/peer.go \ || fail "acceptance: the trailing-document refusal must be explicit" log_step "Acceptance: peer export offers both modes in the UI" grep -q 'includeSecrets' apps/frontend/src/api/peer/index.ts \ || fail "acceptance: the UI must be able to request a secrets-bearing backup" grep -q 'handleExportBackup' apps/frontend/src/views/peer/list/index.vue \ || fail "acceptance: the backup export button is missing" grep -q 'exportBackupConfirm' apps/frontend/src/views/peer/list/index.vue \ || fail "acceptance: a secrets-bearing export must require explicit confirmation" local locale for locale in ru en; do grep -q 'exportBackupConfirm' "apps/frontend/src/lang/package/${locale}.ts" \ || fail "acceptance: the backup warning is missing from the ${locale} locale" done } # Границы транзакции операции: снятие guard'а и взаимное исключение операций. run_transaction_boundary_acceptance() { log_step "Acceptance: disarming the firewall guard is proven, not announced" # Снятие автоматического отката было утверждением, а не фактом: # # systemctl stop .timer .service || true # -> "firewall rollback timer disarmed" # -> phase=installed # # Отказ остановки стирался, и взведённый таймер мог вернуть прежний firewall # уже после долговечной записи успеха. grep -q 'export function buildAutoRollbackScript' orchestrator/src/steps/firewall.ts \ || fail "acceptance: скрипт автоотката снова собирается на месте вместо отдельной проверяемой функции" grep -q 'auto-rollback-fired' orchestrator/src/steps/firewall.ts \ || fail "acceptance: маркер срабатывания guard'а отсутствует" ! grep -q 'stopRollbackTimer' orchestrator/src/steps/firewall.ts \ || fail "acceptance: вернулась stopRollbackTimer, снимавшая guard без доказательства" grep -q 'export async function runMutatingStatus' orchestrator/src/lib/process.ts \ || fail "acceptance: нет раннера, возвращающего код возврата вместо броска; снятие guard'а снова требует '|| true'" # Скрипт автоотката проверяется как ТЕКСТ: скан заглушённых ошибок смотрит # только на строки с runMutatingVisible и до содержимого скрипта не достаёт. "$BUN_BIN" -e ' const { buildAutoRollbackScript } = await import("./orchestrator/src/steps/firewall.ts"); const script = buildAutoRollbackScript("2026-01-01T00-00-00.000Z"); const marker = script.indexOf("auto-rollback-fired"); const prepared = script.indexOf("$root/prepared"); if (marker < 0 || prepared < 0) throw new Error("в скрипте нет маркера срабатывания или проверки prepared"); if (marker > prepared) throw new Error("маркер срабатывания создаётся не первым действием"); for (const masked of ["|| true", "2>/dev/null", ">/dev/null 2>&1"]) { if (script.includes(masked)) throw new Error("скрипт автоотката снова маскирует ошибки: " + masked); } if (!script.includes("exit \"$rc\"")) throw new Error("скрипт автоотката не возвращает накопленный код"); const failures = script.split("auto-rollback: failed").length - 1; const raised = script.split("rc=1").length - 1; if (failures === 0 || failures !== raised) { throw new Error("не каждый сообщённый отказ поднимает код возврата: " + failures + " != " + raised); } // Создание маркера обязано входить в учёт rc. Инвариант фиксации // "маркера нет и юниты inactive => guard не сработал" верен только при // условии, что guard способен маркер записать. Пока rc=0 стояло ПОСЛЕ // создания маркера, отказ записи не влиял ни на что: скрипт восстанавливал // firewall, завершался нулём, и операция фиксировала успех после реально // сработавшего отката. const rcInit = script.indexOf("rc=0"); if (rcInit < 0 || rcInit > marker) throw new Error("rc объявляется после создания маркера"); if (script.includes("exit 0")) throw new Error("ранний выход теряет накопленный код возврата"); if (!script.includes("exit \"$rc\"")) throw new Error("ранний выход не возвращает rc"); // touch, а не `: >file`: двоеточие — special builtin POSIX, и ошибка // перенаправления на нём обязана завершить неинтерактивный shell целиком. // В dash, который на Debian и есть /bin/sh, скрипт умер бы ДО // восстановления firewall. if (script.includes(": >\"$root/auto-rollback-fired\"")) { throw new Error("маркер создаётся перенаправлением на special builtin"); } // ExecStop у nftables.service делает `nft flush ruleset`: остановка сервиса // внутри guard стёрла бы только что восстановленные правила. if (script.includes("systemctl")) throw new Error("guard трогает состояние nftables.service"); ' || fail "acceptance: скрипт автоматического отката firewall нарушает свой контракт" # Снятие guard'а обязано опираться на наблюдаемое состояние юнитов и на # маркер, а не на код возврата systemctl: для уже отработавшего транзиентного # юнита `systemctl stop` возвращает 5 — исход, неотличимый от успеха. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8"); const start = source.indexOf("async function stopRollbackGuard"); const end = source.indexOf("export async function disarmFirewallRollback"); if (start < 0 || end < 0) throw new Error("не найдена функция снятия guard'"'"'а"); const body = source.slice(start, end); const before = body.indexOf("before stopping the rollback guard"); const stop = body.indexOf("runMutatingStatus`systemctl stop"); const after = body.indexOf("after stopping the rollback guard"); if (before < 0 || stop < 0 || after < 0) throw new Error("маркер проверяется не с обеих сторон остановки"); if (!(before < stop && stop < after)) throw new Error("порядок обязан быть маркер -> stop -> маркер"); if (!body.includes("readUnitProperty(target, \"ActiveState\")")) { throw new Error("снятие guard'"'"'а не подтверждается состоянием юнитов"); } if (!source.includes("const GUARD_STOPPED_STATES_FOR_COMMIT = [\"inactive\"] as const")) { throw new Error("на пути фиксации успеха допустимо не только inactive"); } ' || fail "acceptance: снятие guard'а обязано быть доказуемым" log_step "Acceptance: a fired guard forbids the durable commit" # Классификация по ТИПУ ошибки, а не по её тексту: разбор сообщения # регулярным выражением уже убирали и из install, и из reconfigure. grep -q 'export class FirewallGuardFiredError' orchestrator/src/steps/firewall.ts \ || fail "acceptance: сработавший guard не имеет собственного типа ошибки" grep -q 'error instanceof FirewallGuardFiredError' orchestrator/src/commands/install.ts \ || fail "acceptance: install не отличает сработавший guard от прочих отказов" grep -q 'error instanceof FirewallGuardFiredError' orchestrator/src/commands/reconfigure.ts \ || fail "acceptance: reconfigure не отличает сработавший guard от прочих отказов" grep -q 'classifyFailure(ownership, phase, error)' orchestrator/src/commands/install.ts \ || fail "acceptance: классификация отказа install не получает саму ошибку" log_step "Acceptance: smoke verifies the effective firewall, not only its syntax" # `nft -c` разбирает текущий файл, каким бы он ни был. Откатившийся прежний # ruleset проходил эту проверку зелёным. grep -q 'assertEffectiveFirewallIsOurs' orchestrator/src/steps/smoke.ts \ || fail "acceptance: smoke не сверяет эффективный firewall с конфигурацией операции" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8"); const syntax = source.indexOf("nft -c -f /etc/nftables.conf"); const semantic = source.indexOf("assertEffectiveFirewallIsOurs(context)"); if (syntax < 0 || semantic < 0) throw new Error("не найдены проверки firewall в smoke"); if (semantic < syntax) throw new Error("семантическая проверка идёт раньше разбора файла"); const firewall = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8"); const start = firewall.indexOf("export async function assertEffectiveFirewallIsOurs"); const end = firewall.indexOf("function firewallRollbackIsInactive"); const body = firewall.slice(start, end); for (const claim of ["await renderHy2xsNft(context)", "hy2xs-managed", "nft list table inet hy2xs"]) { if (!body.includes(claim)) throw new Error("проверка эффективного firewall не сверяет: " + claim); } // Проверка выполняется и в doctor, то есть под read-only guard. if (/runMutating[A-Za-z]*[`(]/.test(body)) throw new Error("проверка эффективного firewall мутирует систему"); ' || fail "acceptance: проверка эффективного firewall нарушает свой контракт" log_step "Acceptance: firewall candidates and nftables.service state do not outlive the operation" grep -q 'async function cleanupFirewallCandidates' orchestrator/src/steps/firewall.ts \ || fail "acceptance: candidate-файлы firewall снова остаются на диске навсегда" grep -q 'renderNftablesServiceState' orchestrator/src/steps/firewall.ts \ || fail "acceptance: состояние nftables.service не сохраняется и не восстанавливается откатом" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8"); const body = source.slice(source.indexOf("export async function rollbackFirewallNow")); const unitFile = body.indexOf("restore nftables.service unit file state"); const inactive = body.indexOf("restore nftables.service inactive state"); const ruleset = body.indexOf("apply restored ruleset"); if (unitFile < 0 || inactive < 0 || ruleset < 0) throw new Error("не найдены стадии восстановления сервиса"); // ExecStop у nftables.service делает `nft flush ruleset`, поэтому // восстановление состояния сервиса обязано идти ДО применения ruleset. if (!(unitFile < inactive && inactive < ruleset)) { throw new Error("остановка сервиса идёт после применения ruleset и сотрёт его"); } ' || fail "acceptance: порядок восстановления firewall стирает восстановленные правила" log_step "Acceptance: lifecycle operations are serialized by an exclusive lock" # install-state.json замком не является: два одновременных reconfigure писали # одни и те же production paths, а уникальные op-id разделяли только копии. [ -f orchestrator/src/lib/operationLock.ts ] \ || fail "acceptance: модуль взаимного исключения операций отсутствует" grep -q '/run/lock/hy2xs-orchestrator.lock' orchestrator/src/lib/operationLock.ts \ || fail "acceptance: замок операций не объявлен в /run/lock" grep -q 'assertMutationAllowed' orchestrator/src/lib/operationLock.ts \ || fail "acceptance: захват замка не спрашивает разрешения у read-only guard'а" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/cli.ts", "utf8"); for (const command of ["install", "reconfigure", "repair", "doctor"]) { if (!source.includes("await withOperationLock(\"" + command + "\", () => " + command + "(options))")) { throw new Error("команда выполняется без замка: " + command); } if (source.includes("await " + command + "(options)")) { throw new Error("команда вызывается мимо замка: " + command); } } // Отказ обязан произойти до первой проверки PHASE 0, а не после exec. const assertAt = source.indexOf("await assertNoOperationInProgress(\"install preflight\")"); const preflightAt = source.indexOf("await preflightInstall(options)"); if (assertAt < 0 || preflightAt < 0) throw new Error("PHASE 0 не проверяет идущую операцию"); if (assertAt > preflightAt) throw new Error("PHASE 0 проверяет замок после собственных проверок"); // status и diagnostics нужны в том числе во время долгой операции. for (const observer of ["status", "diagnostics"]) { if (source.includes("withOperationLock(\"" + observer + "\"")) { throw new Error("наблюдающая команда берёт эксклюзивный замок: " + observer); } } if (!source.includes("await describeOperationInProgress()")) { throw new Error("наблюдающие команды не сообщают об идущей операции"); } ' || fail "acceptance: политика взаимного исключения операций нарушена" # Замок обязан сниматься при любом завершении: обрыв SSH (SIGHUP) не имеет # права заблокировать сервер до перезагрузки. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/lib/operationLock.ts", "utf8"); for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { if (!source.includes(signal)) throw new Error("замок не снимается по сигналу " + signal); } if (!source.includes("process.on(\"exit\"")) throw new Error("замок не снимается при выходе процесса"); if (!source.includes("} finally {")) throw new Error("замок снимается не в finally"); // Снятие замка мёртвого держателя обязано идти через увод файла со сверкой // nonce: unlink на месте способен снять уже живой замок. if (!source.includes("async function reclaimStaleLock")) { throw new Error("нет безопасного переиспользования замка мёртвого держателя"); } if (!source.includes("stolen.nonce !== observed.nonce")) { throw new Error("переиспользование замка не сверяет, что уводит именно мёртвый замок"); } ' || fail "acceptance: жизненный цикл замка операций нарушен" log_step "Acceptance: a lifecycle operation waits for the previous one to become quiescent" # Стык двух защитных механизмов. Замок защищает production paths, пока жив # процесс-держатель; rollback guard firewall — отдельный systemd-объект, # переживающий свой процесс. Аварийно умершая операция оставляла вооружённый # guard, который возвращал прежний firewall уже посреди следующей операции. # Уникальные op-id тут не помогают: каталоги копий разные, а # /etc/nftables.conf и ruleset в ядре общие. grep -q 'export async function assertNoPendingRollbackGuard' orchestrator/src/steps/firewall.ts \ || fail "acceptance: нет барьера покоя перед новой операцией жизненного цикла" grep -q 'export class PendingRecoveryError' orchestrator/src/steps/firewall.ts \ || fail "acceptance: у отказа по незавершённому восстановлению нет собственного типа" # «Guard вооружён» и «состояние guard'а неизвестно» — разные утверждения, и # оператору по ним нужны разные действия: подождать окно отката либо чинить # systemd. Один тип на оба означал бы, что различие существует только в тексте. grep -q 'export class GuardStateUnknownError' orchestrator/src/steps/firewall.ts \ || fail "acceptance: у недоказуемого состояния guard'а нет собственного типа" "$BUN_BIN" -e ' const fs = require("node:fs"); const cli = fs.readFileSync("orchestrator/src/cli.ts", "utf8"); if (!cli.includes("{ barrier: assertNoPendingRollbackGuard }")) { throw new Error("захват замка идёт без барьера покоя"); } // Единственное употребление withOperationLock — внутри // runLifecycleOperation: иначе появился бы путь захвата мимо барьера. const direct = cli.split("withOperationLock(").length - 1; if (direct !== 1) throw new Error("замок берётся мимо runLifecycleOperation: " + direct + " употреблений"); if (!cli.includes("await assertNoPendingRollbackGuard()")) { throw new Error("PHASE 0 не проверяет вооружённый guard предыдущей операции"); } // Барьер обязан вызываться ДО захвата и ещё раз ПОСЛЕ: между этими // моментами умирающая предыдущая операция успевает вооружить guard. const lock = fs.readFileSync("orchestrator/src/lib/operationLock.ts", "utf8"); const acquire = lock.slice(lock.indexOf("export async function acquireOperationLock")); const before = acquire.indexOf("await options.barrier?.()"); const write = acquire.indexOf("await writeLockFile(path, record)"); const after = acquire.indexOf("await options.barrier()"); if (before < 0 || write < 0 || after < 0) throw new Error("барьер проверяется не с обеих сторон захвата"); if (!(before < write && write < after)) throw new Error("порядок обязан быть барьер -> захват -> барьер"); if (!acquire.slice(after).includes("releaseSync(path, record.nonce)")) { throw new Error("отказ второй проверки оставляет замок за собой"); } // Покой перечисляется БЕЛЫМ списком. Чёрный объявлял безопасным любое // состояние, которого автор не назвал, — включая те, которых он не знал: // systemd 257 знает maintenance и refreshing помимо перечислявшихся. const firewall = fs.readFileSync("orchestrator/src/steps/firewall.ts", "utf8"); if (!firewall.includes("const GUARD_QUIESCENT_ACTIVE_STATES = [\"inactive\", \"failed\"] as const")) { throw new Error("политика покоя guard изменилась без пересмотра барьера"); } if (firewall.includes("GUARD_PENDING_STATES")) { throw new Error("вернулся чёрный список состояний: неизвестное состояние снова считается покоем"); } // Отказ запроса к systemd — отсутствие наблюдения, а не наблюдение покоя. // Прежний код возвращал пустой список, то есть принимал невозможность // получить доказательство за положительный результат. const inspectStart = firewall.indexOf("export async function inspectRollbackGuard"); const inspectEnd = firewall.indexOf("export async function assertNoPendingRollbackGuard"); if (inspectStart < 0 || inspectEnd < inspectStart) { throw new Error("нет единого наблюдателя состояния guard перед барьером"); } const inspect = firewall.slice(inspectStart, inspectEnd); if (inspect.includes("return [];")) { throw new Error("барьер снова fail-open при отказе systemctl"); } for (const outcome of ["kind: \"unknown\"", "kind: \"pending\"", "kind: \"quiescent\""]) { if (!inspect.includes(outcome)) throw new Error("наблюдение не различает исход: " + outcome); } // Второй копии листинга быть не должно: status расходился с барьером по // --plain, по `|| true` и по трактовке failed-юнита. const status = fs.readFileSync("orchestrator/src/commands/status.ts", "utf8"); if (status.includes("list-units")) { throw new Error("status снова листит guard-юниты сам, мимо барьерного наблюдателя"); } if (!status.includes("await inspectRollbackGuard()")) { throw new Error("status не берёт состояние guard у общего наблюдателя"); } ' || fail "acceptance: барьер покоя между операциями нарушен" log_step "Acceptance: the transient guard timer states its window and its cleanup" # `OnActiveSec=` не означает «ровно через столько»: systemd.timer разрешает # себе сработать в окне [цель; цель + AccuracySec], а умолчание — 1min. Guard, # про который README и docs говорят «45 секунд», без явного значения имел # контракт «от 45 до 105». RemainAfterElapse задаётся по другой причине: от # выгрузки отработавшего таймера зависит право барьера считать его отсутствие # покоем, и держать этот инвариант на чужом умолчании нельзя. "$BUN_BIN" -e ' const { buildArmGuardArgv } = await import("./orchestrator/src/steps/firewall.ts"); const argv = buildArmGuardArgv("2026-01-01T00-00-00.000Z"); for (const expected of [ "--unit=hy2xs-fw-rollback-2026-01-01T00-00-00.000Z.service", "--on-active=45s", "--timer-property=RemainAfterElapse=no", "--timer-property=AccuracySec=1s" ]) { if (!argv.includes(expected)) throw new Error("взведение guard не задаёт " + expected); } // Ключ операции подставляется в имя юнита и в путь скрипта; shell в этой // команде не участвует, поэтому единственная защита — отказ. let rejected = false; try { buildArmGuardArgv("op id"); } catch { rejected = true; } if (!rejected) throw new Error("небезопасный ключ операции принят взведением guard"); ' || fail "acceptance: контракт транзиентного таймера guard нарушен" log_step "Acceptance: nftables.service restore claims only what it can guarantee" # `enable --runtime` не удаляет постоянную ссылку, поэтому "восстановление" # enabled-runtime таким вызовом обещало точность, которой не давало. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8"); const body = source.slice(source.indexOf("restore nftables.service unit file state")); const end = body.indexOf("restore nftables.service inactive state"); const stage = body.slice(0, end); for (const guessed of ["enable --runtime", "systemctl mask"]) { if (stage.includes(guessed)) { throw new Error("восстановление UnitFileState снова обещает недостижимую точность: " + guessed); } } if (!stage.includes("case \"enabled\":") || !stage.includes("case \"disabled\":")) { throw new Error("не восстанавливаются состояния, которые операция реально меняет"); } if (!stage.includes("is left as is")) { throw new Error("невосстановимое состояние не называется оператору явно"); } ' || fail "acceptance: восстановление состояния nftables.service нарушает свой контракт" log_step "Acceptance: the operation key has a single source" # install писал в маркер сырой ISO-timestamp с двоеточиями, а каталог отката # назывался санитизированным ключом: путь из runbook не существовал. grep -q 'export function operationKeyFor' orchestrator/src/steps/firewall.ts \ || fail "acceptance: ключ операции не объявлен единственной функцией" grep -q 'opId: operationKeyFor(context.installDate)' orchestrator/src/commands/install.ts \ || fail "acceptance: install снова пишет в маркер собственный вариант ключа операции" # Формула живёт ровно в одном файле — там, где объявлена operationKeyFor. local key_formula key_formula="$(grep -rl 'installDate.replace(' orchestrator/src \ | grep -v 'orchestrator/src/steps/firewall.ts' || true)" [ -z "$key_formula" ] \ || fail "acceptance: формула ключа операции продублирована в: $key_formula" } # Compatibility-слой предыдущего поколения не должен пережить 1.0. run_legacy_account_acceptance() { log_step "Acceptance: the legacy account layer is gone from the runtime" [ ! -f apps/model/entity/account.go ] \ || fail "acceptance: the LegacyAccount entity came back" # Каталоги перечислены явно: рекурсия по apps захватила бы # apps/frontend/node_modules, который к этому шагу сборки уже существует. ! grep -rq 'LegacyAccount' apps/dao apps/model apps/service apps/controller apps/cmd \ || fail "acceptance: legacy account migration code came back" # Ищется запись в списке миграций (имя в кавычках), а не упоминание: # комментарий, объясняющий, почему миграции удалены, должен быть разрешён. local dead_migration for dead_migration in 002_migrate_legacy_accounts 003_archive_legacy_account; do ! grep -qF "\"${dead_migration}\"" apps/dao/sqlite.go \ || fail "acceptance: legacy migration ${dead_migration} came back" done # Номера оставшихся миграций не перенумеровываются: они уже записаны в # schema_migrations на установленных машинах. local kept_migration for kept_migration in 000_base_config 001_admin_peer_split 004_traffic_samples_and_aggregates 005_metric_sample; do grep -qF "\"${kept_migration}\"" apps/dao/sqlite.go \ || fail "acceptance: migration ${kept_migration} disappeared or was renumbered" done log_step "Acceptance: v1 docs carry no previous-generation vocabulary" # docs/14 — единственное место, где эти имена обозначают реальные объекты # для удаления. В обычных docs их быть не должно. local doc for doc in docs/*.md; do case "$doc" in docs/14-legacy-cleanup.md) continue ;; esac ! grep -q 'H_UI_' "$doc" \ || fail "acceptance: previous-generation config keys leaked into $doc" done }