fix(deploy): sécuriser les contrôles, sauvegardes et interruptions

This commit is contained in:
fy59 2026-10-02 14:40:24 +02:00
parent c77443b93c
commit 074d5dd0b2
4 changed files with 928 additions and 226 deletions

46
docs/deploy-safety.md Normal file
View file

@ -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.

View file

@ -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'

143
tests/deploy/mock-command.sh Executable file
View file

@ -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

273
tests/deploy/test.sh Executable file
View file

@ -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"