From 074d5dd0b2a1b362c8c0cd6413ab77f868461e25 Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 2 Oct 2026 14:40:24 +0200 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20s=C3=A9curiser=20les=20contr?= =?UTF-8?q?=C3=B4les,=20sauvegardes=20et=20interruptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/deploy-safety.md | 46 +++ scripts/deploy.sh | 692 +++++++++++++++++++++++------------ tests/deploy/mock-command.sh | 143 ++++++++ tests/deploy/test.sh | 273 ++++++++++++++ 4 files changed, 928 insertions(+), 226 deletions(-) create mode 100644 docs/deploy-safety.md create mode 100755 tests/deploy/mock-command.sh create mode 100755 tests/deploy/test.sh diff --git a/docs/deploy-safety.md b/docs/deploy-safety.md new file mode 100644 index 0000000..917adb3 --- /dev/null +++ b/docs/deploy-safety.md @@ -0,0 +1,46 @@ +# Déploiement par étapes : contrat de sécurité V1 + +Ce document décrit `scripts/deploy.sh`. Il ne valide pas une réinstallation et **n'autorise pas son exécution réelle sur ArchASP** tant que les décisions pertinentes de [reconstruction](reconstruction-baseline.md) (notamment réseau et signature) restent ouvertes. Le script ne restaure ni `/etc/fstab`, ni Limine, ni les clés Secure Boot, ni les données ou dotfiles. + +## Options et résultat + +`--packages`, `--configs`, `--tools`, `--services` sélectionnent explicitement des étapes ; `--all` sélectionne les quatre dans cet ordre. Plusieurs sélections peuvent être combinées. Sans sélection, le code est `2`. `-h`/`--help` affiche l'aide. `--dry-run` s'ajoute à une sélection et affiche sources, destinations et actions sans confirmation, sudo, gestionnaire de paquets, sauvegarde ni temporaire de déploiement. Il peut lire les fichiers du dépôt et interroger sans privilèges la présence des unités systemd lorsque leur installation n'est pas demandée. + +Exemples de **plan uniquement** : + +```bash +./scripts/deploy.sh --dry-run --configs +./scripts/deploy.sh --dry-run --all +``` + +Le code `0` en simulation signifie seulement « préconditions statiques connues satisfaites et plan affiché ». Les validations nftables/sudoers privilégiées, la présence future des unités apportées par les paquets et le résultat d'une installation ne sont pas vérifiés par ce code. Le code `1` signale une précondition ou une étape échouée ; le code `2`, un usage invalide ; `130`/`143`, une interruption gérable par `INT`/`TERM`. En application, `0` signifie que les commandes sélectionnées se sont terminées sans erreur signalée **ou que l'utilisateur a répondu non à la confirmation** ; dans ce dernier cas, « Déploiement annulé » est affiché et rien n'est appliqué. Un code `0` après application ne prouve pas le démarrage ni la conformité fonctionnelle. + +## Préconditions et ordre + +Les fichiers de configuration, helpers et templates sélectionnés doivent exister, être lisibles, non vides et ne pas être des liens. Les listes de paquets et manifests doivent exister et être lisibles ; un fichier **présent mais vide** est une sélection volontairement vide. Les lignes vides et commentaires entiers sont ignorés ; chaque autre ligne doit contenir un seul nom valide, sans doublon ni option commençant par `-`. Une source manquante ou une ligne invalide arrête le lot avant toute application. + +Le script doit être appelé depuis un compte non root lorsque des paquets étrangers ou `--tools` sont demandés. Il obtient le bénéficiaire avec `id`, pas avec `USER` ou `SUDO_USER`, et refuse une règle sudoers au nom de root. Il n'amorce pas `yay` : une liste étrangère non vide avec `yay` absent fait échouer le précontrôle avant l'installation native. Les noms du fichier « AUR » sont des entrées à soumettre à yay, pas une preuve qu'ils proviennent tous de l'AUR ; leur compatibilité et leur source doivent être examinées séparément. + +Pour une application, le nom de machine est lu et validé dans `/etc/hostname` avant la confirmation et toute mutation. Un fichier absent, illisible, vide ou invalide arrête l'application avec code `1`. La commande externe `hostname` n'est pas requise. Le contrôle privilégié des cibles utilise `find` et `/usr/bin/bash`, qui doivent être disponibles ; une absence de `find` est diagnostiquée au précontrôle. + +Après confirmation, les validations privilégiées connues sont tentées **avant la première mutation durable** : `sudo -v`, `nft -c -f` pour `--configs`, puis rendu des deux règles sudoers et `visudo -cf` pour `--tools`. Si `--packages` est également demandé et fournit `nft`, sa disponibilité et la validation sont différées après les paquets, avant la première configuration. Les unités systemd sont vérifiées immédiatement si aucun paquet n'est demandé ; sinon elles sont vérifiées après les paquets, avant leur première utilisation. Un `--all` peut donc échouer après installation des paquets : il n'est pas garanti entièrement avant la première mutation. + +`--tools` et `--services` peuvent **arrêter** les quatre unités VMware/Samba du manifest à la demande avec `disable --now`. `--services` active les services et timers listés. Une unité requise absente est une erreur ; une unité à la demande déjà désactivée et inactive est laissée telle quelle. Les autres erreurs de désactivation ou d'activation ne sont pas masquées. `--configs` effectue `daemon-reload` et lance TLP si sa commande est disponible. Ces actions peuvent interrompre un service déjà utilisé : choisir les étapes et le moment en conséquence. + +## Cibles, sauvegardes et reprise + +Les cibles existantes sont copiées **avant** remplacement dans un répertoire root privé créé paresseusement par `mktemp -d` sous `/root/arch-system-restore.*`. Chaque invocation obtient un nom atomiquement unique, y compris dans la même seconde. Les fichiers remplacés sont sous `existing/` avec leur chemin d'origine et conservent leurs métadonnées par `cp -a`. Une cible initialement absente reçoit seulement un marqueur vide sous `absent/` : ce n'est pas une fausse copie de sauvegarde. Les helpers et les deux règles sudoers suivent la même politique que les configurations. Le chemin de sauvegarde est affiché en cas d'échec et à la fin. + +Le script refuse une cible ou un parent qui est un lien symbolique, même cassé, ou une cible existante non régulière. Il réexamine les cibles avant sauvegarde et avant installation. Le contrôle privilégié renvoie, **uniquement après succès de l'inspection**, un état strict : `EXISTING`, `ABSENT`, `LINK` ou `INVALID`. Une erreur de sudo ou de l'inspection, une réponse vide ou inconnue, ou un état plausible accompagné d'un code d'échec arrêtent le lot ; aucun marqueur d'absence ni remplacement de la cible concernée n'est alors autorisé. Une erreur au second contrôle conserve la sauvegarde déjà faite. Cela limite les écritures inattendues mais ne garantit pas l'absence de toute course avec un autre processus privilégié. Les fichiers sudoers rendus sont gardés dans un répertoire temporaire privé et supprimés sur sortie normale, erreur, `INT` ou `TERM`. Les sauvegardes root ne sont jamais supprimées par ce nettoyage. Un `SIGKILL` ou une coupure de courant ne déclenche pas de trap. + +Une erreur arrête les étapes suivantes et affiche la phase, les phases achevées, les actions déjà appliquées et le répertoire de sauvegarde disponible. Sur `INT` ou `TERM`, le bilan affiche le signal, la phase, les étapes achevées, les actions **confirmées**, l'action en cours dont les effets sont à vérifier, et le chemin des sauvegardes lorsqu'il existe. Une commande interrompue peut avoir déjà eu des effets, même si elle n'est pas dans la liste des actions confirmées. Il n'y a **aucun rollback automatique global** : pacman/yay, services et fichiers n'ont pas une annulation commune. Pour reprendre, comparer l'état réel aux cibles et aux copies ou marqueurs, restaurer manuellement ce qui doit l'être, puis relancer uniquement une étape comprise. Les installations de paquets peuvent être relancées avec `--needed`, mais cela n'annule pas une transaction partielle ; les copies et activations systemd sont répétables seulement après vérification de l'état et des effets de service. + +## Tests autorisés + +Commande unique du harnais : + +```bash +tests/deploy/test.sh +``` + +Le harnais exige `bwrap` déjà installé et vérifie les exécutables réels qu'il place dans son `PATH` fermé ; un outil manquant échoue avant la création d'un lien cassé. Il crée un dépôt synthétique et exécute **une copie** de `deploy.sh` dans un espace sans réseau, avec `/` en lecture seule, `/etc` et `/usr/local/sbin` remplacés par des fixtures privées. La doublure `sudo` reconnaît strictement le fragment de contrôle prévu et simule son résultat sans jamais exécuter librement un argument `bash -c` ; toute autre sous-commande inconnue est refusée. Paquets, yay, nftables et systemd sont simulés. Les scénarios nominaux rejettent tout stderr ; les scénarios d'erreur contrôlent les diagnostics admis. Aucun essai réel du script du dépôt sur ArchASP n'est effectué. `bash -n` complète les tests ; ShellCheck n'est utilisé que s'il est déjà présent. Les réponses simulées prouvent uniquement les branches exercées : ni déploiement, ni pare-feu, ni signature, ni démarrage, ni restauration ne sont validés. diff --git a/scripts/deploy.sh b/scripts/deploy.sh index ca14337..3c43497 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -2,288 +2,528 @@ set -Eeuo pipefail -readonly REPO_DIR="$( - cd "$(dirname "${BASH_SOURCE[0]}")/.." - pwd -)" +readonly REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DO_PACKAGES=0 DO_CONFIGS=0 DO_TOOLS=0 DO_SERVICES=0 DRY_RUN=0 +BACKUP_DIR='' TEMP_DIR='' BENEFICIARY='' MACHINE_NAME='' DISPLAY_USER='' +CURRENT_STAGE='précontrôles' CURRENT_ACTION='' +COMPLETED=() APPLIED=() OFFICIAL_PACKAGES=() FOREIGN_PACKAGES=() +SYSTEM_SERVICES=() SYSTEM_TIMERS=() ON_DEMAND_SERVICES=() -readonly BACKUP_DIR="/root/arch-system-restore-$(date +%F-%H%M%S)" +readonly CONFIG_SOURCES=( + rootfs/etc/pacman.conf rootfs/etc/makepkg.conf rootfs/etc/mkinitcpio.conf + rootfs/etc/nftables.conf rootfs/etc/limine-snapper-sync.conf + rootfs/etc/tlp.d/10-archasp.conf rootfs/etc/systemd/zram-generator.conf + rootfs/etc/snapper/configs/root +) +readonly CONFIG_TARGETS=( + /etc/pacman.conf /etc/makepkg.conf /etc/mkinitcpio.conf + /etc/nftables.conf /etc/limine-snapper-sync.conf + /etc/tlp.d/10-archasp.conf /etc/systemd/zram-generator.conf + /etc/snapper/configs/root +) +readonly HELPER_NAMES=(batlimit-set vmware-stack) +readonly SUDOERS_NAMES=(batlimit vmware-stack) -DO_PACKAGES=0 -DO_CONFIGS=0 -DO_TOOLS=0 -DO_SERVICES=0 +# Le fragment privilégié est fixe : aucun argument n'est interprété comme du +# code. find termine avec 0 et émet un état même si le composant est absent ; +# toute erreur d'inspection fait échouer la commande au lieu de signifier ABSENT. +readonly INSPECT_CODE=' +target="$1" +[[ "$target" == /* && "$target" != / ]] || exit 2 +rest="${target#/}" +cursor=/ +while [[ -n "$rest" ]]; do + name="${rest%%/*}" + [[ -n "$name" && "$name" != . && "$name" != .. ]] || exit 2 + if [[ "$rest" == */* ]]; then rest="${rest#*/}"; last=0 + else rest=""; last=1; fi + kind="$(find "$cursor" -mindepth 1 -maxdepth 1 -name "$name" -printf "%y\n" -quit)" || exit 3 + case "$kind" in + "") printf "ABSENT\n"; exit 0 ;; + l) printf "LINK\n"; exit 0 ;; + d) if (( last )); then printf "INVALID\n"; exit 0; fi ;; + f) if (( last )); then printf "EXISTING\n"; else printf "INVALID\n"; fi; exit 0 ;; + *) printf "INVALID\n"; exit 0 ;; + esac + if [[ "$cursor" == / ]]; then cursor="/$name"; else cursor="$cursor/$name"; fi +done +exit 2 +' usage() { - cat <<'EOF' -Usage : deploy.sh [options] - - --packages Installe les paquets officiels et AUR + cat <<'HELP' +Usage : deploy.sh [--dry-run] (--packages|--configs|--tools|--services|--all) ... + --packages Installe les listes de paquets natifs et étrangers (yay requis) --configs Restaure les configurations système suivies --tools Installe les helpers root et les règles sudoers --services Active les services et timers système voulus - --all Exécute toutes les étapes -EOF + --all Exécute les quatre étapes dans cet ordre + --dry-run Affiche un plan statique, sans privilèges ni application + -h, --help Affiche cette aide +Codes : 0 = plan valide, annulation ou application terminée ; 1 = précondition/application +échouée ; 2 = usage invalide ; 130/143 = interruption INT/TERM. +HELP } +fatal() { + printf '[!] Échec (%s) : %s\n' "$CURRENT_STAGE" "$1" >&2 + if (( ${#COMPLETED[@]} )); then + printf '[!] Étapes achevées : %s\n' "${COMPLETED[*]}" >&2 + else + printf '[!] Aucune étape achevée.\n' >&2 + fi + if (( ${#APPLIED[@]} )); then + printf '[!] Actions déjà appliquées : %s\n' "${APPLIED[*]}" >&2 + fi + [[ -z "$CURRENT_ACTION" ]] || + printf '[!] Action échouée, effets à vérifier : %s\n' "$CURRENT_ACTION" >&2 + [[ -z "$BACKUP_DIR" ]] || + printf '[!] Sauvegardes et marqueurs de cibles absentes : %s\n' "$BACKUP_DIR" >&2 + printf '[!] Pas de rollback global : inspecter les changements avant reprise.\n' >&2 + exit 1 +} + +run() { + local label="$1" + shift + CURRENT_ACTION="$label" + "$@" || fatal "$label" + CURRENT_ACTION='' +} + +interrupt() { + local signal="$1" status="$2" + trap - INT TERM + printf '[!] Interruption %s (%s), phase : %s\n' "$signal" "$status" "$CURRENT_STAGE" >&2 + if (( ${#COMPLETED[@]} )); then + printf '[!] Étapes achevées : %s\n' "${COMPLETED[*]}" >&2 + else + printf '[!] Aucune étape achevée.\n' >&2 + fi + if (( ${#APPLIED[@]} )); then + printf '[!] Actions confirmées : %s\n' "${APPLIED[*]}" >&2 + else + printf '[!] Aucune action confirmée.\n' >&2 + fi + [[ -z "$CURRENT_ACTION" ]] || + printf '[!] Action interrompue, effets à vérifier : %s\n' "$CURRENT_ACTION" >&2 + [[ -z "$BACKUP_DIR" ]] || + printf '[!] Sauvegardes et marqueurs conservés : %s\n' "$BACKUP_DIR" >&2 + exit "$status" +} + +cleanup() { + local status="$1" + if [[ -n "$TEMP_DIR" ]]; then + # Supprimer uniquement les deux fichiers créés par cette invocation. + rm -f -- "$TEMP_DIR/batlimit" "$TEMP_DIR/vmware-stack" || + printf '[!] Nettoyage des sudoers temporaires incomplet : %s\n' "$TEMP_DIR" >&2 + rmdir -- "$TEMP_DIR" 2>/dev/null || true + fi + exit "$status" +} +trap 'cleanup $?' EXIT +trap 'interrupt INT 130' INT +trap 'interrupt TERM 143' TERM + while (( $# > 0 )); do case "$1" in - --packages) - DO_PACKAGES=1 - ;; - --configs) - DO_CONFIGS=1 - ;; - --tools) - DO_TOOLS=1 - ;; - --services) - DO_SERVICES=1 - ;; - --all) - DO_PACKAGES=1 - DO_CONFIGS=1 - DO_TOOLS=1 - DO_SERVICES=1 - ;; - -h|--help) - usage - exit 0 - ;; - *) - printf 'Option inconnue : %s\n' "$1" >&2 - usage - exit 2 - ;; + --packages) DO_PACKAGES=1 ;; + --configs) DO_CONFIGS=1 ;; + --tools) DO_TOOLS=1 ;; + --services) DO_SERVICES=1 ;; + --all) DO_PACKAGES=1; DO_CONFIGS=1; DO_TOOLS=1; DO_SERVICES=1 ;; + --dry-run) DRY_RUN=1 ;; + -h|--help) usage; exit 0 ;; + *) printf 'Option inconnue : %s\n' "$1" >&2; usage; exit 2 ;; esac - shift done - if (( DO_PACKAGES + DO_CONFIGS + DO_TOOLS + DO_SERVICES == 0 )); then usage exit 2 fi -read -rp \ - "Déployer arch-system sur $(hostname) pour ${USER} ? [y/N] " \ - confirm +require_source() { + [[ -f "$1" && -r "$1" && -s "$1" && ! -L "$1" ]] || + fatal "source obligatoire absente, vide, illisible ou liée : $1" +} -[[ "$confirm" =~ ^[yY]$ ]] || exit 0 +# Une liste présente et vide est volontairement vide. Une substitution de +# processus cacherait une erreur de lecture : lire directement et vérifier. +read_list() { + local file="$1" kind="$2" output_name="$3" line item + local -n output="$output_name" + local -A seen=() + [[ -f "$file" && -r "$file" && ! -L "$file" ]] || + fatal "liste/manifest absent, illisible ou lié : $file" + while IFS= read -r line || [[ -n "$line" ]]; do + item="${line#"${line%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [[ -z "$item" || "$item" == \#* ]] && continue + case "$kind" in + package) + [[ "$item" =~ ^[a-zA-Z0-9@][a-zA-Z0-9@._+-]*$ ]] || + fatal "nom de paquet invalide dans $file : $item" ;; + service) + [[ "$item" =~ ^[a-zA-Z0-9@_.-]+\.service$ ]] || + fatal "unité service invalide dans $file : $item" ;; + timer) + [[ "$item" =~ ^[a-zA-Z0-9@_.-]+\.timer$ ]] || + fatal "unité timer invalide dans $file : $item" ;; + esac + [[ ! -v "seen[$item]" ]] || fatal "doublon dans $file : $item" + seen["$item"]=1 + output+=("$item") + done < "$file" || fatal "lecture échouée : $file" +} -backup_target() { - local target="$1" +require_command() { + command -v "$1" >/dev/null 2>&1 || fatal "commande requise absente : $1" +} - if sudo test -e "$target"; then - sudo mkdir -p "$BACKUP_DIR$(dirname "$target")" - sudo cp -a "$target" "$BACKUP_DIR$target" +read_machine_name() { + [[ -r /etc/hostname ]] || fatal 'nom de machine indisponible : /etc/hostname illisible ou absent' + IFS= read -r MACHINE_NAME < /etc/hostname || + [[ -n "$MACHINE_NAME" ]] || fatal 'lecture du nom de machine échouée' + [[ "$MACHINE_NAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + fatal 'nom de machine invalide dans /etc/hostname' +} + +check_beneficiary() { + local uid + # Ne jamais utiliser USER/SUDO_USER comme autorité pour sudoers ou AUR. + (( EUID != 0 )) || fatal 'compte root refusé pour cette invocation' + BENEFICIARY="$(id -un)" || fatal 'identité du compte indisponible' + uid="$(id -u "$BENEFICIARY")" || fatal 'compte bénéficiaire inexistant' + [[ "$BENEFICIARY" =~ ^[a-z_][a-z0-9_-]*\$?$ && "$uid" =~ ^[0-9]+$ ]] || + fatal 'nom de compte bénéficiaire invalide' + (( uid > 0 )) || fatal 'compte bénéficiaire root refusé' +} + +check_target_local() { + local parent="${1%/*}" + while [[ "$parent" != / ]]; do + [[ ! -L "$parent" ]] || fatal "répertoire cible lié refusé : $parent" + [[ ! -e "$parent" || -d "$parent" ]] || + fatal "parent non répertoire refusé : $parent" + parent="${parent%/*}" + [[ -n "$parent" ]] || parent=/ + done + [[ ! -L "$1" ]] || fatal "cible liée refusée : $1" + [[ ! -e "$1" || -f "$1" ]] || fatal "cible non régulière refusée : $1" +} + +check_unit() { + systemctl cat "$1" >/dev/null 2>&1 || fatal "unité requise absente : $1" +} + +check_units() { + local unit + if (( DO_SERVICES )); then + for unit in "${SYSTEM_SERVICES[@]}" "${SYSTEM_TIMERS[@]}"; do + check_unit "$unit" + done + fi + if (( DO_TOOLS || DO_SERVICES )); then + for unit in "${ON_DEMAND_SERVICES[@]}"; do + check_unit "$unit" + done fi } -install_config() { - local source="$1" - local target="$2" - local mode="${3:-0644}" - - if [[ ! -f "$source" ]]; then - printf '[!] Source absente : %s\n' "$source" >&2 - return 0 +preflight_static() { + local i name + if (( DO_PACKAGES )); then + read_list "$REPO_DIR/pkglist/pacman.txt" package OFFICIAL_PACKAGES + read_list "$REPO_DIR/pkglist/aur.txt" package FOREIGN_PACKAGES + require_command pacman + if (( ${#FOREIGN_PACKAGES[@]} )); then + require_command yay + check_beneficiary + fi + fi + if (( DO_CONFIGS )); then + for i in "${!CONFIG_SOURCES[@]}"; do + require_source "$REPO_DIR/${CONFIG_SOURCES[i]}" + check_target_local "${CONFIG_TARGETS[i]}" + done + if (( ! DO_PACKAGES )); then require_command nft; fi + require_command systemctl + fi + if (( DO_TOOLS )); then + check_beneficiary + for name in "${HELPER_NAMES[@]}"; do + require_source "$REPO_DIR/rootfs/usr/local/sbin/$name" + check_target_local "/usr/local/sbin/$name" + done + for name in "${SUDOERS_NAMES[@]}"; do + require_source "$REPO_DIR/templates/sudoers.d/$name" + grep -Fq '@USER@' "$REPO_DIR/templates/sudoers.d/$name" || + fatal "placeholder @USER@ absent : $name" + check_target_local "/etc/sudoers.d/$name" + done + require_command visudo + fi + if (( DO_SERVICES )); then + read_list "$REPO_DIR/manifests/system-services.txt" service SYSTEM_SERVICES + read_list "$REPO_DIR/manifests/system-timers.txt" timer SYSTEM_TIMERS + fi + if (( DO_TOOLS || DO_SERVICES )); then + read_list "$REPO_DIR/manifests/on-demand-system-services.txt" service ON_DEMAND_SERVICES + require_command systemctl + fi + if (( ! DO_PACKAGES )); then + check_units + fi + if (( ! DRY_RUN )); then + require_command sudo + require_command install + require_command mktemp + require_command find + read_machine_name + DISPLAY_USER="$(id -un)" || fatal 'identité du compte indisponible' fi - - backup_target "$target" - - sudo install \ - -D \ - -m "$mode" \ - "$source" \ - "$target" } -install_package_list() { - local manager="$1" - local file="$2" - local -a packages=() - - if [[ ! -s "$file" ]]; then - printf '[!] Liste vide : %s\n' "$file" - return 0 +show_plan() { + local i name unit + printf '[PLAN] Aucun changement appliqué ; validations privilégiées non exécutées.\n' + if (( DO_PACKAGES )); then + printf '[PLAN] %s -> pacman -Syu --needed (%s entrées)\n' \ + "$REPO_DIR/pkglist/pacman.txt" "${#OFFICIAL_PACKAGES[@]}" + printf '[PLAN] %s -> yay -S --needed (%s entrées étrangères)\n' \ + "$REPO_DIR/pkglist/aur.txt" "${#FOREIGN_PACKAGES[@]}" fi + if (( DO_CONFIGS )); then + printf '[PLAN] Validation nftables privilégiée différée : %s\n' \ + "$REPO_DIR/rootfs/etc/nftables.conf" + for i in "${!CONFIG_SOURCES[@]}"; do + printf '[PLAN] %s -> %s\n' \ + "$REPO_DIR/${CONFIG_SOURCES[i]}" "${CONFIG_TARGETS[i]}" + done + printf '[PLAN] systemctl daemon-reload ; tlp start si présent.\n' + fi + if (( DO_TOOLS )); then + for name in "${HELPER_NAMES[@]}"; do + printf '[PLAN] %s -> /usr/local/sbin/%s\n' \ + "$REPO_DIR/rootfs/usr/local/sbin/$name" "$name" + done + for name in "${SUDOERS_NAMES[@]}"; do + printf '[PLAN] %s -> /etc/sudoers.d/%s (visudo différé, compte %s)\n' \ + "$REPO_DIR/templates/sudoers.d/$name" "$name" "$BENEFICIARY" + done + fi + if (( DO_SERVICES )); then + for unit in "${SYSTEM_SERVICES[@]}" "${SYSTEM_TIMERS[@]}"; do + printf '[PLAN] Activer : %s\n' "$unit" + done + fi + if (( DO_TOOLS || DO_SERVICES )); then + for unit in "${ON_DEMAND_SERVICES[@]}"; do + printf '[PLAN] Désactiver et arrêter si nécessaire : %s\n' "$unit" + done + fi + if (( DO_PACKAGES )); then + printf '[PLAN] Unités fournies par les paquets : contrôle différé après installation.\n' + fi + printf '[PLAN] Plan statique valide, déploiement non effectué.\n' +} - mapfile -t packages < <( - grep -Ev '^[[:space:]]*(#|$)' "$file" - ) +preflight_privileged() { + local name + run 'autorisation sudo' sudo -v + if (( DO_CONFIGS )); then + if command -v nft >/dev/null 2>&1; then + run 'validation nftables' sudo nft -c -f "$REPO_DIR/rootfs/etc/nftables.conf" + elif (( ! DO_PACKAGES )); then + fatal 'nft absent avant la phase configurations' + fi + fi + if (( DO_TOOLS )); then + TEMP_DIR="$(mktemp -d)" || fatal 'création du répertoire temporaire sudoers' + for name in "${SUDOERS_NAMES[@]}"; do + sed "s/@USER@/$BENEFICIARY/g" "$REPO_DIR/templates/sudoers.d/$name" \ + > "$TEMP_DIR/$name" || fatal "rendu sudoers $name" + run "validation sudoers $name" sudo visudo -cf "$TEMP_DIR/$name" + done + fi +} - (( ${#packages[@]} > 0 )) || return 0 - - case "$manager" in - pacman) - sudo pacman -Syu --needed "${packages[@]}" - ;; - - yay) - yay -S --needed "${packages[@]}" - ;; - - *) - printf 'Gestionnaire inconnu : %s\n' "$manager" >&2 - return 2 - ;; +# Seul un résultat strict, produit avec un code de succès, décrit la cible. +# Un échec sudo/find, y compris avec une sortie plausible, reste indéterminé. +check_target_privileged() { + local target="$1" state + if state="$(sudo /usr/bin/bash -c "$INSPECT_CODE" -- "$target")"; then + : + else + fatal "contrôle privilégié impossible : $target" + fi + case "$state" in + EXISTING) return 0 ;; + ABSENT) return 1 ;; + LINK) fatal "cible ou parent lié refusé : $target" ;; + INVALID) fatal "cible ou parent non régulier refusé : $target" ;; + *) fatal "réponse de contrôle invalide pour $target" ;; esac } -enable_manifest() { - local file="$1" - local unit +ensure_backup_dir() { + [[ -n "$BACKUP_DIR" ]] && return 0 + # mktemp crée atomiquement un répertoire root privé, sans collision + # entre deux invocations commencées dans la même seconde. + BACKUP_DIR="$(sudo mktemp -d /root/arch-system-restore.XXXXXXXX)" || + fatal 'création du répertoire privé de sauvegarde' + [[ "$BACKUP_DIR" == /root/arch-system-restore.* ]] || + fatal 'chemin de sauvegarde inattendu' +} - [[ -f "$file" ]] || return 0 +backup_target() { + local target="$1" + local exists=0 + if check_target_privileged "$target"; then exists=1; fi + ensure_backup_dir + if (( exists )); then + run "répertoire de sauvegarde $target" sudo mkdir -p \ + "$BACKUP_DIR/existing$(dirname "$target")" + run "sauvegarde $target" sudo cp -a -- "$target" \ + "$BACKUP_DIR/existing$target" + else + # Un marqueur distinct d'une copie permet de retirer manuellement + # une cible créée par ce déploiement sans inventer un ancien contenu. + run "marqueur de cible absente $target" sudo install -D -m 0600 \ + /dev/null "$BACKUP_DIR/absent$target" + fi +} - while IFS= read -r unit; do - [[ -z "$unit" || "$unit" == \#* ]] && continue +install_target() { + local source="$1" target="$2" mode="$3" + backup_target "$target" + # Réexaminer la cible après la copie et avant le remplacement ; un + # changement concurrent ne doit pas transformer la destination en lien. + if check_target_privileged "$target"; then :; fi + run "installation $target" sudo install -D -m "$mode" "$source" "$target" + APPLIED+=("installation:$target") +} - if systemctl cat "$unit" >/dev/null 2>&1; then - sudo systemctl enable "$unit" - else - printf '[!] Unité absente : %s\n' "$unit" +unit_state() { + local unit="$1" property="$2" result + if [[ "$property" == enabled ]]; then + result="$(systemctl is-enabled "$unit" 2>/dev/null)" || : + else + result="$(systemctl is-active "$unit" 2>/dev/null)" || : + fi + [[ -n "$result" ]] || fatal "état systemd indéterminé : $unit" + printf '%s' "$result" +} + +disable_on_demand() { + local unit enabled active + for unit in "${ON_DEMAND_SERVICES[@]}"; do + check_unit "$unit" + enabled="$(unit_state "$unit" enabled)" + active="$(unit_state "$unit" active)" + if [[ "$enabled" == disabled && "$active" == inactive ]]; then + printf '[*] Déjà désactivé/inactif : %s\n' "$unit" + continue fi - done < "$file" + [[ "$enabled" =~ ^(enabled|enabled-runtime|disabled|masked)$ && + "$active" =~ ^(active|activating|inactive|failed)$ ]] || + fatal "état systemd non pris en charge : $unit ($enabled/$active)" + run "désactivation $unit" sudo systemctl disable --now "$unit" + APPLIED+=("désactivation:$unit") + done } -disable_manifest() { - local file="$1" +enable_units() { local unit - - [[ -f "$file" ]] || return 0 - - while IFS= read -r unit; do - [[ -z "$unit" || "$unit" == \#* ]] && continue - - sudo systemctl disable --now "$unit" 2>/dev/null || true - done < "$file" + for unit in "${SYSTEM_SERVICES[@]}" "${SYSTEM_TIMERS[@]}"; do + check_unit "$unit" + run "activation $unit" sudo systemctl enable "$unit" + APPLIED+=("activation:$unit") + done } +preflight_static +if (( DRY_RUN )); then + show_plan + exit 0 +fi + +read -rp "Déployer arch-system sur ${MACHINE_NAME} pour ${DISPLAY_USER} ? [y/N] " confirm || + fatal 'confirmation indisponible' +[[ "$confirm" =~ ^[yY]$ ]] || { printf '[*] Déploiement annulé.\n'; exit 0; } + +# Les validations privilégiées précèdent tout changement durable ; les unités +# fournies par les paquets demandés restent nécessairement vérifiées après eux. +preflight_privileged + if (( DO_PACKAGES )); then - echo '[*] Installation des paquets officiels' - - install_package_list \ - pacman \ - "$REPO_DIR/pkglist/pacman.txt" - - if [[ -s "$REPO_DIR/pkglist/aur.txt" ]]; then - if command -v yay >/dev/null 2>&1; then - echo '[*] Installation des paquets AUR' - - install_package_list \ - yay \ - "$REPO_DIR/pkglist/aur.txt" - else - echo '[!] Yay est absent : installation AUR reportée.' - fi + CURRENT_STAGE='paquets' + if (( ${#OFFICIAL_PACKAGES[@]} )); then + run 'installation des paquets natifs' sudo pacman -Syu --needed \ + "${OFFICIAL_PACKAGES[@]}" + APPLIED+=(paquets-natifs) + else + printf '[*] Liste native présente mais vide.\n' + fi + if (( ${#FOREIGN_PACKAGES[@]} )); then + run 'installation des paquets étrangers' yay -S --needed \ + "${FOREIGN_PACKAGES[@]}" + APPLIED+=(paquets-étrangers) + else + printf '[*] Liste étrangère présente mais vide.\n' + fi + COMPLETED+=(paquets) + if (( DO_CONFIGS )) && ! command -v nft >/dev/null 2>&1; then + fatal 'nft reste absent après installation des paquets' + fi + if (( DO_CONFIGS )); then + run 'validation nftables différée' sudo nft -c -f \ + "$REPO_DIR/rootfs/etc/nftables.conf" + fi + if (( DO_TOOLS || DO_SERVICES )); then + CURRENT_STAGE='contrôle différé des unités' + check_units fi fi if (( DO_CONFIGS )); then - echo "[*] Sauvegardes dans ${BACKUP_DIR}" - - # Valider le pare-feu avant de remplacer le fichier actif. - if [[ -f "$REPO_DIR/rootfs/etc/nftables.conf" ]]; then - sudo nft -c -f "$REPO_DIR/rootfs/etc/nftables.conf" - fi - - install_config \ - "$REPO_DIR/rootfs/etc/pacman.conf" \ - /etc/pacman.conf - - install_config \ - "$REPO_DIR/rootfs/etc/makepkg.conf" \ - /etc/makepkg.conf - - install_config \ - "$REPO_DIR/rootfs/etc/mkinitcpio.conf" \ - /etc/mkinitcpio.conf - - install_config \ - "$REPO_DIR/rootfs/etc/nftables.conf" \ - /etc/nftables.conf - - install_config \ - "$REPO_DIR/rootfs/etc/limine-snapper-sync.conf" \ - /etc/limine-snapper-sync.conf - - install_config \ - "$REPO_DIR/rootfs/etc/tlp.d/10-archasp.conf" \ - /etc/tlp.d/10-archasp.conf - - install_config \ - "$REPO_DIR/rootfs/etc/systemd/zram-generator.conf" \ - /etc/systemd/zram-generator.conf - - install_config \ - "$REPO_DIR/rootfs/etc/snapper/configs/root" \ - /etc/snapper/configs/root - - sudo systemctl daemon-reload - + CURRENT_STAGE='configurations' + for i in "${!CONFIG_SOURCES[@]}"; do + install_target "$REPO_DIR/${CONFIG_SOURCES[i]}" "${CONFIG_TARGETS[i]}" 0644 + done + run 'rechargement systemd' sudo systemctl daemon-reload + APPLIED+=(rechargement-systemd) if command -v tlp >/dev/null 2>&1; then - sudo tlp start + run 'démarrage TLP' sudo tlp start + APPLIED+=(démarrage-tlp) fi - - echo '[!] fstab, Limine et Secure Boot restent à finaliser manuellement.' + COMPLETED+=(configurations) + printf '[!] fstab, Limine et Secure Boot restent à finaliser manuellement.\n' fi if (( DO_TOOLS )); then - echo '[*] Installation des helpers root' - - sudo install \ - -D \ - -m 0755 \ - "$REPO_DIR/rootfs/usr/local/sbin/batlimit-set" \ - /usr/local/sbin/batlimit-set - - sudo install \ - -D \ - -m 0755 \ - "$REPO_DIR/rootfs/usr/local/sbin/vmware-stack" \ - /usr/local/sbin/vmware-stack - - echo '[*] Installation des sudoers minimaux' - - for name in batlimit vmware-stack; do - template="$REPO_DIR/templates/sudoers.d/$name" - temporary="$(mktemp)" - - sed "s/@USER@/${USER}/g" \ - "$template" \ - > "$temporary" - - sudo visudo -cf "$temporary" - - sudo install \ - -o root \ - -g root \ - -m 0440 \ - "$temporary" \ - "/etc/sudoers.d/$name" - - rm -f "$temporary" + CURRENT_STAGE='outils root' + for name in "${HELPER_NAMES[@]}"; do + install_target "$REPO_DIR/rootfs/usr/local/sbin/$name" \ + "/usr/local/sbin/$name" 0755 done - - disable_manifest \ - "$REPO_DIR/manifests/on-demand-system-services.txt" + for name in "${SUDOERS_NAMES[@]}"; do + install_target "$TEMP_DIR/$name" "/etc/sudoers.d/$name" 0440 + done + disable_on_demand + COMPLETED+=(outils) fi if (( DO_SERVICES )); then - echo '[*] Activation des services système' - - enable_manifest \ - "$REPO_DIR/manifests/system-services.txt" - - enable_manifest \ - "$REPO_DIR/manifests/system-timers.txt" - - disable_manifest \ - "$REPO_DIR/manifests/on-demand-system-services.txt" + CURRENT_STAGE='services système' + enable_units + disable_on_demand + COMPLETED+=(services) fi -echo '[+] Déploiement système terminé.' -echo '[*] Les configurations utilisateur restent dans Dotfiles_ArchAP.' -echo '[!] Vérifier fstab, Limine, mkinitcpio, Snapper, nftables et sbctl avant reboot.' +printf '[+] Déploiement sélectionné terminé : %s\n' "${COMPLETED[*]}" +[[ -z "$BACKUP_DIR" ]] || printf '[+] Sauvegardes et marqueurs : %s\n' "$BACKUP_DIR" +printf '[!] Aucune transaction globale ni rollback automatique.\n' +printf '[!] Vérifier fstab, Limine, mkinitcpio, Snapper, nftables et sbctl avant reboot.\n' diff --git a/tests/deploy/mock-command.sh b/tests/deploy/mock-command.sh new file mode 100755 index 0000000..c52a1eb --- /dev/null +++ b/tests/deploy/mock-command.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# All privileged calls are dispatched here inside the read-only bwrap sandbox. +set -euo pipefail + +name="${0##*/}" +printf '%s' "$name" >> /mnt/calls +printf ' %q' "$@" >> /mnt/calls +printf '\n' >> /mnt/calls + +if [[ "$name" == id ]]; then + if [[ "${SIM_ID_ROOT:-0}" == 1 ]]; then + case "${1:-}" in + -un) printf 'root\n' ;; + -u) printf '0\n' ;; + *) exit 90 ;; + esac + else + /usr/bin/id "$@" + fi + exit +fi + +mapped() { + case "$1" in + /root/arch-system-restore.*) printf '/mnt/backups%s' "${1#/root}" ;; + /etc/*|/usr/local/sbin/*) printf '%s' "$1" ;; + *) printf 'REFUSED destination: %s\n' "$1" >&2; exit 90 ;; + esac +} + +if [[ "$name" == systemctl ]]; then + action="${1:-}" + case "$action" in + cat) + [[ "${SIM_MISSING_UNIT:-}" != "$2" ]] && grep -Fxq -- "$2" /mnt/units ;; + is-enabled) printf '%s\n' "${SIM_UNIT_ENABLED:-disabled}" ;; + is-active) printf '%s\n' "${SIM_UNIT_ACTIVE:-inactive}" ;; + *) printf 'REFUSED systemctl read: %s\n' "$action" >&2; exit 90 ;; + esac + exit +fi + +if [[ "$name" == yay ]]; then + [[ "${1:-}" == -S && "${2:-}" == --needed ]] || exit 90 + [[ "${SIM_FAIL_YAY:-0}" != 1 ]] || exit 41 + exit 0 +fi + +if [[ "$name" != sudo ]]; then + printf 'REFUSED direct mutation: %s\n' "$name" >&2 + exit 90 +fi + +command_name="${1:-}" +shift || true +case "$command_name" in + -v) exit 0 ;; + /usr/bin/bash) + # Reconnaître le fragment fixe sans jamais exécuter le code reçu. + [[ $# == 4 && "$1" == -c && "$3" == -- ]] || exit 90 + digest="$(printf '%s' "$2" | /usr/bin/sha256sum)" + [[ "${digest%% *}" == 88a1ea2034ba3685baa35be4404de4089207fd0c781f248d7cbc3f9c56e33b67 ]] || exit 90 + target="$4" + [[ "$target" == /etc/* || "$target" == /usr/local/sbin/* ]] || exit 90 + count=0 + if [[ "${SIM_INSPECT_TARGET:-}" == "$target" ]]; then + [[ ! -f /mnt/inspect-count ]] || read -r count < /mnt/inspect-count + count=$((count + 1)) + printf '%s\n' "$count" > /mnt/inspect-count + mode="${SIM_INSPECT_MODE:-}" + if [[ "$mode" == second_fail && "$count" != 2 ]]; then mode=''; fi + case "$mode" in + fail) printf 'simulated sudo refusal\n' >&2; exit 1 ;; + parent_fail) printf 'simulated parent inspection error\n' >&2; exit 3 ;; + second_fail) printf 'simulated second inspection error\n' >&2; exit 3 ;; + empty) exit 0 ;; + unknown) printf 'UNKNOWN\n'; exit 0 ;; + valid_fail) printf 'EXISTING\n'; exit 1 ;; + '') ;; + *) exit 90 ;; + esac + fi + parent="${target%/*}" + while [[ "$parent" != / ]]; do + if [[ -L "$parent" ]]; then printf 'LINK\n'; exit 0; fi + if [[ ! -e "$parent" ]]; then printf 'ABSENT\n'; exit 0; fi + if [[ ! -d "$parent" ]]; then printf 'INVALID\n'; exit 0; fi + parent="${parent%/*}" + [[ -n "$parent" ]] || parent=/ + done + if [[ -L "$target" ]]; then printf 'LINK\n' + elif [[ -f "$target" ]]; then printf 'EXISTING\n' + elif [[ -e "$target" ]]; then printf 'INVALID\n' + else printf 'ABSENT\n'; fi ;; + mktemp) + [[ "$1" == -d && "$2" == '/root/arch-system-restore.XXXXXXXX' ]] || exit 90 + count=0 + [[ ! -f /mnt/counter ]] || read -r count < /mnt/counter + count=$((count + 1)) + printf '%s\n' "$count" > /mnt/counter + printf '/root/arch-system-restore.SIM%04d\n' "$count" ;; + nft) + [[ "$1" == -c && "$2" == -f && "$3" == /mnt/repo/rootfs/etc/nftables.conf ]] || exit 90 + [[ "${SIM_FAIL_NFT:-0}" != 1 ]] || exit 42 ;; + visudo) + [[ "$1" == -cf && "$2" == /tmp/* ]] || exit 90 + [[ "${SIM_FAIL_VISUDO:-0}" != 1 ]] || exit 43 + grep -Fq 'fixtureuser' "$2" ;; + pacman) + [[ "$1" == -Syu && "$2" == --needed ]] || exit 90 + [[ "${SIM_FAIL_PACMAN:-0}" != 1 ]] || exit 44 ;; + systemctl) + action="${1:-}" + case "$action" in + daemon-reload|enable|disable) ;; + *) exit 90 ;; + esac + [[ "${SIM_FAIL_SYSTEMCTL:-}" != "$action" ]] || exit 45 ;; + tlp) + [[ "${1:-}" == start ]] || exit 90 ;; + mkdir) + [[ "$1" == -p && "$2" == /root/arch-system-restore.* ]] || exit 90 + /usr/bin/mkdir -p -- "$(mapped "$2")" ;; + cp) + [[ "$1" == -a && "$2" == -- ]] || exit 90 + src="$3" dest="$(mapped "$4")" + [[ "$src" == /etc/* || "$src" == /usr/local/sbin/* ]] || exit 90 + /usr/bin/cp -a -- "$src" "$dest" ;; + install) + [[ "$1" == -D && "$2" == -m && $# == 5 ]] || exit 90 + mode="$3" src="$4" dest="$5" + [[ "$src" == /mnt/repo/* || "$src" == /tmp/* || "$src" == /dev/null ]] || exit 90 + [[ "$dest" == /etc/* || "$dest" == /usr/local/sbin/* || "$dest" == /root/arch-system-restore.* ]] || exit 90 + [[ "${SIM_FAIL_INSTALL_TARGET:-}" != "$dest" ]] || exit 46 + if [[ "${SIM_INTERRUPT_TARGET:-}" == "$dest" ]]; then + signal="${SIM_INTERRUPT_SIGNAL:-TERM}" + [[ "$signal" == INT || "$signal" == TERM ]] || exit 90 + kill "-$signal" "$PPID" + [[ "$signal" == INT ]] && exit 130 || exit 143 + fi + /usr/bin/install -D -m "$mode" "$src" "$(mapped "$dest")" ;; + *) printf 'REFUSED sudo subcommand: %s\n' "$command_name" >&2; exit 90 ;; +esac diff --git a/tests/deploy/test.sh b/tests/deploy/test.sh new file mode 100755 index 0000000..e8503bb --- /dev/null +++ b/tests/deploy/test.sh @@ -0,0 +1,273 @@ +#!/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"