@@ -0,0 +1,390 @@
import { describe , expect , test } from "bun:test" ;
import { existsSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from "node:fs" ;
import { tmpdir } from "node:os" ;
import { 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 <unit>.timer <unit>.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 ;
}
describe ( "скрипт автоматического отката firewall" , ( ) = > {
const script = buildAutoRollbackScript ( OP_ID ) ;
test ( "маркер срабатывания создаётся ПЕРВЫМ действием" , ( ) = > {
const marker = script . indexOf ( ` : >" $ 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 ( ` : >" $ root/auto-rollback-fired" ` ) ;
const earlyExit = script . indexOf ( "nothing to restore" ) ;
expect ( marker ) . toBeLessThan ( earlyExit ) ;
} ) ;
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/ ) ;
} ) ;
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/<op_id> — по значению из маркера такого пути не было.
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'
) ;
} ) ;
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 ( "сработавший guard запрещает фиксацию успеха" , ( ) = > {
function ownership ( overrides : Record < string , boolean > = { } ) {
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 ) ;
} ) ;
} ) ;