#!/usr/bin/env bash # Exécuter seulement une copie de deploy.sh dans un bwrap sans réseau, avec / en lecture seule. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" command -v bwrap >/dev/null || { echo 'bwrap requis pour le confinement' >&2; exit 1; } TEST_HOME="$(mktemp -d)" trap 'rm -rf -- "$TEST_HOME"' EXIT passes=0 case_dir='' rc=0 fail() { printf 'ÉCHEC : %s\n' "$*" >&2; exit 1; } pass() { passes=$((passes + 1)); } expect_rc() { [[ "$rc" == "$1" ]] || fail "code $rc, attendu $1 ; sortie : $(cat "$case_dir/output")"; } contains() { grep -Fq -- "$1" "$case_dir/output" || fail "texte absent : $1"; } not_contains() { ! grep -Fq -- "$1" "$case_dir/output" || fail "texte interdit : $1"; } no_sudo() { ! grep -q '^sudo ' "$case_dir/calls" || fail 'appel sudo dans un scénario sans mutation'; } clean_nominal() { [[ ! -s "$case_dir/stderr" ]] || fail "stderr inattendu : $(cat "$case_dir/stderr")" ! grep -Eq 'REFUSED|commande introuvable|command not found|ligne [0-9]+:' "$case_dir/output" || fail 'diagnostic parasite dans un scénario nominal' } clean_error() { ! grep -Eq 'REFUSED|commande introuvable|command not found|ligne [0-9]+:' "$case_dir/output" || fail 'diagnostic parasite dans un scénario d’erreur' if grep -Ev '^\[!\] |^Option inconnue : |^simulated (sudo refusal|parent inspection error|second inspection error)$' "$case_dir/stderr" > "$case_dir/unexpected-stderr"; then fail "stderr non prévu : $(cat "$case_dir/unexpected-stderr")" fi } new_case() { case_dir="$(mktemp -d "$TEST_HOME/case.XXXXXXXX")" mkdir -p "$case_dir"/{repo/scripts,repo/pkglist,repo/manifests,repo/templates/sudoers.d,repo/rootfs/etc/tlp.d,repo/rootfs/etc/systemd,repo/rootfs/etc/snapper/configs,repo/rootfs/usr/local/sbin,bin,etc/usr,usr-local-sbin,tmp,backups} cp "$ROOT/scripts/deploy.sh" "$case_dir/repo/scripts/deploy.sh" chmod 755 "$case_dir/repo/scripts/deploy.sh" for source in pacman.conf makepkg.conf mkinitcpio.conf nftables.conf limine-snapper-sync.conf; do printf 'fixture %s\n' "$source" > "$case_dir/repo/rootfs/etc/$source" done printf 'fixture tlp\n' > "$case_dir/repo/rootfs/etc/tlp.d/10-archasp.conf" printf 'fixture zram\n' > "$case_dir/repo/rootfs/etc/systemd/zram-generator.conf" printf 'fixture snapper\n' > "$case_dir/repo/rootfs/etc/snapper/configs/root" for helper in batlimit-set vmware-stack; do printf '#!/usr/bin/env bash\nexit 0\n' > "$case_dir/repo/rootfs/usr/local/sbin/$helper" done printf '@USER@ ALL=(root) NOPASSWD: /usr/local/sbin/batlimit-set\n' > "$case_dir/repo/templates/sudoers.d/batlimit" printf '@USER@ ALL=(root) NOPASSWD: /usr/local/sbin/vmware-stack start\n' > "$case_dir/repo/templates/sudoers.d/vmware-stack" printf '# comment\n alpha \n\n' > "$case_dir/repo/pkglist/pacman.txt" printf 'foreign\n' > "$case_dir/repo/pkglist/aur.txt" printf 'alpha.service\n' > "$case_dir/repo/manifests/system-services.txt" printf 'beta.timer\n' > "$case_dir/repo/manifests/system-timers.txt" printf 'ondemand.service\n' > "$case_dir/repo/manifests/on-demand-system-services.txt" printf 'alpha.service\nbeta.timer\nondemand.service\n' > "$case_dir/units" printf 'existing config\n' > "$case_dir/etc/pacman.conf" printf 'fixturehost\n' > "$case_dir/etc/hostname" uid="$(id -u)" gid="$(id -g)" printf 'fixtureuser:x:%s:%s:Fixture:/mnt:/usr/bin/bash\n' "$uid" "$gid" > "$case_dir/etc/passwd" printf 'fixture:x:%s:\n' "$gid" > "$case_dir/etc/group" cp /etc/nsswitch.conf "$case_dir/etc/nsswitch.conf" for safe in bash cat dirname find grep sed mktemp rm rmdir; do [[ -x "/usr/bin/$safe" ]] || fail "outil réel absent : /usr/bin/$safe" ln -s "/usr/bin/$safe" "$case_dir/bin/$safe" done for mocked in sudo systemctl yay pacman nft visudo tlp install id; do ln -s /mnt/mock-command.sh "$case_dir/bin/$mocked" done cp "$ROOT/tests/deploy/mock-command.sh" "$case_dir/mock-command.sh" chmod 755 "$case_dir/mock-command.sh" : > "$case_dir/calls" } # Le bind de /etc et /usr/local/sbin fournit uniquement des cibles synthétiques. # / est en lecture seule, /tmp et /mnt sont les seuls espaces inscriptibles, # le réseau est absent, et PATH n'offre que des lectures sûres ou nos doublures. run_case() { local name="$1" shift : > "$case_dir/output" : > "$case_dir/stderr" set +e printf '%s\n' "${SIM_ANSWER:-y}" | bwrap --unshare-user --unshare-pid --unshare-net \ --ro-bind / / --bind "$case_dir/etc" /etc \ --bind "$case_dir/usr-local-sbin" /usr/local/sbin \ --bind "$case_dir/tmp" /tmp --bind "$case_dir" /mnt \ --proc /proc --dev /dev \ --setenv PATH /mnt/bin --setenv HOME /mnt --setenv TMPDIR /tmp \ --setenv SIM_FAIL_NFT "${SIM_FAIL_NFT:-0}" \ --setenv SIM_FAIL_VISUDO "${SIM_FAIL_VISUDO:-0}" \ --setenv SIM_FAIL_INSTALL_TARGET "${SIM_FAIL_INSTALL_TARGET:-}" \ --setenv SIM_INTERRUPT_TARGET "${SIM_INTERRUPT_TARGET:-}" \ --setenv SIM_INTERRUPT_SIGNAL "${SIM_INTERRUPT_SIGNAL:-TERM}" \ --setenv SIM_INSPECT_TARGET "${SIM_INSPECT_TARGET:-}" \ --setenv SIM_INSPECT_MODE "${SIM_INSPECT_MODE:-}" \ --setenv SIM_FAIL_SYSTEMCTL "${SIM_FAIL_SYSTEMCTL:-}" \ --setenv SIM_MISSING_UNIT "${SIM_MISSING_UNIT:-}" \ --setenv SIM_UNIT_ENABLED "${SIM_UNIT_ENABLED:-disabled}" \ --setenv SIM_UNIT_ACTIVE "${SIM_UNIT_ACTIVE:-inactive}" \ --setenv SIM_ID_ROOT "${SIM_ID_ROOT:-0}" \ --setenv USER root \ -- /mnt/repo/scripts/deploy.sh "$@" > "$case_dir/stdout" 2> "$case_dir/stderr" rc=$? set -e cat "$case_dir/stdout" "$case_dir/stderr" > "$case_dir/output" printf '[test] %s : rc=%s\n' "$name" "$rc" } new_case run_case help --help; expect_rc 0; no_sudo; clean_nominal; pass run_case no_selection; expect_rc 2; no_sudo; clean_error; pass run_case bad_option --unknown; expect_rc 2; no_sudo; clean_error; pass for selection in --packages --configs --tools --services --all; do new_case run_case "dry_$selection" --dry-run "$selection" expect_rc 0; contains 'déploiement non effectué'; not_contains 'Déploiement sélectionné terminé' no_sudo clean_nominal if [[ "$selection" == --all ]]; then contains '/etc/snapper/configs/root' contains '/etc/sudoers.d/batlimit' contains 'Activer : alpha.service' fi [[ ! -e "$case_dir/counter" ]] || fail 'sauvegarde créée en simulation' [[ -z "$(ls -A "$case_dir/tmp")" ]] || fail 'temporaire créé en simulation' pass done new_case rm "$case_dir/repo/rootfs/etc/pacman.conf" run_case missing_source --dry-run --configs; expect_rc 1; contains 'source obligatoire'; no_sudo; clean_error; pass new_case rm "$case_dir/repo/rootfs/etc/pacman.conf" run_case all_missing_source --all; expect_rc 1; contains 'source obligatoire'; no_sudo; clean_error; pass new_case rm "$case_dir/repo/manifests/system-services.txt" run_case missing_manifest --dry-run --services; expect_rc 1; contains 'liste/manifest absent'; no_sudo; clean_error; pass new_case printf 'bad option\n' >> "$case_dir/repo/pkglist/pacman.txt" run_case bad_line --dry-run --packages; expect_rc 1; contains 'nom de paquet invalide'; no_sudo; clean_error; pass new_case : > "$case_dir/repo/pkglist/pacman.txt" : > "$case_dir/repo/pkglist/aur.txt" run_case empty_lists --dry-run --packages; expect_rc 0; contains '(0 entrées)'; no_sudo; clean_nominal; pass run_case empty_lists_apply --packages; expect_rc 0; clean_nominal ! grep -q '^sudo pacman ' "$case_dir/calls" || fail 'pacman appelé pour liste vide' ! grep -q '^yay ' "$case_dir/calls" || fail 'yay appelé pour liste vide' pass new_case rm "$case_dir/bin/yay" run_case no_yay --packages; expect_rc 1; contains 'commande requise absente : yay'; no_sudo; clean_error; pass new_case SIM_ID_ROOT=1 run_case invalid_identity --tools; expect_rc 1; contains 'compte bénéficiaire root refusé'; no_sudo; clean_error; pass new_case SIM_FAIL_NFT=1 run_case nft_rejected --configs; expect_rc 1; contains 'validation nftables' ! grep -q '^sudo install ' "$case_dir/calls" || fail 'configuration installée après échec nft'; pass new_case SIM_FAIL_VISUDO=1 run_case sudoers_rejected --tools; expect_rc 1; contains 'validation sudoers' [[ -z "$(ls -A "$case_dir/tmp")" ]] || fail 'temporaire sudoers non nettoyé' ! grep -q '^sudo install ' "$case_dir/calls" || fail 'helper installé avant validation sudoers'; pass new_case SIM_FAIL_INSTALL_TARGET=/etc/makepkg.conf run_case partial_after_packages --packages --configs expect_rc 1; contains 'Étapes achevées : paquets'; not_contains 'Déploiement sélectionné terminé' contains 'installation:/etc/pacman.conf' [[ -e "$case_dir/backups/arch-system-restore.SIM0001/existing/etc/pacman.conf" ]] || fail 'sauvegarde existante absente' clean_error; pass # R1 : un échec du contrôle sudo/find, même avec un état plausible, n'est jamais ABSENT. for mode in fail parent_fail empty unknown valid_fail; do new_case SIM_INSPECT_TARGET=/etc/pacman.conf SIM_INSPECT_MODE="$mode" run_case "inspect_$mode" --configs expect_rc 1 if [[ "$mode" == empty || "$mode" == unknown ]]; then contains 'réponse de contrôle invalide' else contains 'contrôle privilégié impossible' fi not_contains 'Déploiement sélectionné terminé' [[ "$(cat "$case_dir/etc/pacman.conf")" == 'existing config' ]] || fail 'cible existante modifiée après contrôle invalide' [[ ! -e "$case_dir/backups/arch-system-restore.SIM0001/absent/etc/pacman.conf" ]] || fail 'fausse absence après contrôle invalide' ! grep -Fq 'sudo install -D -m 0644' "$case_dir/calls" || fail 'installation après contrôle invalide' clean_error; pass done new_case SIM_INSPECT_TARGET=/etc/pacman.conf SIM_INSPECT_MODE=second_fail run_case inspect_second_fail --configs expect_rc 1; contains 'contrôle privilégié impossible' [[ -e "$case_dir/backups/arch-system-restore.SIM0001/existing/etc/pacman.conf" ]] || fail 'sauvegarde perdue après second contrôle' [[ "$(cat "$case_dir/etc/pacman.conf")" == 'existing config' ]] || fail 'cible modifiée après second contrôle' ! grep -Fq 'sudo install -D -m 0644 /mnt/repo/rootfs/etc/pacman.conf /etc/pacman.conf' "$case_dir/calls" || fail 'installation après second contrôle' clean_error; pass new_case run_case first_backup --configs; expect_rc 0 clean_nominal run_case second_backup --configs; expect_rc 0 clean_nominal [[ -e "$case_dir/backups/arch-system-restore.SIM0001/existing/etc/pacman.conf" ]] || fail 'première sauvegarde absente' [[ -e "$case_dir/backups/arch-system-restore.SIM0001/absent/etc/makepkg.conf" ]] || fail 'marqueur de cible absente absent' [[ -e "$case_dir/backups/arch-system-restore.SIM0002/existing/etc/makepkg.conf" ]] || fail 'seconde sauvegarde absente' [[ "$(cat "$case_dir/backups/arch-system-restore.SIM0001/existing/etc/pacman.conf")" == 'existing config' ]] || fail 'sauvegarde initiale incorrecte' [[ "$(cat "$case_dir/etc/makepkg.conf")" == 'fixture makepkg.conf' ]] || fail 'installation de cible absente manquante' pass new_case rm "$case_dir/etc/pacman.conf" ln -s /mnt/escape "$case_dir/etc/pacman.conf" run_case dangling_target --dry-run --configs; expect_rc 1; contains 'cible liée refusée'; no_sudo; pass new_case ln -s /mnt/escape "$case_dir/etc/tlp.d" run_case linked_parent --dry-run --configs; expect_rc 1; contains 'répertoire cible lié refusé'; no_sudo; pass new_case SIM_FAIL_INSTALL_TARGET=/usr/local/sbin/batlimit-set run_case temp_cleanup_failure --tools expect_rc 1 [[ -z "$(ls -A "$case_dir/tmp")" ]] || fail 'temporaire non nettoyé après erreur' [[ -e "$case_dir/backups/arch-system-restore.SIM0001/absent/usr/local/sbin/batlimit-set" ]] || fail 'marqueur supprimé après erreur' pass new_case SIM_INTERRUPT_TARGET=/usr/local/sbin/batlimit-set run_case term_cleanup --tools expect_rc 143 [[ -z "$(ls -A "$case_dir/tmp")" ]] || fail 'temporaire non nettoyé après TERM' contains 'Interruption TERM (143)'; contains 'Sauvegardes et marqueurs conservés'; clean_error; pass for signal in INT TERM; do new_case SIM_INTERRUPT_TARGET=/usr/local/sbin/vmware-stack SIM_INTERRUPT_SIGNAL="$signal" run_case "interrupt_second_$signal" --tools if [[ "$signal" == INT ]]; then expect_rc 130; else expect_rc 143; fi contains "Interruption $signal"; contains 'phase : outils root' contains 'installation:/usr/local/sbin/batlimit-set' contains 'Action interrompue, effets à vérifier : installation /usr/local/sbin/vmware-stack' contains 'Sauvegardes et marqueurs conservés : /root/arch-system-restore.SIM0001' not_contains 'Déploiement sélectionné terminé' [[ -e "$case_dir/usr-local-sbin/batlimit-set" ]] || fail 'premier helper non installé avant interruption' [[ -e "$case_dir/backups/arch-system-restore.SIM0001/absent/usr/local/sbin/vmware-stack" ]] || fail 'marqueur non conservé' [[ -z "$(ls -A "$case_dir/tmp")" ]] || fail 'temporaires non nettoyés après signal' clean_error; pass done new_case rm "$case_dir/etc/hostname" run_case missing_hostname --configs; expect_rc 1; contains '/etc/hostname illisible ou absent'; no_sudo; clean_error; pass new_case SIM_ANSWER=n run_case user_cancel --configs; expect_rc 0; contains 'Déploiement annulé'; no_sudo; clean_nominal; pass new_case SIM_MISSING_UNIT=alpha.service run_case missing_unit --dry-run --services expect_rc 1; contains 'unité requise absente'; no_sudo; pass new_case SIM_FAIL_SYSTEMCTL=enable run_case failed_enable --services expect_rc 1; contains 'activation alpha.service'; not_contains 'Déploiement sélectionné terminé'; pass new_case SIM_UNIT_ENABLED=enabled SIM_FAIL_SYSTEMCTL=disable run_case failed_disable --tools expect_rc 1; contains 'désactivation ondemand.service'; not_contains 'Déploiement sélectionné terminé'; pass new_case SIM_MISSING_UNIT=alpha.service run_case deferred_unit_failure --packages --services expect_rc 1; contains 'Étapes achevées : paquets'; contains 'unité requise absente'; pass new_case run_case tools_identity --tools; expect_rc 0 clean_nominal grep -Fq 'fixtureuser ALL=' "$case_dir/etc/sudoers.d/batlimit" || fail 'sudoers utilisateur erroné' [[ -z "$(ls -A "$case_dir/tmp")" ]] || fail 'temporaire non nettoyé en réussite' pass new_case run_case all_order --all; expect_rc 0 clean_nominal python3 - "$case_dir/calls" <<'PY' from pathlib import Path import sys s=Path(sys.argv[1]).read_text() for a,b in [('sudo pacman -Syu','yay -S'),('yay -S','sudo install -D'),('sudo install -D','sudo systemctl enable')]: if s.find(a)<0 or s.find(b)<0 or s.find(a)>=s.find(b): raise SystemExit(f'order failure: {a} -> {b}') PY pass printf 'Résultat : %s scénarios validés dans bwrap ; aucun script du dépôt exécuté hors copie.\n' "$passes"