297 lines
9.9 KiB
Python
297 lines
9.9 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
INSTALLER = ROOT / "scripts/reboot-recovery/install-host112-guest-recovery.sh"
|
|
|
|
|
|
def source() -> str:
|
|
return INSTALLER.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_installer_shell_syntax_is_valid() -> None:
|
|
result = subprocess.run(
|
|
["bash", "-n", str(INSTALLER)],
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
timeout=10,
|
|
)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
|
|
|
|
def test_apply_and_rollback_require_safe_control_identity() -> None:
|
|
text = source()
|
|
|
|
assert '--trace-id ID --run-id ID --work-item-id ID' in text
|
|
assert 'BLOCKER incomplete_control_identity' in text
|
|
assert 'BLOCKER unsafe_control_identity' in text
|
|
assert 'BLOCKER installer_control_identity_required' in text
|
|
assert '^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,127}$' in text
|
|
|
|
|
|
def test_sudo_policy_has_exact_correlated_apply_and_dry_run_only() -> None:
|
|
text = source()
|
|
|
|
assert "SUDOERS_ARGUMENT_REGEX='^--apply --trace-id " in text
|
|
assert "SUDOERS_DRY_RUN_ARGUMENT_REGEX='^--dry-run --trace-id " in text
|
|
assert '"$TARGET_USER" "$READINESS_TARGET" "$SUDOERS_ARGUMENT_REGEX"' in text
|
|
assert (
|
|
'"$TARGET_USER" "$READINESS_TARGET" '
|
|
'"$SUDOERS_DRY_RUN_ARGUMENT_REGEX"' in text
|
|
)
|
|
assert 'SUDO_CORRELATED_APPLY_NOPASSWD' in text
|
|
assert 'SUDO_CORRELATED_DRY_RUN_NOPASSWD' in text
|
|
assert 'SUDO_LEGACY_UNCORRELATED_APPLY_NOPASSWD' in text
|
|
assert 'SUDO_UNSAFE_IDENTITY_NOPASSWD' in text
|
|
assert 'SUDO_POLICY_READY' in text
|
|
assert 'sudo_policy_is_nopasswd' in text
|
|
assert 'Options:' in text
|
|
assert '!authenticate' in text
|
|
|
|
|
|
def test_sudo_policy_parser_requires_nopasswd_and_match_in_same_entry() -> None:
|
|
text = source()
|
|
parser = text[
|
|
text.index("sudo_policy_output_has_nopasswd_match() {") : text.index(
|
|
"agent99_direct_authorization_ready() {"
|
|
)
|
|
]
|
|
broad_password_rule = """Sudoers entry: /etc/sudoers
|
|
RunAsUsers: ALL
|
|
Commands:
|
|
ALL
|
|
Matched: /usr/local/sbin/readiness --apply
|
|
"""
|
|
exact_nopasswd_rule = broad_password_rule + """Sudoers entry: /etc/sudoers.d/agent99
|
|
RunAsUsers: root
|
|
Options: !authenticate
|
|
Commands:
|
|
/usr/local/sbin/readiness ^--apply .*$
|
|
Matched: /usr/local/sbin/readiness --apply --trace-id safe
|
|
"""
|
|
split_entry_false_positive = """Sudoers entry: /etc/sudoers.d/one
|
|
Options: !authenticate
|
|
Commands:
|
|
/usr/bin/true
|
|
Sudoers entry: /etc/sudoers
|
|
Commands:
|
|
ALL
|
|
Matched: /usr/local/sbin/readiness --apply
|
|
"""
|
|
|
|
def parse(sample: str) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
["bash", "-c", parser + "\nsudo_policy_output_has_nopasswd_match"],
|
|
input=sample,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
timeout=10,
|
|
)
|
|
|
|
assert parse(broad_password_rule).returncode != 0
|
|
assert parse(exact_nopasswd_rule).returncode == 0
|
|
assert parse(split_entry_false_positive).returncode != 0
|
|
|
|
|
|
def test_windows99_direct_route_is_fingerprint_bound_and_not_forced() -> None:
|
|
text = source()
|
|
route = text[
|
|
text.index("agent99_direct_authorization_ready() {") : text.index(
|
|
"readiness_check_no_write_ready() {"
|
|
)
|
|
]
|
|
|
|
assert 'HOST112_AGENT99_SOURCE_ADDRESS:-192.168.0.99' in text
|
|
assert 'HOST112_AGENT99_EXPECTED_KEY_FINGERPRINT' in text
|
|
assert 'AGENT99_99_DIRECT_ROUTE_READY' in text
|
|
assert 'index($1, "command=") == 0' in route
|
|
assert 'has_restrict($1)' in route
|
|
assert 'ssh-keygen -lf -' in route
|
|
assert 'cat "$AUTH_FILE"' not in text
|
|
assert 'from="%s",restrict,command="%s --check"' in text
|
|
assert 'CONTROL_SOURCE_ADDRESS:-192.168.0.110' in text
|
|
assert 'BLOCKER control_public_key_not_staged=$CONTROL_PUBLIC_KEY_PATH' in text
|
|
assert "never discovers, reads, copies, or derives a private key" in text
|
|
|
|
|
|
def test_apply_is_transactional_and_has_a_verified_rollback() -> None:
|
|
text = source()
|
|
apply_path = text[text.index("static_source_preflight\n") :]
|
|
handler = text[
|
|
text.index("handle_apply_failure() {") : text.index(
|
|
"static_source_preflight() {"
|
|
)
|
|
]
|
|
|
|
assert 'set -Eeuo pipefail' in text
|
|
assert 'TRANSACTION_ACTIVE=1' in apply_path
|
|
assert "trap 'handle_apply_failure $?' ERR" in apply_path
|
|
assert "trap 'handle_apply_failure 130' INT TERM" in apply_path
|
|
assert 'restore_from_backup "$BACKUP_DIR"' in handler
|
|
assert 'verify_rollback "$BACKUP_DIR"' in handler
|
|
assert 'ROLLBACK_VERIFIED=1' in handler
|
|
assert 'install_failed_rolled_back_verified' in handler
|
|
assert 'cmp -s "$backup_dir/rootfs/${path#/}" "$path"' in text
|
|
assert 'timer_was_enabled' in text
|
|
assert 'timer_was_active' in text
|
|
|
|
|
|
def test_static_gate_precedes_timer_activation_and_no_oneshot_is_started() -> None:
|
|
text = source()
|
|
apply_path = text[text.index("static_source_preflight\n") :]
|
|
|
|
static_gate = apply_path.index("post_install_static_gate\n")
|
|
timer_start = apply_path.index(
|
|
"systemctl start awoooi-host112-guest-recovery.timer"
|
|
)
|
|
final_gate = apply_path.index("check\n", timer_start)
|
|
receipt = apply_path.index(
|
|
'write_install_receipt "installed_timer_activated_recovery_separate"'
|
|
)
|
|
assert static_gate < timer_start < final_gate < receipt
|
|
assert 'systemctl enable awoooi-host112-guest-recovery.timer' in apply_path
|
|
assert 'systemctl enable --now awoooi-host112-guest-recovery.timer' not in text
|
|
assert 'systemctl start awoooi-host112-guest-recovery.service' not in text
|
|
assert "grep -Fq 'TimeoutStartSec=480'" in text
|
|
assert "for tool in bash cksum cmp flock" in text
|
|
assert "TimeoutStartSec=600" not in text
|
|
assert 'INSTALLER_SYNCHRONOUS_RECOVERY_START=0' in text
|
|
assert 'RECOVERY_EXECUTION_PERFORMED=0' in text
|
|
|
|
|
|
def test_install_receipt_does_not_claim_a_recovery_run() -> None:
|
|
text = source()
|
|
receipt = text[
|
|
text.index("write_install_receipt() {") : text.index(
|
|
"handle_apply_failure() {"
|
|
)
|
|
]
|
|
|
|
assert 'schema_version=host112_guest_recovery_install_receipt_v1' in receipt
|
|
assert 'install_verified=$install_verified' in receipt
|
|
assert 'manager_runtime_write_performed=0' in receipt
|
|
assert 'recovery_execution_performed=0' in receipt
|
|
assert 'recovery_run_id=none' in receipt
|
|
assert 'recovery_receipt_ref=none' in receipt
|
|
assert 'agent99_99_direct_runtime_probe=not_started' in receipt
|
|
assert 'rollback_attempted=$rollback_attempted' in receipt
|
|
assert 'rollback_verified=$rollback_verified' in receipt
|
|
assert 'static_post_gate_blockers=${STATIC_POST_GATE_BLOCKERS:-not_evaluated}' in receipt
|
|
|
|
|
|
def test_static_post_gate_reports_named_blockers_before_rollback() -> None:
|
|
text = source()
|
|
gate = text[
|
|
text.index("post_install_static_gate() {") : text.index(
|
|
"wait_for_recovery_service_idle() {"
|
|
)
|
|
]
|
|
|
|
for blocker in (
|
|
"target_hashes_mismatch",
|
|
"unit_static_verify_failed",
|
|
"target_files_not_ready",
|
|
"unit_contract_not_ready",
|
|
"sudo_policy_not_ready",
|
|
"agent99_direct_route_not_ready",
|
|
"readiness_no_write_not_ready",
|
|
"timer_not_enabled",
|
|
):
|
|
assert blocker in gate
|
|
assert 'STATIC_POST_GATE_BLOCKERS=' in gate
|
|
assert 'READINESS_CHECK_EXIT=' in gate
|
|
|
|
|
|
def test_check_gate_requires_policy_route_timer_and_no_write_readback() -> None:
|
|
text = source()
|
|
check = text[text.index("check() {") : text.index("require_root() {")]
|
|
|
|
for required in (
|
|
'SUDO_POLICY_READY',
|
|
'AGENT99_99_DIRECT_ROUTE_READY',
|
|
'READINESS_NO_WRITE_READY',
|
|
'TIMER_ENABLED',
|
|
'TIMER_ACTIVE',
|
|
'CHECK_GATE_READY',
|
|
):
|
|
assert required in check
|
|
assert 'runtime_write_performed=0' in text
|
|
assert 'manager_runtime_write_performed=0' in text
|
|
assert 'receipt_write_performed=0' in text
|
|
|
|
|
|
def test_clean_install_timer_stop_is_state_aware_and_idempotent(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
text = source()
|
|
function = text[
|
|
text.index("stop_recovery_timer_if_running() {") : text.index(
|
|
"\n\nresolve_target_user",
|
|
text.index("stop_recovery_timer_if_running() {"),
|
|
)
|
|
]
|
|
runner = tmp_path / "run-stop-contract.sh"
|
|
runner.write_text(
|
|
"#!/usr/bin/env bash\nset -u\n" + function + "\nstop_recovery_timer_if_running\n",
|
|
encoding="utf-8",
|
|
)
|
|
runner.chmod(0o755)
|
|
fake_bin = tmp_path / "bin"
|
|
fake_bin.mkdir()
|
|
log = tmp_path / "systemctl.log"
|
|
systemctl = fake_bin / "systemctl"
|
|
systemctl.write_text(
|
|
"""#!/usr/bin/env bash
|
|
printf '%s\n' "$*" >>"${SYSTEMCTL_LOG:?}"
|
|
if [ "${1:-}" = "is-active" ]; then
|
|
printf '%s\n' "${FAKE_TIMER_STATE:-not-found}"
|
|
[ "${FAKE_TIMER_STATE:-not-found}" = "active" ]
|
|
fi
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
systemctl.chmod(0o755)
|
|
timeout = fake_bin / "timeout"
|
|
timeout.write_text(
|
|
"#!/usr/bin/env bash\nshift\n\"$@\"\n",
|
|
encoding="utf-8",
|
|
)
|
|
timeout.chmod(0o755)
|
|
env = os.environ | {
|
|
"PATH": f"{fake_bin}:{os.environ['PATH']}",
|
|
"SYSTEMCTL_LOG": str(log),
|
|
}
|
|
|
|
clean = subprocess.run(
|
|
["bash", str(runner)],
|
|
text=True,
|
|
capture_output=True,
|
|
env=env | {"FAKE_TIMER_STATE": "not-found"},
|
|
check=False,
|
|
timeout=10,
|
|
)
|
|
assert clean.returncode == 0, clean.stdout + clean.stderr
|
|
assert "stop awoooi-host112-guest-recovery.timer" not in log.read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
log.write_text("", encoding="utf-8")
|
|
active = subprocess.run(
|
|
["bash", str(runner)],
|
|
text=True,
|
|
capture_output=True,
|
|
env=env | {"FAKE_TIMER_STATE": "active"},
|
|
check=False,
|
|
timeout=10,
|
|
)
|
|
assert active.returncode == 0, active.stdout + active.stderr
|
|
assert "stop awoooi-host112-guest-recovery.timer" in log.read_text(
|
|
encoding="utf-8"
|
|
)
|