import { describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { classifyFailure } from "../src/commands/install"; import { FirewallGuardFiredError, buildAutoRollbackScript, operationKeyFor, parseNftablesServiceState, renderNftablesEntrypoint, renderNftablesServiceState } from "../src/steps/firewall"; /** * Транзакционность staged firewall guard. * * Дефект, который здесь закрепляется, — второй по счёту commit ordering, и он * тоньше первого. Первый был про то, что данные для отката уничтожались до * фиксации успеха. Этот — про то, что сама фиксация опиралась на недоказанное * утверждение. * * systemctl stop .timer .service || true * -> "firewall rollback timer disarmed" * -> phase=installed * * Между «мы думаем, что guard снят» и «guard действительно снят» не было ни * одной проверки. Отказ остановки стирался через `|| true`, и взведённый таймер * мог вернуть прежний firewall уже ПОСЛЕ того, как установка объявлена * успешной и долговечно записана. * * Соседний дефект того же корня: guard мог сработать ВО ВРЕМЯ успешного smoke. * Окно 45 секунд заведомо короче худшего случая smoke (retry-бюджеты дают * заметно больше), а единственной проверкой firewall в smoke был `nft -c` — * разбор текущего файла, каким бы он ни был. Откатившийся прежний ruleset * проходил её зелёным. * * Оба закрываются одним фактом: маркером `auto-rollback-fired`, который * rollback-скрипт создаёт первым действием. Дальше инвариант становится * детерминированным: * * маркер отсутствует И юниты inactive => можно фиксировать успех */ function source(relativeToSrc: string): string { return readFileSync(new URL(`../src/${relativeToSrc}`, import.meta.url), "utf8"); } function occurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } const firewallSource = source("steps/firewall.ts"); const smokeSource = source("steps/smoke.ts"); const OP_ID = "2026-08-30T12-34-56.789Z"; /** * Ищет shell, которому можно доверить разбор скрипта. * * Кандидат принимается только после ДВУСТОРОННЕЙ проверки: он обязан принять * заведомо корректный скрипт и отвергнуть заведомо сломанный. Односторонней * проверки мало — `C:\Windows\system32\bash.exe` без установленного WSL * «проваливает» любой файл одинаково, и тест на таком парсере либо всегда * зелёный, либо всегда красный, но в обоих случаях ничего не проверяет. */ function findShellParser(sandbox: string): string | null { const good = join(sandbox, "good.sh"); const bad = join(sandbox, "bad.sh"); writeFileSync(good, "true\n"); writeFileSync(bad, "if then fi else\n"); const candidates = [ Bun.which("dash"), Bun.which("sh"), Bun.which("bash"), "C:\\Program Files\\Git\\usr\\bin\\bash.exe", "E:\\Git\\usr\\bin\\bash.exe" ].filter((candidate): candidate is string => Boolean(candidate) && existsSync(candidate as string)); for (const candidate of candidates) { const accepts = Bun.spawnSync([candidate, "-n", good], { stdout: "pipe", stderr: "pipe" }); const rejects = Bun.spawnSync([candidate, "-n", bad], { stdout: "pipe", stderr: "pipe" }); if (accepts.exitCode === 0 && rejects.exitCode !== 0) { return candidate; } } return null; } /** * Запускает скрипт кандидатом-shell. * * Каталог самого shell добавляется в PATH: Git for Windows кладёт bash и * coreutils рядом, но при запуске напрямую из процесса PATH этого каталога не * содержит, и `mkdir`/`touch` внутри скрипта оказываются не найдены. Это * особенность окружения, а не скрипта, и тест не должен на неё падать. */ function spawnShell(shell: string, scriptPath: string) { return Bun.spawnSync([shell, scriptPath], { stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: `${dirname(shell)}${delimiter}${process.env.PATH ?? ""}` } }); } /** * Ищет shell, которому можно доверить ЗАПУСК скрипта. * * Проба самопроверяющая и намеренно строгая: кандидат обязан вернуть код * возврата скрипта И создать файл, который тест затем видит на своей файловой * системе. Одной проверки кода возврата мало — `C:\Windows\system32\bash.exe` * это launcher WSL, он честно выполнит `exit 7`, но в совершенно другом * пространстве имён путей, и поведенческий тест ниже проверял бы не скрипт. */ function findShell(sandbox: string): string | null { const probeDir = join(sandbox, "probe-dir"); const probe = join(sandbox, "probe.sh"); writeFileSync( probe, `mkdir -p '${probeDir.replace(/\\/g, "/")}'\ntouch '${probeDir.replace(/\\/g, "/")}/ok'\nexit 7\n` ); const candidates = [ Bun.which("dash"), Bun.which("sh"), Bun.which("bash"), "C:\\Program Files\\Git\\usr\\bin\\bash.exe", "E:\\Git\\usr\\bin\\bash.exe" ].filter((candidate): candidate is string => Boolean(candidate) && existsSync(candidate as string)); for (const candidate of candidates) { rmSync(probeDir, { recursive: true, force: true }); const result = spawnShell(candidate, probe); if (result.exitCode === 7 && existsSync(join(probeDir, "ok"))) { return candidate; } } return null; } describe("скрипт автоматического отката firewall", () => { const script = buildAutoRollbackScript(OP_ID); test("маркер срабатывания создаётся ПЕРВЫМ действием", () => { const marker = script.indexOf(`touch "$root/auto-rollback-fired"`); const prepared = script.indexOf(`if [ ! -f "$root/prepared" ]`); const firstRestore = script.indexOf(`restore_file "$root/nftables.conf.existed"`); expect(marker).toBeGreaterThan(-1); expect(prepared).toBeGreaterThan(marker); expect(firstRestore).toBeGreaterThan(prepared); }); // Без этого «guard сработал» недоказуемо: транзиентные юниты systemd после // выполнения исчезают, и `systemctl stop` для них неотличим от успешного // снятия взведённого таймера. test("маркер создаётся даже когда восстанавливать нечего", () => { const marker = script.indexOf(`touch "$root/auto-rollback-fired"`); const earlyExit = script.indexOf("nothing to restore"); expect(marker).toBeLessThan(earlyExit); }); /** * Отказ записи маркера обязан входить в учёт rc. * * Инвариант фиксации — «маркера нет и юниты inactive => guard не сработал» — * верен только при дополнительном условии «guard способен записать маркер». * Пока `rc=0` стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs * /run, read-only ФС) не влиял ни на что: скрипт успешно восстанавливал * прежний firewall и завершался кодом 0, юнит уходил в inactive, маркера не * было — и операция фиксировала успех после реально сработавшего отката. */ test("rc объявляется до создания маркера, а не после", () => { const rcInit = script.indexOf("rc=0"); const mkdir = script.indexOf('mkdir -p "$root"'); const marker = script.indexOf(`touch "$root/auto-rollback-fired"`); expect(rcInit).toBeGreaterThan(-1); expect(rcInit).toBeLessThan(mkdir); expect(rcInit).toBeLessThan(marker); }); test("невозможность записать маркер поднимает код возврата", () => { expect(script).toContain("failed to access the recovery root"); expect(script).toContain("failed to create the fired marker"); }); /** * Маркер создаётся `touch`, а не `: >file`. * * Двоеточие — special builtin POSIX: ошибка перенаправления на нём обязана * завершить неинтерактивный shell целиком. На Debian /bin/sh — это dash, * который так и делает, поэтому при недоступном /run скрипт умер бы ДО * восстановления firewall — то есть guard перестал бы делать ровно то, ради * чего существует. */ test("маркер создаётся обычной командой, а не special builtin", () => { expect(script).toContain('touch "$root/auto-rollback-fired"'); expect(script).not.toContain(': >"$root/auto-rollback-fired"'); }); // Аварийный канал факта срабатывания: если маркер записать не удалось, юнит // обязан уйти в failed, а `failed` на пути фиксации успеха запрещён. test("ранний выход возвращает накопленный код, а не ноль", () => { const earlyExit = script.indexOf("nothing to restore"); const tail = script.slice(earlyExit); expect(tail).toContain('exit "$rc"'); expect(script).not.toContain("exit 0"); }); test("ошибки не маскируются", () => { expect(script).not.toContain("|| true"); expect(script).not.toContain("2>/dev/null"); expect(script).not.toContain(">/dev/null 2>&1"); }); // set -e прервал бы восстановление на первой отказавшей части, а задача // guard'а — восстановить ВСЁ, что получится. test("непрерывность обеспечивается rc, а не set -e", () => { expect(script).not.toMatch(/^set -e/m); expect(script).toContain("rc=0"); expect(script).toContain('exit "$rc"'); }); test("каждый сообщённый отказ поднимает код возврата", () => { expect(occurrences(script, "rc=1")).toBe(occurrences(script, "auto-rollback: failed")); expect(occurrences(script, "rc=1")).toBeGreaterThan(0); }); /** * На Debian у nftables.service `ExecStop=/usr/sbin/nft flush ruleset`. * Остановка сервиса внутри guard'а стёрла бы только что восстановленные * правила — то есть сделала бы ровно противоположное задаче. */ test("состояние nftables.service guard не трогает", () => { expect(script).not.toContain("systemctl"); }); test("пути привязаны к каталогу этой операции", () => { expect(script).toContain(`root='/run/hy2xs/rollback/${OP_ID}'`); expect(buildAutoRollbackScript("2026-09-01T00-00-00.000Z")).not.toContain(OP_ID); }); // Ключ операции служит именем каталога, именем systemd-юнита и подставляется // в текст скрипта. Прежний однострочник держался на том, что в нём не бывает // пробелов; теперь это проверяемое утверждение, а не допущение. test("небезопасный ключ операции отвергается", () => { expect(() => buildAutoRollbackScript("op id")).toThrow(/unsafe operation key/); expect(() => buildAutoRollbackScript("op'; rm -rf /")).toThrow(/unsafe operation key/); expect(() => buildAutoRollbackScript("")).toThrow(/unsafe operation key/); }); /** * Поведенческая проверка раннего пути скрипта. * * Запускается ТОЛЬКО ветка «prepared отсутствует»: она заканчивается до * первой команды восстановления, поэтому ничего в /etc не трогает и * безопасна на любой машине. Именно в этой ветке живёт исправленный дефект — * раньше она возвращала жёсткий `exit 0` и теряла факт неудачной записи * маркера. * * Строка `root=` подменяется на временный каталог: это единственное * изменение, остальные сорок строк — ровно те, что уезжают на сервер. */ function runEarlyPath(sandbox: string, root: string): { exitCode: number; stderr: string } | null { const shell = findShell(sandbox); if (!shell) { return null; } const scriptPath = join(sandbox, "run.sh"); writeFileSync( scriptPath, script.replace(/^root='.*'$/m, `root='${root.replace(/\\/g, "/")}'`) ); const result = spawnShell(shell, scriptPath); return { exitCode: result.exitCode, stderr: result.stderr.toString() }; } test("при доступном /run маркер создаётся, а ранний выход успешен", () => { const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-run-")); try { const root = join(sandbox, "rollback"); const result = runEarlyPath(sandbox, root); if (!result) { console.warn("shell is unavailable: skipping the behavioural check of the rollback script"); return; } expect(existsSync(join(root, "auto-rollback-fired"))).toBe(true); expect(result.stderr).toContain("nothing to restore"); expect(result.exitCode).toBe(0); } finally { rmSync(sandbox, { recursive: true, force: true }); } }); // Ключевой сценарий исправления: маркер записать не удалось, восстановления // не было — юнит обязан уйти в failed, потому что это аварийный канал факта // срабатывания, и `failed` запрещает фиксацию успеха. test("при недоступном /run ранний выход возвращает отказ", () => { const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-run-")); try { // Родитель — файл, поэтому ни mkdir, ни touch выполниться не могут. const blocker = join(sandbox, "blocker"); writeFileSync(blocker, "не каталог\n"); const result = runEarlyPath(sandbox, join(blocker, "rollback")); if (!result) { console.warn("shell is unavailable: skipping the behavioural check of the rollback script"); return; } expect(result.stderr).toContain("auto-rollback: failed"); expect(result.exitCode).not.toBe(0); } finally { rmSync(sandbox, { recursive: true, force: true }); } }); test("разбирается настоящим shell-парсером", () => { const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-")); try { const parser = findShellParser(sandbox); if (!parser) { // Сборка идёт на Linux, где парсер есть всегда; на машине разработчика // его может не быть, и это не повод объявлять скрипт корректным. console.warn("shell parser is unavailable: skipping the syntax check of the rollback script"); return; } const scriptPath = join(sandbox, "auto-rollback.sh"); writeFileSync(scriptPath, script); const parsed = Bun.spawnSync([parser, "-n", scriptPath], { stdout: "pipe", stderr: "pipe" }); expect(parsed.stderr.toString().trim()).toBe(""); expect(parsed.exitCode).toBe(0); } finally { rmSync(sandbox, { recursive: true, force: true }); } }); }); describe("ключ операции один на все её артефакты", () => { // Раньше формула была записана дважды, и копии разошлись: install писал в // маркер сырой ISO-timestamp с двоеточиями, а каталог отката назывался // санитизированным ключом. Runbook предлагает оператору открыть // /run/hy2xs/rollback/ — по значению из маркера такого пути не было. test("двоеточия ISO-времени заменяются", () => { expect(operationKeyFor("2026-08-30T12:34:56.789Z")).toBe("2026-08-30T12-34-56.789Z"); }); test("install и reconfigure берут ключ из одного места", () => { expect(source("commands/install.ts")).toContain("opId: operationKeyFor(context.installDate)"); expect(source("commands/reconfigure.ts")).toContain("return operationKeyFor(context.installDate)"); }); }); describe("состояние nftables.service переживает откат", () => { test("запись и разбор согласованы", () => { const state = { unitFileState: "disabled", activeState: "inactive" }; expect(parseNftablesServiceState(renderNftablesServiceState(state))).toEqual(state); }); test("непонятый файл не притворяется состоянием", () => { expect(parseNftablesServiceState("")).toBeNull(); expect(parseNftablesServiceState("мусор без разделителя\n")).toBeNull(); }); test("частичное состояние разбирается, а не отбрасывается", () => { expect(parseNftablesServiceState("unit_file_state=enabled\n")).toEqual({ unitFileState: "enabled", activeState: "" }); }); test("состояние снимается до первой мутации firewall", () => { const backup = firewallSource.indexOf("async function backupFirewallState"); const capture = firewallSource.indexOf("nftablesServiceStatePath(opId)", backup); const prepared = firewallSource.indexOf("touch ${rollbackMarker(opId)}", backup); expect(capture).toBeGreaterThan(backup); expect(prepared).toBeGreaterThan(capture); }); /** * Порядок стадий отката. `systemctl stop nftables` выполняет * `nft flush ruleset`, поэтому восстановление состояния сервиса обязано идти * ДО применения ruleset — иначе остановка стёрла бы восстановленные правила. */ test("состояние сервиса восстанавливается до применения ruleset", () => { const body = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow")); const unitFile = body.indexOf('name: "restore nftables.service unit file state"'); const inactive = body.indexOf('name: "restore nftables.service inactive state"'); const ruleset = body.indexOf('name: "apply restored ruleset"'); expect(unitFile).toBeGreaterThan(-1); expect(inactive).toBeGreaterThan(unitFile); expect(ruleset).toBeGreaterThan(inactive); }); }); describe("disarm доказывает снятие guard'а, а не сообщает о нём", () => { const body = firewallSource.slice( firewallSource.indexOf("async function stopRollbackGuard"), firewallSource.indexOf("export async function disarmFirewallRollback") ); test("остановка guard'а больше не глушит свой код возврата", () => { expect(body).not.toContain("|| true"); expect(body).toContain("runMutatingStatus`systemctl stop"); }); test("маркер срабатывания проверяется до и после остановки", () => { 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"); expect(before).toBeGreaterThan(-1); expect(stop).toBeGreaterThan(before); expect(after).toBeGreaterThan(stop); }); test("снятие подтверждается наблюдаемым состоянием юнитов", () => { expect(body).toContain('readUnitProperty(target, "ActiveState")'); expect(body).toContain("`${unit}.timer`"); expect(body).toContain("`${unit}.service`"); }); // На пути фиксации успеха допустимо ровно одно состояние. `failed` там // означает сработавший и упавший guard, то есть запрет на фиксацию. test("на пути фиксации допустим только inactive", () => { expect(firewallSource).toContain('const GUARD_STOPPED_STATES_FOR_COMMIT = ["inactive"] as const'); expect(firewallSource).toContain( 'const GUARD_STOPPED_STATES_FOR_RECOVERY = ["inactive", "failed"] as const' ); }); /** * Имя транзиентного юнита передаётся с явным суффиксом. Голое имя systemd-run * пропускает через мангling, который смотрит, не заканчивается ли оно уже * известным типом юнита; ключ операции содержит точку (`...T12-34-56.789Z`), * и корректность зависела бы от того, что `.789Z` ни с чем не совпало. */ test("имя юнита guard'а не зависит от мангления systemd", () => { expect(firewallSource).toContain("systemd-run --unit ${`${unit}.service`}"); }); test("остановка guard'а стала стадией отката с отчётом", () => { const rollback = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow")); expect(rollback).toContain('name: "stop firewall rollback guard"'); expect(rollback).toContain("assertNotFired: false"); }); }); describe("барьер покоя между операциями", () => { /** * Стык двух защитных механизмов. Замок защищает production paths, пока жив * процесс-держатель; rollback guard — отдельный systemd-объект, переживающий * свой процесс. Аварийно умершая операция оставляет вооружённый guard, * который возвращает прежний firewall уже посреди следующей операции. */ const body = firewallSource.slice( firewallSource.indexOf("export async function assertNoPendingRollbackGuard"), firewallSource.indexOf("function firewallRollbackIsInactive") ); test("вооружённый guard предыдущей операции запрещает новую", () => { expect(body).toContain("PendingRecoveryError"); expect(body).toContain("readUnitProperty(unit, \"ActiveState\")"); }); // `failed` и `inactive` — покой: guard уже отработал и больше ничего не // сделает. Отказ по `failed` заблокировал бы `repair` ровно тогда, когда он // нужен для устранения последствий. test("покоем считаются inactive и failed, а не только inactive", () => { expect(firewallSource).toContain( 'const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading"] as const' ); }); test("отказ запроса к systemd не выдаётся за наличие guard", () => { const listing = firewallSource.slice( firewallSource.indexOf("export async function listRollbackGuardUnits"), firewallSource.indexOf("export async function assertNoPendingRollbackGuard") ); expect(listing).toContain("unable to list firewall rollback guard units"); expect(listing).toContain("return [];"); }); test("барьер проверяется при любом захвате замка, а не только при устаревшем", () => { const cliSource = source("cli.ts"); expect(cliSource).toContain("{ barrier: assertNoPendingRollbackGuard }"); // Обработчик сигналов снимает замок сам, поэтому у прерванной операции // stale-замка может не быть вовсе, а таймер останется. expect(cliSource).toContain("await assertNoPendingRollbackGuard()"); }); // Барьер вызывается дважды: между проверкой и захватом умирающая операция // успевает вооружить guard. test("барьер проверяется до и после захвата замка", () => { const lockSource = source("lib/operationLock.ts"); const start = lockSource.indexOf("export async function acquireOperationLock"); const acquire = lockSource.slice(start); const before = acquire.indexOf("await options.barrier?.()"); const write = acquire.indexOf("await writeLockFile(path, record)"); const after = acquire.indexOf("await options.barrier()"); expect(before).toBeGreaterThan(-1); expect(write).toBeGreaterThan(before); expect(after).toBeGreaterThan(write); // Отказ второй проверки не имеет права оставить замок за собой. expect(acquire.slice(after)).toContain("releaseSync(path, record.nonce)"); }); }); describe("сработавший guard запрещает фиксацию успеха", () => { function ownership(overrides: Record = {}) { return { stateTouched: true, bootstrapTouched: true, depsTouched: true, filesystemTouched: true, uiTouched: true, hysteriaTouched: true, configTouched: true, unitsTouched: true, firewallTouched: true, postInstallTouched: true, bootstrapSecretTouched: true, servicesStarted: true, ...overrides }; } test("install классифицирует его отдельной причиной отказа", () => { expect(classifyFailure(ownership(), "smoke_ok", new FirewallGuardFiredError("guard"))).toBe( "firewall_guard_fired" ); }); // Классификация по ТИПУ, а не по тексту: ошибка с теми же словами, но другим // типом обязана классифицироваться по владению, как и прежде. test("похожий текст чужой ошибки на классификацию не влияет", () => { expect( classifyFailure(ownership(), "smoke_ok", new Error("automatic firewall rollback has already fired")) ).toBe("service_start_failure"); }); test("маркер отказа получает собственную фазу", () => { const installSource = source("commands/install.ts"); expect(installSource).toContain('? "firewall_guard_fired"'); expect(source("commands/reconfigure.ts")).toContain('return "firewall_guard_fired"'); }); }); describe("эффективный firewall проверяется семантически", () => { test("smoke сверяет firewall, а не только разбирает файл", () => { const syntax = smokeSource.indexOf("nft -c -f /etc/nftables.conf"); const semantic = smokeSource.indexOf("assertEffectiveFirewallIsOurs(context)"); expect(syntax).toBeGreaterThan(-1); expect(semantic).toBeGreaterThan(syntax); }); test("сверяется отрендеренный фрагмент, entrypoint и загруженная таблица", () => { const body = firewallSource.slice( firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs"), firewallSource.indexOf("function firewallRollbackIsInactive") ); expect(body).toContain("await renderHy2xsNft(context)"); expect(body).toContain("effective !== expected"); expect(body).toContain('entrypointKind !== "hy2xs-managed"'); expect(body).toContain("nft list table inet hy2xs"); }); // Проверка обязана оставаться наблюдением: она выполняется и в doctor, // который целиком идёт под read-only guard. test("проверка выполняется read-only раннером", () => { const body = firewallSource.slice( firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs"), firewallSource.indexOf("function firewallRollbackIsInactive") ); expect(body).not.toMatch(/runMutating[A-Za-z]*`/); }); test("рендер фрагмента объявлен один раз и переиспользуется", () => { expect(occurrences(firewallSource, "templates/nftables/hy2xs.nft.tpl")).toBe(1); }); }); describe("candidate-файлы не переживают операцию", () => { test("успешный apply убирает оба", () => { expect(firewallSource).toContain( "runMutatingVisible`rm -f ${HY2XS_NFT_CANDIDATE} ${NFTABLES_ENTRYPOINT_CANDIDATE}`" ); const apply = firewallSource.slice( firewallSource.indexOf("export async function applyFirewall"), firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs") ); expect(apply).toContain("await cleanupFirewallCandidates()"); }); test("откат убирает их best-effort и не срывается на этом", () => { const rollback = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow")); const cleanup = rollback.indexOf("await cleanupFirewallCandidates()"); const guarded = rollback.indexOf("catch (candidateError)"); expect(cleanup).toBeGreaterThan(-1); expect(guarded).toBeGreaterThan(cleanup); }); }); describe("entrypoint nftables", () => { test("объявляет владение HY2XS и подключает переданный фрагмент", () => { const rendered = renderNftablesEntrypoint("/etc/nftables.d/hy2xs.nft"); expect(rendered).toContain("HY2XS-MANAGED"); expect(rendered).toContain("flush ruleset"); expect(rendered).toContain('include "/etc/nftables.d/hy2xs.nft"'); }); test("candidate и production entrypoint строятся одной функцией", () => { expect(occurrences(firewallSource, "HY2XS-MANAGED: root nftables entrypoint")).toBe(1); }); });