From 473b98a17b5b52b01c7b47f609cce5f62da22083 Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 2 Oct 2026 21:58:20 +0200 Subject: [PATCH] fix(backup): isoler les exports et fiabiliser leur finalisation --- README.md | 16 +- docs/backup.md | 140 ++++++-- scripts/backup.sh | 618 +++++++++++++++++++++++------------ tests/backup/mock-command.sh | 143 ++++++++ tests/backup/test.sh | 409 +++++++++++++++++++++++ 5 files changed, 1072 insertions(+), 254 deletions(-) create mode 100755 tests/backup/mock-command.sh create mode 100755 tests/backup/test.sh diff --git a/README.md b/README.md index 041f01d..d7aee3c 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,24 @@ restent des observations datées, pas une cible automatique de déploiement. ## Mise à jour du dépôt +**Changement de comportement :** sans argument, `backup.sh` crée désormais un +candidat d’export **privé hors du dépôt** pour revue. Il ne modifie plus +automatiquement les listes, configurations ou inventaires suivis par Git et +ne sauvegarde ni données personnelles ni clés. Sa présence locale ne prouve +pas une copie hors disque. Voir le [contrat d’export](docs/backup.md). + ```bash ./scripts/check-repo.sh +./scripts/backup.sh --dry-run +# Après décision explicite de collecter sur la machine concernée : ./scripts/backup.sh -git diff --check -git status --short ``` -Toujours relire le diff avant de commit : un inventaire généré peut révéler un paquet ajouté par erreur ou un service réactivé automatiquement. +Relire le manifeste et **tous** les fichiers candidats avant toute reprise +manuelle dans Git ou publication : noms de services, métadonnées de stockage, +configurations et modèle Limine peuvent contenir des informations sensibles ou +propres à la machine. Aucun candidat n’est promu automatiquement en référence +de reconstruction. ## Restauration diff --git a/docs/backup.md b/docs/backup.md index 25e9123..f4c079e 100644 --- a/docs/backup.md +++ b/docs/backup.md @@ -1,44 +1,118 @@ -# Sauvegarde du dépôt +# Export système pour revue -## Actualisation +`scripts/backup.sh` produit un **candidat privé hors du dépôt**. La commande +sans argument ne met plus à jour `pkglist/`, `inventory/`, `rootfs/` ni +`templates/` suivis par Git. Elle ne sauvegarde ni données personnelles, +dotfiles, clés Secure Boot, connexions réseau, secrets, ni fichiers Trainlog. +La zone locale d’export n’est pas une preuve de sauvegarde hors disque ou de +restauration possible. Ce code n’a été testé ici que sur des fixtures. -```bash -./scripts/backup.sh +## Interface et destination + +```text +backup.sh [--output-dir REPERTOIRE_PARENT] +backup.sh --dry-run [--output-dir REPERTOIRE_PARENT] +backup.sh --help ``` -Le script exporte : +`--output-dir` désigne un **parent** : chaque invocation crée un sous-répertoire +unique. Par défaut, le parent est +`${XDG_STATE_HOME:-$HOME/.local/state}/arch-system/exports`. Un +`XDG_STATE_HOME` relatif est ignoré avec diagnostic, selon la +[spécification XDG](https://specifications.freedesktop.org/basedir/0.8/) ; +le chemin par défaut sous `HOME` est alors employé. Un parent explicite +relatif est résolu depuis le répertoire courant. Les chemins sont normalisés ; +la destination égale au dépôt ou située dedans, les alias vers celui-ci et les +parents traversant un lien symbolique sont refusés. Un parent final existant +doit appartenir à l’utilisateur et ne pas être ouvert au groupe ou aux autres. +Les nouveaux répertoires sont créés privés, sans `chmod`/`chown` des ancêtres. +Un parent contenant des espaces ou un antislash littéral reste accepté ; le +calcul des empreintes ne dépend pas de la représentation de son nom. -- paquets officiels explicites avec `pacman -Qqen` ; -- paquets étrangers/AUR explicites avec `pacman -Qqem` ; -- paquets orphelins ; -- unités systemd actives, activées et en échec ; -- timers système et utilisateur ; -- état Btrfs, zram, mémoire et SSD NVMe ; -- configurations système explicitement autorisées ; -- un modèle Limine sans la section d’entrées de snapshots générées. +`--dry-run` affiche les sources, les catégories obligatoire/facultatif, le +parent et les transformations ; il ne crée rien et ne lance ni sudo, ni +collecteur, ni demande de mot de passe. Il ne prouve pas la réussite des +lectures futures. La collecte refuse une invocation en root ; les fichiers de +sortie sont écrits par l’utilisateur sous `umask 077`. Les lectures privilégiées +utilisent `sudo -n`, un contrôle d’état strict et seulement les sources +explicitement autorisées. Aucun résultat n’est installé avec sudo. -`backup.sh` ne sauvegarde ni les scripts et unités utilisateur de `~/.dotfiles`, -ni les données, connexions réseau, secrets, clés Secure Boot ou sauvegardes -hors disque. Les inventaires qu’il produit sont des observations datées ; les -manifests demeurent l’intention de services. Une copie du dépôt sur un autre -support, accessible et récupérable, doit être vérifiée séparément. +## Collecteurs et états -## Contrôle avant commit +| Classe | Collecteurs | Raison | +|---|---|---| +| Obligatoire | `pacman -Qqen`, `-Qqem` ; unités système activées, en cours et timers ; `lsblk`, `findmnt`, `/etc/fstab`, Btrfs subvolumes/usage, mémoire et swap ; `pacman.conf`, `makepkg.conf`, `mkinitcpio.conf` | Sans ces éléments, le candidat annoncé manquerait son noyau d’inventaire et de configuration. | +| Facultatif | `pacman -Qdtq` ; unités en échec et unités utilisateur ; scrub Btrfs ; SMART de `/dev/nvme0` ; `nftables.conf`, limine-snapper-sync, TLP, zram-generator, Snapper, deux helpers root ; modèle Limine | Leur absence ou indisponibilité peut être légitime selon la machine, l’utilisateur ou le matériel. Elle reste visible dans le manifeste. | -```bash -git diff --check -git diff --stat -git status --short -``` +`SUCCESS` indique un fichier collecté non vide ; `EMPTY`, une commande +réussie avec résultat vide permis ; `ABSENT`, une absence effectivement +établie pour une source facultative ; `UNAVAILABLE`, un outil ou `nvme0` +indisponible ; `CHECK_ERROR`, un contrôle impossible ou une réponse +invalide ; `COLLECT_ERROR`, une commande ou lecture échouée. +`PORTABILITY_REVIEW` marque le modèle Limine à relire, même après une +transformation reconnue. Une erreur obligatoire interdit la finalisation ; +une erreur facultative produit `COMPLETE_WITH_WARNINGS` si le noyau +obligatoire a réussi. `COMPLETE` est réservé à un ensemble sans avertissement. +En pratique, un modèle Limine présent entraîne une alerte de portabilité. -Inspecter particulièrement : +Les listes sont des **observations** : « natif » ne prouve pas dans tous les +cas « dépôt officiel », et « étranger » ne prouve pas « AUR ». +`pacman -Qdtq` désigne les dépendances orphelines selon +[pacman(8)](https://man.archlinux.org/man/pacman.8.en). La documentation +primaire ne justifie pas une règle générale « code 1 = aucun orphelin » : +une sortie vide avec code non nul reste `COLLECT_ERROR`, avec une cause à +examiner. Les résultats vides de commandes revenues avec succès sont +distingués. Le relevé SMART éventuel concerne **seulement** `/dev/nvme0`, +pas tous les disques. -- `pkglist/orphans.txt` ; -- les services qui apparaissent nouvellement activés ; -- les modifications de `nftables.conf`, `mkinitcpio.conf` et `pacman.conf` ; -- l’absence de secrets. +## Finalisation, manifeste et limites -Relire également le modèle Limine généré : le script retire la section -automatique et remplace `root=UUID=…`, mais ne démontre pas que tout autre champ -propre à la machine est devenu portable. Ne pas publier un export sans revue -de ses valeurs et de ses fichiers ajoutés. +La collecte commence dans un répertoire `.incomplete.*` privé, doté d’un +`manifest.tsv` au statut `INCOMPLETE`. Les sorties sont d’abord reçues dans +une zone de travail privée ; une commande qui écrit partiellement puis échoue +ne publie pas ce fichier comme relevé valide. Après les éléments obligatoires, +le manifeste est terminé et le répertoire est renommé localement en +`export-*` sans remplacer un nom existant. Chaque invocation a son espace ; +avant un renommage réussi, une erreur ou un `INT`/`TERM` conserve son reliquat +marqué `INCOMPLETE` et annoncé. Après le renommage réussi, un `INT`/`TERM` +conserve le candidat complet et annonce son chemin avec le diagnostic +« finalisation acquise, terminaison interrompue ». L'exporteur reconnaît ce +renommage par la disparition du staging et l'identité de l'objet au chemin +final ; la simple présence d'une destination préexistante ne suffit pas. +Une collision conserve le staging incomplet sans revendiquer cet autre export. +Les exports antérieurs ne sont ni supprimés ni remplacés. Les codes sont `0` pour +simulation ou candidat finalisé, `1` pour erreur, `2` pour usage invalide, +`130`/`143` pour `INT`/`TERM`. `SIGKILL` et une coupure d’alimentation +ne sont pas interceptés ; aucune garantie de durabilité après coupure n’est +revendiquée. + +Le manifeste contient version, début/fin UTC, révision source, statut global, +état de chaque collecteur et SHA-256 des **fichiers de sortie finalisés**. +Chaque empreinte est calculée via l'entrée standard, après vérification de +l'ouverture du fichier et du succès de la commande ; seule une réponse de +64 chiffres hexadécimaux est acceptée. Un échec ou une réponse malformée +interdit la finalisation. Le manifeste ne contient pas son propre hash. +Vérifier les empreintes et lire le contenu +avant toute utilisation. Les lectures ne forment pas un instantané atomique : +services et fichiers peuvent changer pendant la collecte. Les vérifications +de chemins réduisent les risques d’alias et de liens ; elles ne remplacent pas +une exclusion des modifications concurrentes de la machine. + +Le modèle Limine candidat est produit **uniquement dans l’export**. La section +commençant par `### Auto-generated by limine-snapper-sync` est retirée. +Les formes `root=UUID=` et +`root=PARTUUID=` deviennent des placeholders distincts. +Une forme `root=` non reconnue est signalée ; tous les autres champs +demeurent soumis à revue. Ni `ESP_PATH`, ni signatures, ni snapshots ne sont +changés. Le modèle n’est pas déclaré universellement portable. + +## Revue avant toute promotion manuelle + +Examiner les fichiers et le manifeste hors du dépôt, particulièrement +identifiants de stockage, noms de services, `nftables.conf`, `fstab` et +Limine. Aucun filtrage simple ne certifie l’absence de secrets ; **ne pas +publier automatiquement**. Les listes de déploiement et la politique réseau +restent des décisions de reconstruction distinctes de l’état observé. +Une éventuelle reprise manuelle dans Git demande une revue spécifique des +contenus et de leur portée. L’existence du candidat n’apporte ni copie +externe, ni restauration testée. diff --git a/scripts/backup.sh b/scripts/backup.sh index 1c33bf7..4f01d7d 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,234 +1,416 @@ #!/usr/bin/env bash - set -Eeuo pipefail -IFS=$'\n\t' +umask 077 -readonly REPO_DIR="$( - cd "$(dirname "${BASH_SOURCE[0]}")/.." - pwd -)" +readonly REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +# Contrôle privilégié fixe : sudo et find doivent réussir avant qu'un état +# soit utilisable. La cible est une donnée positionnelle, jamais du code. +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="$(/usr/bin/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 +' -log() { - printf '[*] %s\n' "$*" +DRY_RUN=0 EXPLICIT_PARENT='' PARENT='' STAGE='' FINAL='' REVISION='' +STAGE_ID='' FINAL_ACQUIRED=0 FINAL_ANNOUNCED=0 +STARTED='' CURRENT='' WARNINGS=0 +declare -a RESULTS=() HASHES=() + +usage() { + cat <<'HELP' +Usage : backup.sh [--output-dir REPERTOIRE_PARENT] + backup.sh --dry-run [--output-dir REPERTOIRE_PARENT] + backup.sh --help +Export système privé pour revue ; ni sauvegarde des données personnelles, +ni actualisation de la référence Git. --output-dir désigne un parent : chaque +collecte crée un sous-répertoire distinct. Codes : 0 = candidat finalisé ou +simulation ; 1 = contrôle/collecte/finalisation échoué ; 2 = usage invalide ; +130/143 = interruption INT/TERM. +HELP } - -warn() { - printf '[!] %s\n' "$*" >&2 +fail() { printf '[!] Échec : %s\n' "$1" >&2; exit 1; } +interrupt() { + local signal="$1" code="$2" + trap - INT TERM + printf '[!] Interruption %s (%s) ; collecteur : %s\n' "$signal" "$code" "${CURRENT:-aucun}" >&2 + reconcile_finalization + if (( FINAL_ACQUIRED )); then + printf '[!] Finalisation acquise, terminaison interrompue ; candidat conservé : %s\n' "$FINAL" >&2 + FINAL_ANNOUNCED=1 + fi + exit "$code" } +# Le nom final seul ne suffit pas : une collision peut déjà l'occuper. +# Le couple périphérique/inode identifie le répertoire privé créé par mktemp. +reconcile_finalization() { + local current_id + (( FINAL_ACQUIRED == 0 )) || return 0 + [[ -n "$FINAL" && -n "$STAGE_ID" && ! -e "$STAGE" && ! -L "$FINAL" && -d "$FINAL" ]] || return 0 + current_id="$(stat -c '%d:%i' -- "$FINAL")" || return 0 + [[ "$current_id" == "$STAGE_ID" ]] && FINAL_ACQUIRED=1 + return 0 +} +write_manifest() { + local status="$1" ended + [[ -n "$STAGE" && -d "$STAGE" ]] || return 0 + ended="$(date -u +%Y-%m-%dT%H:%M:%SZ)" || return 1 + { + printf 'format\t1\nstarted_utc\t%s\nended_utc\t%s\nsource_revision\t%s\nglobal_status\t%s\n' \ + "$STARTED" "$ended" "$REVISION" "$status" + printf 'collector\tclass\tstatus\tdetail\n' + if (( ${#RESULTS[@]} )); then printf '%s\n' "${RESULTS[@]}"; fi + printf 'file\tsha256\n' + if (( ${#HASHES[@]} )); then printf '%s\n' "${HASHES[@]}"; fi + } > "$STAGE/.manifest.tmp" || return 1 + mv -f -- "$STAGE/.manifest.tmp" "$STAGE/manifest.tsv" +} +on_exit() { + local code="$1" + trap - EXIT + reconcile_finalization + if (( FINAL_ACQUIRED )); then + if (( ! FINAL_ANNOUNCED )); then + printf '[!] Candidat de cette invocation déjà finalisé et conservé : %s\n' "$FINAL" >&2 + fi + elif [[ -n "$STAGE" && -d "$STAGE" ]]; then + write_manifest INCOMPLETE || printf '[!] Manifeste incomplet non actualisé.\n' >&2 + printf '[!] Export incomplet conservé : %s ; dernier collecteur : %s\n' \ + "$STAGE" "${CURRENT:-aucun}" >&2 + elif [[ -n "$FINAL" ]]; then + printf '[!] État de finalisation indéterminé ; inspecter : %s et %s\n' "$STAGE" "$FINAL" >&2 + fi + exit "$code" +} +trap 'on_exit $?' EXIT +trap 'interrupt INT 130' INT +trap 'interrupt TERM 143' TERM -copy_root_file() { - local source="$1" - local destination="$2" - local mode="${3:-0644}" +while (( $# )); do + case "$1" in + --help|-h) usage; exit 0 ;; + --dry-run) DRY_RUN=1 ;; + --output-dir) + (( $# >= 2 )) || { usage >&2; exit 2; } + [[ -z "$EXPLICIT_PARENT" && -n "$2" && "$2" != --* ]] || { usage >&2; exit 2; } + EXPLICIT_PARENT="$2"; shift ;; + *) printf '[!] Option inconnue : %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac + shift +done - if sudo test -f "$source"; then - sudo install \ - -D \ - -o "$(id -u)" \ - -g "$(id -g)" \ - -m "$mode" \ - "$source" \ - "$destination" +# Deux normalisations protègent les alias du dépôt ; chaque composant lexical +# existant doit aussi être un vrai répertoire, sans traversée de lien. +choose_parent() { + local raw base component rest cursor=/ physical + if [[ -n "$EXPLICIT_PARENT" ]]; then raw="$EXPLICIT_PARENT" else - warn "Fichier absent, ignoré : $source" + base="${XDG_STATE_HOME:-}" + if [[ -n "$base" && "$base" != /* ]]; then + printf '[!] XDG_STATE_HOME relatif ignoré ; défaut sous HOME utilisé.\n' >&2 + base='' + fi + if [[ -z "$base" ]]; then + [[ "${HOME:-}" == /* ]] || fail 'HOME absolu requis pour la destination par défaut' + base="$HOME/.local/state" + fi + raw="$base/arch-system/exports" + fi + [[ "$raw" == /* ]] || raw="$PWD/$raw" + PARENT="$(realpath -m -s -- "$raw")" || fail 'normalisation du parent impossible' + physical="$(realpath -m -- "$raw")" || fail 'résolution du parent impossible' + [[ "$PARENT" != / ]] || fail 'racine interdite comme parent' + [[ "$physical" != "$REPO_DIR" && "$physical" != "$REPO_DIR/"* ]] || + fail 'destination dans le dépôt interdite' + rest="${PARENT#/}" + while [[ -n "$rest" ]]; do + component="${rest%%/*}" + if [[ "$cursor" == / ]]; then cursor="/$component"; else cursor="$cursor/$component"; fi + [[ ! -L "$cursor" ]] || fail "lien dans le parent refusé : $cursor" + [[ ! -e "$cursor" || -d "$cursor" ]] || fail "parent non répertoire : $cursor" + if [[ "$rest" == */* ]]; then rest="${rest#*/}"; else rest=''; fi + done +} +make_parent() { + local rest="${PARENT#/}" component cursor=/ uid mode physical + while [[ -n "$rest" ]]; do + component="${rest%%/*}" + if [[ "$cursor" == / ]]; then cursor="/$component"; else cursor="$cursor/$component"; fi + [[ ! -L "$cursor" ]] || fail "lien dans le parent refusé : $cursor" + if [[ ! -e "$cursor" ]]; then + mkdir -m 0700 -- "$cursor" 2>/dev/null || + [[ -d "$cursor" && ! -L "$cursor" ]] || fail "création du parent impossible : $cursor" + fi + [[ -d "$cursor" && ! -L "$cursor" ]] || fail "parent invalide : $cursor" + if [[ "$rest" == */* ]]; then rest="${rest#*/}"; else rest=''; fi + done + uid="$(stat -c %u -- "$PARENT")" || fail 'propriétaire du parent indisponible' + mode="$(stat -c %a -- "$PARENT")" || fail 'permissions du parent indisponibles' + [[ "$uid" == "$EUID" ]] || fail 'parent final non détenu par le compte courant' + (( (8#$mode & 077) == 0 )) || fail 'parent final non privé' + physical="$(realpath -m -- "$PARENT")" || fail 'résolution du parent impossible' + [[ "$physical" != "$REPO_DIR" && "$physical" != "$REPO_DIR/"* ]] || + fail 'destination dans le dépôt interdite' +} +record() { + RESULTS+=("$(printf '%s\t%s\t%s\t%s' "$1" "$2" "$3" "$4")") + case "$3" in SUCCESS|EMPTY) ;; *) (( WARNINGS += 1 )) ;; esac +} +hash_output() { + local relative="$1" digest + # L'entrée standard force le nom « - » dans la sortie de sha256sum : un + # antislash dans le parent ne peut plus préfixer le champ de somme. + if digest="$(sha256sum < "$STAGE/$relative")"; then : + else fail "empreinte impossible : $relative"; fi + [[ "$digest" =~ ^([0-9a-f]{64})[[:space:]][[:space:]]-$ ]] || + fail "réponse d'empreinte invalide : $relative" + HASHES+=("$(printf '%s\t%s' "$relative" "${BASH_REMATCH[1]}")") +} +collect_command() { + local key="$1" class="$2" relative="$3" empty_ok="$4"; shift 4 + local tmp="$STAGE/.work/$key" state=SUCCESS + CURRENT="$key" + if ! command -v "$1" >/dev/null 2>&1; then + record "$key" "$class" UNAVAILABLE 'commande_absente' + [[ "$class" == optional ]] || fail "collecteur obligatoire indisponible : $key" + return 0 + fi + if "$@" > "$tmp"; then + # Les anciennes listes étaient triées ; garder cet ordre déterministe + # dans le candidat sans écrire directement dans la référence Git. + case "$key" in + pacman_*|system_enabled|system_running|system_timers|user_enabled|user_running|user_timers) + LC_ALL=C sort -u -- "$tmp" > "$tmp.sorted" || fail "tri impossible : $key" + mv -- "$tmp.sorted" "$tmp" || fail "tri impossible : $key" ;; + esac + if [[ ! -s "$tmp" ]]; then + if [[ "$empty_ok" == yes ]]; then state=EMPTY + else + rm -f -- "$tmp" + record "$key" "$class" COLLECT_ERROR 'sortie_vide_inattendue' + [[ "$class" == optional ]] || fail "sortie obligatoire vide : $key" + return 0 + fi + fi + mkdir -p -- "$STAGE/${relative%/*}" || fail "répertoire de sortie impossible : $key" + mv -- "$tmp" "$STAGE/$relative" || fail "écriture de sortie impossible : $key" + hash_output "$relative" + record "$key" "$class" "$state" "$relative" + else + rm -f -- "$tmp" + record "$key" "$class" COLLECT_ERROR 'commande_en_echec_ou_selection_vide_indeterminee' + [[ "$class" == optional ]] || fail "collecte obligatoire échouée : $key" fi } +copy_source() { + local key="$1" class="$2" source="$3" relative="$4" state tmp + CURRENT="$key" + if state="$(sudo -n /usr/bin/bash -c "$INSPECT_CODE" -- "$source")"; then : + else + record "$key" "$class" CHECK_ERROR 'inspection_privilegiee_en_echec' + [[ "$class" == optional ]] || fail "contrôle obligatoire impossible : $key" + return 0 + fi + case "$state" in + EXISTING) ;; + ABSENT) + record "$key" "$class" ABSENT 'absence_etablie' + [[ "$class" == optional ]] || fail "source obligatoire absente : $key" + return 0 ;; + *) + record "$key" "$class" CHECK_ERROR "etat_invalide_${state:-vide}" + [[ "$class" == optional ]] || fail "réponse de contrôle invalide : $key" + return 0 ;; + esac + tmp="$STAGE/.work/$key" + if sudo -n /usr/bin/cat -- "$source" > "$tmp"; then + mkdir -p -- "$STAGE/${relative%/*}" || fail "répertoire de sortie impossible : $key" + mv -- "$tmp" "$STAGE/$relative" || fail "écriture de sortie impossible : $key" + hash_output "$relative" + if [[ -s "$STAGE/$relative" ]]; then record "$key" "$class" SUCCESS "$relative" + else record "$key" "$class" EMPTY "$relative"; fi + else + rm -f -- "$tmp" + record "$key" "$class" COLLECT_ERROR 'lecture_privilegiee_en_echec' + [[ "$class" == optional ]] || fail "lecture obligatoire échouée : $key" + fi +} +make_limine_model() { + local raw="$STAGE/.work/limine_raw" model="$STAGE/.work/limine_model" state token tokens unsupported=0 grep_code + CURRENT=limine_model + if state="$(sudo -n /usr/bin/bash -c "$INSPECT_CODE" -- /boot/limine.conf)"; then : + else record limine_model optional CHECK_ERROR 'inspection_privilegiee_en_echec'; return 0; fi + case "$state" in + ABSENT) record limine_model optional ABSENT 'absence_etablie'; return 0 ;; + EXISTING) ;; + *) record limine_model optional CHECK_ERROR "etat_invalide_${state:-vide}"; return 0 ;; + esac + if ! sudo -n /usr/bin/cat -- /boot/limine.conf > "$raw"; then + rm -f -- "$raw" + record limine_model optional COLLECT_ERROR 'lecture_privilegiee_en_echec'; return 0 + fi + if ! awk '/^[[:space:]]*### Auto-generated by limine-snapper-sync/ {exit} {print}' "$raw" | + sed -E -e 's/(^|[[:space:]])root=UUID=[[:xdigit:]-]+/\1root=UUID=@ROOT_UUID@/g' \ + -e 's/(^|[[:space:]])root=PARTUUID=[[:xdigit:]-]+/\1root=PARTUUID=@ROOT_PARTUUID@/g' > "$model"; then + rm -f -- "$raw" "$model" + record limine_model optional COLLECT_ERROR 'transformation_en_echec'; return 0 + fi + rm -f -- "$raw" + if [[ ! -s "$model" ]]; then + rm -f -- "$model"; record limine_model optional COLLECT_ERROR 'modele_vide'; return 0 + fi + if tokens="$(grep -oE '(^|[[:space:]])root=[^[:space:]]+' "$model")"; then + while IFS= read -r token; do + token="${token#"${token%%[![:space:]]*}"}" + case "$token" in + 'root=UUID=@ROOT_UUID@'|'root=PARTUUID=@ROOT_PARTUUID@') ;; + *) unsupported=1 ;; + esac + done <<< "$tokens" + else + grep_code=$? + if (( grep_code != 1 )); then + rm -f -- "$model" + record limine_model optional COLLECT_ERROR 'analyse_root_en_echec'; return 0 + fi + unsupported=1 + fi + mkdir -p -- "$STAGE/templates" || fail 'répertoire du modèle impossible' + mv -- "$model" "$STAGE/templates/limine.conf" || fail 'écriture du modèle impossible' + hash_output templates/limine.conf + if (( unsupported )); then + record limine_model optional PORTABILITY_REVIEW 'root_non_pris_en_charge_et_autres_champs_a_relire' + else + record limine_model optional PORTABILITY_REVIEW 'autres_champs_a_relire' + fi +} +show_plan() { + printf '[PLAN] Parent : %s ; chaque collecte aurait un sous-répertoire unique.\n' "$PARENT" + cat <<'PLAN' +[PLAN] Classe | source ou commande -> destination relative au candidat +[PLAN] obligatoire | pacman -Qqen -> pkglist/pacman.txt +[PLAN] obligatoire | pacman -Qqem -> pkglist/foreign.txt +[PLAN] facultatif | pacman -Qdtq -> pkglist/orphans.txt +[PLAN] obligatoire | systemctl système enabled -> inventory/systemd/system-enabled.txt +[PLAN] obligatoire | systemctl système running -> inventory/systemd/system-running.txt +[PLAN] obligatoire | systemctl système timers -> inventory/systemd/system-timers.txt +[PLAN] facultatif | systemctl système failed -> inventory/systemd/system-failed.txt +[PLAN] facultatif | systemctl utilisateur enabled -> inventory/systemd/user-enabled.txt +[PLAN] facultatif | systemctl utilisateur running -> inventory/systemd/user-running.txt +[PLAN] facultatif | systemctl utilisateur timers -> inventory/systemd/user-timers.txt +[PLAN] facultatif | systemctl utilisateur failed -> inventory/systemd/user-failed.txt +[PLAN] obligatoire | lsblk -> inventory/storage/lsblk.txt +[PLAN] obligatoire | findmnt -> inventory/storage/findmnt.txt +[PLAN] obligatoire | /etc/fstab -> inventory/storage/fstab.current +[PLAN] obligatoire | btrfs subvolume list / -> inventory/storage/btrfs-subvolumes.txt +[PLAN] obligatoire | btrfs filesystem usage / -> inventory/storage/btrfs-usage.txt +[PLAN] facultatif | btrfs scrub status / -> inventory/storage/btrfs-scrub.txt +[PLAN] obligatoire | free -h -> inventory/storage/memory.txt +[PLAN] obligatoire | swapon --show -> inventory/storage/swap.txt +[PLAN] facultatif | nvme smart-log /dev/nvme0 -> inventory/storage/nvme-health.txt +[PLAN] obligatoire | /etc/pacman.conf -> rootfs/etc/pacman.conf +[PLAN] obligatoire | /etc/makepkg.conf -> rootfs/etc/makepkg.conf +[PLAN] obligatoire | /etc/mkinitcpio.conf -> rootfs/etc/mkinitcpio.conf +[PLAN] facultatif | /etc/nftables.conf -> rootfs/etc/nftables.conf +[PLAN] facultatif | /etc/limine-snapper-sync.conf -> rootfs/etc/limine-snapper-sync.conf +[PLAN] facultatif | /etc/tlp.d/10-archasp.conf -> rootfs/etc/tlp.d/10-archasp.conf +[PLAN] facultatif | /etc/systemd/zram-generator.conf -> rootfs/etc/systemd/zram-generator.conf +[PLAN] facultatif | /etc/snapper/configs/root -> rootfs/etc/snapper/configs/root +[PLAN] facultatif | /usr/local/sbin/batlimit-set -> rootfs/usr/local/sbin/batlimit-set +[PLAN] facultatif | /usr/local/sbin/vmware-stack -> rootfs/usr/local/sbin/vmware-stack +[PLAN] facultatif | /boot/limine.conf -> templates/limine.conf +[PLAN] Transformations : tri des listes, retrait de la section Limine générée, +[PLAN] remplacement des identifiants root UUID/PARTUUID pris en charge ; +[PLAN] modèle restant à relire pour sa portabilité. +PLAN + printf '[PLAN] Aucun fichier du dépôt ou export existant ne sera modifié.\n' +} -mkdir -p \ - "$REPO_DIR/pkglist" \ - "$REPO_DIR/inventory/systemd" \ - "$REPO_DIR/inventory/storage" \ - "$REPO_DIR/inventory/boot" \ - "$REPO_DIR/rootfs/etc/snapper/configs" \ - "$REPO_DIR/rootfs/etc/systemd" \ - "$REPO_DIR/rootfs/usr/local/sbin" - -log "Listes des paquets explicitement installés" - -pacman -Qqen | - LC_ALL=C sort -u \ - > "$REPO_DIR/pkglist/pacman.txt" - -pacman -Qqem | - LC_ALL=C sort -u \ - > "$REPO_DIR/pkglist/aur.txt" - -pacman -Qdtq 2>/dev/null | - LC_ALL=C sort -u \ - > "$REPO_DIR/pkglist/orphans.txt" || - true - -log "Inventaire systemd système" - -systemctl list-unit-files \ - --type=service \ - --state=enabled \ - --no-legend \ - --no-pager | - LC_ALL=C sort \ - > "$REPO_DIR/inventory/systemd/system-enabled.txt" - -systemctl --type=service \ - --state=running \ - --no-legend \ - --no-pager | - LC_ALL=C sort \ - > "$REPO_DIR/inventory/systemd/system-running.txt" - -systemctl list-timers \ - --all \ - --no-legend \ - --no-pager | - LC_ALL=C sort \ - > "$REPO_DIR/inventory/systemd/system-timers.txt" - -systemctl --failed \ - --no-legend \ - --no-pager \ - > "$REPO_DIR/inventory/systemd/system-failed.txt" || - true - -log "Inventaire systemd utilisateur" - -systemctl --user list-unit-files \ - --type=service \ - --state=enabled \ - --no-legend \ - --no-pager | - LC_ALL=C sort \ - > "$REPO_DIR/inventory/systemd/user-enabled.txt" - -systemctl --user --type=service \ - --state=running \ - --no-legend \ - --no-pager | - LC_ALL=C sort \ - > "$REPO_DIR/inventory/systemd/user-running.txt" - -systemctl --user list-timers \ - --all \ - --no-legend \ - --no-pager | - LC_ALL=C sort \ - > "$REPO_DIR/inventory/systemd/user-timers.txt" - -systemctl --user --failed \ - --no-legend \ - --no-pager \ - > "$REPO_DIR/inventory/systemd/user-failed.txt" || - true - -log "Inventaire du stockage" - -lsblk -e7 \ - -o NAME,MODEL,SIZE,ROTA,TRAN,FSTYPE,FSVER,LABEL,UUID,PARTUUID,MOUNTPOINTS \ - > "$REPO_DIR/inventory/storage/lsblk.txt" - -findmnt \ - > "$REPO_DIR/inventory/storage/findmnt.txt" - -cp /etc/fstab \ - "$REPO_DIR/inventory/storage/fstab.current" - -sudo btrfs subvolume list / \ - > "$REPO_DIR/inventory/storage/btrfs-subvolumes.txt" - -sudo btrfs filesystem usage / \ - > "$REPO_DIR/inventory/storage/btrfs-usage.txt" - -sudo btrfs scrub status / \ - > "$REPO_DIR/inventory/storage/btrfs-scrub.txt" || - true - -free -h \ - > "$REPO_DIR/inventory/storage/memory.txt" - -swapon --show \ - > "$REPO_DIR/inventory/storage/swap.txt" +(( EUID != 0 )) || fail 'exécution root refusée' +command -v realpath >/dev/null 2>&1 || fail 'commande requise absente : realpath' +choose_parent +if (( DRY_RUN )); then show_plan; exit 0; fi +for tool in stat mkdir mktemp mv date sha256sum git sudo sort; do + command -v "$tool" >/dev/null 2>&1 || fail "commande requise absente : $tool" +done +REVISION="$(git -C "$REPO_DIR" rev-parse HEAD)" || fail 'révision source indisponible' +[[ "$REVISION" =~ ^[[:xdigit:]]{40}$ ]] || fail 'révision source invalide' +make_parent +STAGE="$(mktemp -d "$PARENT/.incomplete.XXXXXXXX")" || fail 'création de la zone privée impossible' +STARTED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" || fail 'horodatage indisponible' +mkdir -m 0700 -- "$STAGE/.work" || fail 'zone temporaire impossible' +write_manifest INCOMPLETE || fail 'manifeste initial impossible' +printf '[*] Export incomplet privé : %s\n' "$STAGE" +collect_command pacman_native required pkglist/pacman.txt yes pacman -Qqen +collect_command pacman_foreign required pkglist/foreign.txt yes pacman -Qqem +collect_command pacman_orphans optional pkglist/orphans.txt yes pacman -Qdtq +collect_command system_enabled required inventory/systemd/system-enabled.txt yes systemctl list-unit-files --type=service --state=enabled --no-legend --no-pager +collect_command system_running required inventory/systemd/system-running.txt yes systemctl --type=service --state=running --no-legend --no-pager +collect_command system_timers required inventory/systemd/system-timers.txt yes systemctl list-timers --all --no-legend --no-pager +collect_command system_failed optional inventory/systemd/system-failed.txt yes systemctl --failed --no-legend --no-pager +collect_command user_enabled optional inventory/systemd/user-enabled.txt yes systemctl --user list-unit-files --type=service --state=enabled --no-legend --no-pager +collect_command user_running optional inventory/systemd/user-running.txt yes systemctl --user --type=service --state=running --no-legend --no-pager +collect_command user_timers optional inventory/systemd/user-timers.txt yes systemctl --user list-timers --all --no-legend --no-pager +collect_command user_failed optional inventory/systemd/user-failed.txt yes systemctl --user --failed --no-legend --no-pager +collect_command lsblk required inventory/storage/lsblk.txt no lsblk -e7 -o NAME,MODEL,SIZE,ROTA,TRAN,FSTYPE,FSVER,LABEL,UUID,PARTUUID,MOUNTPOINTS +collect_command findmnt required inventory/storage/findmnt.txt no findmnt +copy_source fstab required /etc/fstab inventory/storage/fstab.current +collect_command btrfs_subvolumes required inventory/storage/btrfs-subvolumes.txt no sudo -n /usr/bin/btrfs subvolume list / +collect_command btrfs_usage required inventory/storage/btrfs-usage.txt no sudo -n /usr/bin/btrfs filesystem usage / +collect_command btrfs_scrub optional inventory/storage/btrfs-scrub.txt yes sudo -n /usr/bin/btrfs scrub status / +collect_command memory required inventory/storage/memory.txt no free -h +collect_command swap required inventory/storage/swap.txt yes swapon --show if command -v nvme >/dev/null 2>&1 && [[ -e /dev/nvme0 ]]; then - sudo nvme smart-log /dev/nvme0 \ - > "$REPO_DIR/inventory/storage/nvme-health.txt" + collect_command nvme_health optional inventory/storage/nvme-health.txt no sudo -n /usr/bin/nvme smart-log /dev/nvme0 +else record nvme_health optional UNAVAILABLE 'nvme0_ou_outil_absent'; fi + +copy_source pacman_conf required /etc/pacman.conf rootfs/etc/pacman.conf +copy_source makepkg_conf required /etc/makepkg.conf rootfs/etc/makepkg.conf +copy_source mkinitcpio_conf required /etc/mkinitcpio.conf rootfs/etc/mkinitcpio.conf +copy_source nftables_conf optional /etc/nftables.conf rootfs/etc/nftables.conf +copy_source limine_sync_conf optional /etc/limine-snapper-sync.conf rootfs/etc/limine-snapper-sync.conf +copy_source tlp_conf optional /etc/tlp.d/10-archasp.conf rootfs/etc/tlp.d/10-archasp.conf +copy_source zram_conf optional /etc/systemd/zram-generator.conf rootfs/etc/systemd/zram-generator.conf +copy_source snapper_conf optional /etc/snapper/configs/root rootfs/etc/snapper/configs/root +copy_source batlimit_set optional /usr/local/sbin/batlimit-set rootfs/usr/local/sbin/batlimit-set +copy_source vmware_stack optional /usr/local/sbin/vmware-stack rootfs/usr/local/sbin/vmware-stack +make_limine_model + +CURRENT=finalisation +rmdir -- "$STAGE/.work" || fail 'zone temporaire non vide' +if (( WARNINGS )); then final_status=COMPLETE_WITH_WARNINGS; else final_status=COMPLETE; fi +write_manifest "$final_status" || fail 'manifeste final impossible' +FINAL="$PARENT/export-${STAGE##*.incomplete.}" +[[ ! -e "$FINAL" && ! -L "$FINAL" ]] || fail "destination finale déjà existante : $FINAL" +# Une réponse de mv peut arriver après son effet, voire être suivie d'un signal +# avant l'instruction suivante. Mémoriser l'identité avant cette fenêtre. +STAGE_ID="$(stat -c '%d:%i' -- "$STAGE")" || fail 'identité de staging indisponible' +# mv -n interdit le remplacement ; sa sortie 0 peut aussi signifier non déplacé. +if mv -T -n -- "$STAGE" "$FINAL"; then : else - : > "$REPO_DIR/inventory/storage/nvme-health.txt" + reconcile_finalization + if (( FINAL_ACQUIRED )); then fail 'renommage accompli mais commande terminée en erreur' + else fail 'renommage final impossible'; fi fi - -log "Sauvegarde des configurations système" - -copy_root_file \ - /etc/pacman.conf \ - "$REPO_DIR/rootfs/etc/pacman.conf" - -copy_root_file \ - /etc/makepkg.conf \ - "$REPO_DIR/rootfs/etc/makepkg.conf" - -copy_root_file \ - /etc/mkinitcpio.conf \ - "$REPO_DIR/rootfs/etc/mkinitcpio.conf" - -copy_root_file \ - /etc/nftables.conf \ - "$REPO_DIR/rootfs/etc/nftables.conf" - -copy_root_file \ - /etc/limine-snapper-sync.conf \ - "$REPO_DIR/rootfs/etc/limine-snapper-sync.conf" - -copy_root_file \ - /etc/tlp.d/10-archasp.conf \ - "$REPO_DIR/rootfs/etc/tlp.d/10-archasp.conf" - -copy_root_file \ - /etc/systemd/zram-generator.conf \ - "$REPO_DIR/rootfs/etc/systemd/zram-generator.conf" - -copy_root_file \ - /etc/snapper/configs/root \ - "$REPO_DIR/rootfs/etc/snapper/configs/root" - -copy_root_file \ - /usr/local/sbin/batlimit-set \ - "$REPO_DIR/rootfs/usr/local/sbin/batlimit-set" \ - 0755 - -copy_root_file \ - /usr/local/sbin/vmware-stack \ - "$REPO_DIR/rootfs/usr/local/sbin/vmware-stack" \ - 0755 - -log "Génération du template Limine" - -if sudo test -f /boot/limine.conf; then - mkdir -p "$REPO_DIR/templates" - - sudo awk ' - /^[[:space:]]*### Auto-generated by limine-snapper-sync/ { - exit - } - - { - print - } - ' /boot/limine.conf | - sed -E \ - 's/root=UUID=[[:xdigit:]-]+/root=UUID=@ROOT_UUID@/g' \ - > "$REPO_DIR/templates/limine.conf" -else - warn "Fichier absent : /boot/limine.conf" -fi - -log "Sauvegarde terminée" - -printf '\n' -printf 'Pacman : %s\n' \ - "$(wc -l < "$REPO_DIR/pkglist/pacman.txt")" - -printf 'AUR : %s\n' \ - "$(wc -l < "$REPO_DIR/pkglist/aur.txt")" - -printf 'Orphelins : %s\n' \ - "$(wc -l < "$REPO_DIR/pkglist/orphans.txt")" - -printf '\n' -git -C "$REPO_DIR" status --short || true +reconcile_finalization +(( FINAL_ACQUIRED )) || fail 'renommage final non effectué' +STAGE='' +printf '[+] Candidat privé finalisé (%s) : %s\n' "$final_status" "$FINAL" +FINAL_ANNOUNCED=1 diff --git a/tests/backup/mock-command.sh b/tests/backup/mock-command.sh new file mode 100755 index 0000000..f84c302 --- /dev/null +++ b/tests/backup/mock-command.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Doublures strictes : aucune mutation ou lecture hors des fixtures /mnt. +set -euo pipefail +name="${0##*/}" +printf '%s' "$name" >> /mnt/calls +printf ' %q' "$@" >> /mnt/calls +printf '\n' >> /mnt/calls + +if [[ "$name" == git ]]; then + [[ "$1" == -C && "$2" == /mnt/repo && "$3" == rev-parse && "$4" == HEAD && $# == 4 ]] || + { [[ "${SIM_LEGACY:-0}" == 1 && "$1" == -C && "$2" == /mnt/repo && "$3" == status ]] || exit 90; } + [[ "$3" == status ]] || printf '074d5dd0b2a1b362c8c0cd6413ab77f868461e25\n' + exit 0 +fi +if [[ "$name" == sha256sum ]]; then + [[ $# == 0 ]] || exit 90 + case "${SIM_HASH_MODE:-}" in + fail) exit 47 ;; + malformed) printf 'malformed -\n'; exit 0 ;; + '') exec /usr/bin/sha256sum ;; + *) exit 90 ;; + esac +fi +if [[ "$name" == mv ]]; then + if [[ "${1:-}" == -T ]]; then + [[ "$2" == -n && "$3" == -- && "$4" == /mnt/*/.incomplete.* && "$5" == /mnt/*/export-* ]] || exit 90 + if [[ "${SIM_FINAL_COLLIDE:-0}" == 1 ]]; then + /usr/bin/mkdir -m 0700 -- "$5" + printf 'existing candidate\n' > "$5/keep" + fi + if [[ -n "${SIM_FINAL_SIGNAL_PHASE:-}" ]]; then + signal="${SIM_FINAL_SIGNAL:-TERM}" + [[ "$signal" == INT || "$signal" == TERM ]] || exit 90 + [[ "$SIM_FINAL_SIGNAL_PHASE" == before || "$SIM_FINAL_SIGNAL_PHASE" == after ]] || exit 90 + if [[ "$SIM_FINAL_SIGNAL_PHASE" == after ]]; then /usr/bin/mv "$@" || exit 91; fi + kill "-$signal" "$PPID" + [[ "$signal" == INT ]] && exit 130 || exit 143 + fi + [[ "${SIM_FINAL_FAIL:-0}" != 1 ]] || exit 55 + fi + exec /usr/bin/mv "$@" +fi +if [[ "$name" == sudo ]]; then + if [[ "${SIM_LEGACY:-0}" == 1 ]]; then + case "${1:-}" in + test) + [[ "$2" == -f && ( "$3" == /etc/* || "$3" == /boot/limine.conf || "$3" == /usr/local/sbin/* ) ]] || exit 90 + [[ -f "$3" ]] ; exit ;; + install) + [[ "$2" == -D && "$3" == -o && "$5" == -g && "$7" == -m ]] || exit 90 + [[ "$9" == /etc/* || "$9" == /usr/local/sbin/* ]] || exit 90 + [[ "${10}" == /mnt/repo/* ]] || exit 90 + /usr/bin/install -D -m "$8" "$9" "${10}"; exit ;; + awk) + [[ "${@: -1}" == /boot/limine.conf ]] || exit 90 + exec /usr/bin/awk "${@:2}" ;; + esac + fi + [[ "${1:-}" == -n ]] || exit 90 + shift + case "${1:-}" in + /usr/bin/bash) + [[ $# == 5 && "$2" == -c && "$4" == -- ]] || exit 90 + digest="$(printf '%s' "$3" | /usr/bin/sha256sum)" + [[ "${digest%% *}" == 9fc82ae6938b308c551562c1106372ba19194e90651a107cf819d3744ede8e4d ]] || exit 90 + target="$5" + [[ "$target" == /etc/* || "$target" == /boot/limine.conf || "$target" == /usr/local/sbin/* ]] || exit 90 + if [[ "${SIM_INSPECT_TARGET:-}" == "$target" ]]; then + case "${SIM_INSPECT_MODE:-}" in + fail) exit 1 ;; + parent_fail) exit 3 ;; + empty) exit 0 ;; + unknown) printf 'UNKNOWN\n'; exit 0 ;; + valid_fail) printf 'EXISTING\n'; exit 1 ;; + 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 + exit 0 ;; + /usr/bin/cat) + [[ $# == 3 && "$2" == -- ]] || exit 90 + path="$3" + [[ "$path" == /etc/fstab || "$path" == /etc/pacman.conf || + "$path" == /etc/makepkg.conf || "$path" == /etc/mkinitcpio.conf || + "$path" == /etc/nftables.conf || "$path" == /etc/limine-snapper-sync.conf || + "$path" == /etc/tlp.d/10-archasp.conf || + "$path" == /etc/systemd/zram-generator.conf || + "$path" == /etc/snapper/configs/root || + "$path" == /usr/local/sbin/batlimit-set || + "$path" == /usr/local/sbin/vmware-stack || + "$path" == /boot/limine.conf ]] || exit 90 + if [[ "${SIM_FAIL_KEY:-}" == "read:$path" ]]; then printf 'partial\n'; exit 47; fi + exec /usr/bin/cat -- "$path" ;; + /usr/bin/btrfs) + [[ "$2" == subvolume && "$3" == list && "$4" == / || + "$2" == filesystem && "$3" == usage && "$4" == / || + "$2" == scrub && "$3" == status && "$4" == / ]] || exit 90 + key="btrfs_${2}_${3}" + if [[ "${SIM_SIGNAL_KEY:-}" == "$key" ]]; then kill "-${SIM_SIGNAL:-TERM}" "$PPID"; exit 143; fi + if [[ "${SIM_FAIL_KEY:-}" == "$key" ]]; then printf 'partial\n'; exit 47; fi + printf 'fixture %s\n' "$key"; exit 0 ;; + /usr/bin/nvme) + [[ "$2" == smart-log && "$3" == /dev/nvme0 ]] || exit 90 + printf 'fixture nvme0\n'; exit 0 ;; + esac + exit 90 +fi + +case "$name" in + pacman) + [[ $# == 1 ]] || exit 90 + case "$1" in + -Qqen) key=pacman_native; value=alpha ;; + -Qqem) key=pacman_foreign; value=foreign ;; + -Qdtq) key=pacman_orphans; value=orphan ;; + *) exit 90 ;; + esac ;; + systemctl) + if [[ "$*" == *--user* ]]; then key=user_state + else key=system_state; fi + value='fixture.service enabled' ;; + lsblk) + [[ "$1" == -e7 && "$2" == -o ]] || exit 90 + key=lsblk; value='disk fixture' ;; + findmnt) [[ $# == 0 ]] || exit 90; key=findmnt; value='fixture mount' ;; + free) [[ "$1" == -h ]] || exit 90; key=memory; value='fixture memory' ;; + swapon) [[ "$1" == --show ]] || exit 90; key=swap; value='fixture swap' ;; + *) exit 90 ;; +esac +if [[ "$key" == pacman_orphans && "${SIM_ORPHANS_EMPTY_ERROR:-0}" == 1 ]]; then exit 1; fi +if [[ "${SIM_FAIL_KEY:-}" == "$key" ]]; then printf 'partial\n'; exit 47; fi +if [[ "${SIM_EMPTY_KEY:-}" == "$key" ]]; then exit 0; fi +if [[ "${SIM_SIGNAL_KEY:-}" == "$key" ]]; then kill "-${SIM_SIGNAL:-TERM}" "$PPID"; exit 143; fi +printf '%s\n' "$value" diff --git a/tests/backup/test.sh b/tests/backup/test.sh new file mode 100755 index 0000000..9065986 --- /dev/null +++ b/tests/backup/test.sh @@ -0,0 +1,409 @@ +#!/usr/bin/env bash +# Seules des copies dans bwrap lisent les sources synthétiques ; jamais ArchASP. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +command -v bwrap >/dev/null || { printf 'bwrap requis\n' >&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 : $(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 inattendu : $1"; } +no_repo_change() { + [[ "$(cat "$case_dir/repo/pkglist/pacman.txt")" == old-list ]] || fail 'liste du dépôt modifiée' + [[ "$(cat "$case_dir/repo/rootfs/etc/pacman.conf")" == old-config ]] || fail 'configuration du dépôt modifiée' + [[ "$(cat "$case_dir/repo/inventory/storage/lsblk.txt")" == old-inventory ]] || fail 'inventaire du dépôt modifié' +} +no_complete() { [[ -z "$(find "$case_dir/exports" -maxdepth 1 -type d -name 'export-*' -print -quit 2>/dev/null)" ]] || fail 'faux candidat complet'; } +incomplete_marked() { + local stage + stage="$(find "$case_dir/exports" -maxdepth 1 -type d -name '.incomplete.*' -print -quit)" + [[ -n "$stage" ]] || fail 'zone incomplète absente' + grep -Fq "$(printf 'global_status\tINCOMPLETE')" "$stage/manifest.tsv" || fail 'statut incomplet absent' +} +one_final() { + local n + n="$(find "$case_dir/exports" -maxdepth 1 -type d -name 'export-*' | wc -l)" + [[ "$n" == 1 ]] || fail "nombre de candidats complets : $n" + final_dir="$(find "$case_dir/exports" -maxdepth 1 -type d -name 'export-*' -print -quit)" +} +check_hashes() { + python3 - "$1" <<'PY' +from pathlib import Path +import hashlib,sys,stat +import re +p=Path(sys.argv[1]); lines=(p/'manifest.tsv').read_text().splitlines() +assert 'format\t1' in lines +assert any(x.startswith('source_revision\t074d5dd0') for x in lines) +assert any(x.startswith('global_status\tCOMPLETE') for x in lines) +assert stat.S_IMODE(p.stat().st_mode)==0o700 +assert stat.S_IMODE((p/'manifest.tsv').stat().st_mode)==0o600 +items=lines[lines.index('file\tsha256')+1:] +assert items +listed=set() +for row in items: + rel,digest=row.split('\t') + assert re.fullmatch(r'[0-9a-f]{64}',digest),rel + listed.add(rel) + f=p/rel + assert f.is_file(),rel + assert hashlib.sha256(f.read_bytes()).hexdigest()==digest,rel + assert stat.S_IMODE(f.stat().st_mode)==0o600,rel +assert 'manifest.tsv' not in [x.split('\t')[0] for x in items] +actual={str(f.relative_to(p)) for f in p.rglob('*') if f.is_file() and f.name!='manifest.tsv'} +assert listed==actual,(listed,actual) +PY +} + +new_case() { + case_dir="$(mktemp -d "$TEST_HOME/case.XXXXXXXX")" + mkdir -p "$case_dir"/{repo/scripts,repo/pkglist,repo/rootfs/etc,repo/inventory/storage,etc/tlp.d,etc/systemd,etc/snapper/configs,etc/NetworkManager/system-connections,boot,usr-local-sbin,tmp,home,bin,dev,run} + cp "$ROOT/scripts/backup.sh" "$case_dir/repo/scripts/backup.sh" + chmod 755 "$case_dir/repo/scripts/backup.sh" + printf 'old-list\n' > "$case_dir/repo/pkglist/pacman.txt" + printf 'old-config\n' > "$case_dir/repo/rootfs/etc/pacman.conf" + printf 'old-inventory\n' > "$case_dir/repo/inventory/storage/lsblk.txt" + printf 'old-findmnt\n' > "$case_dir/repo/inventory/storage/findmnt.txt" + for f in fstab pacman.conf makepkg.conf mkinitcpio.conf nftables.conf limine-snapper-sync.conf; do + printf 'fixture %s\n' "$f" > "$case_dir/etc/$f" + done + printf 'fixture tlp\n' > "$case_dir/etc/tlp.d/10-archasp.conf" + printf 'fixture zram\n' > "$case_dir/etc/systemd/zram-generator.conf" + printf 'fixture snapper\n' > "$case_dir/etc/snapper/configs/root" + printf 'fixture helper\n' > "$case_dir/usr-local-sbin/batlimit-set" + printf 'fixture helper\n' > "$case_dir/usr-local-sbin/vmware-stack" + cat > "$case_dir/boot/limine.conf" <<'LIMINE' +/Arch Zen + protocol: linux + kernel_path: boot():/vmlinuz-linux-zen + cmdline: root=UUID=1111-AAAA rw +/Arch LTS + protocol: linux + cmdline: root=PARTUUID=2222-BBBB rw +### Auto-generated by limine-snapper-sync +/Snapshot private value +LIMINE + printf 'SECRET_NEVER_EXPORT\n' > "$case_dir/etc/NetworkManager/system-connections/secret" + printf 'SECRET_NEVER_EXPORT\n' > "$case_dir/home/.env" + for safe in bash cat dirname realpath stat mkdir mktemp date awk sed grep rm rmdir sort; 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 pacman systemctl lsblk findmnt free swapon git mv sha256sum; do + ln -s /mnt/mock-command.sh "$case_dir/bin/$mocked" + done + cp "$ROOT/tests/backup/mock-command.sh" "$case_dir/mock-command.sh" + chmod 755 "$case_dir/mock-command.sh" + : > "$case_dir/calls" +} + +# La racine hôte est en lecture seule ; /etc, /boot, /usr/local/sbin, /home, +# /root, /run et /tmp sont remplacés ou masqués ; aucune connexion réseau. +sandbox() { + local uid_args=() + [[ "${SIM_AS_ROOT:-0}" != 1 ]] || uid_args=(--uid 0 --gid 0) + bwrap --unshare-user --unshare-pid --unshare-net "${uid_args[@]}" \ + --ro-bind / / --bind "$case_dir/etc" /etc --bind "$case_dir/boot" /boot \ + --bind "$case_dir/usr-local-sbin" /usr/local/sbin \ + --tmpfs /home --tmpfs /root --tmpfs /run \ + --bind "$case_dir/tmp" /tmp --bind "$case_dir" /mnt --dev /dev --proc /proc \ + --setenv PATH /mnt/bin --setenv HOME /mnt/home --setenv TMPDIR /tmp \ + --setenv XDG_STATE_HOME "${SIM_XDG:-}" \ + --setenv SIM_LEGACY "${SIM_LEGACY:-0}" \ + --setenv SIM_FAIL_KEY "${SIM_FAIL_KEY:-}" \ + --setenv SIM_EMPTY_KEY "${SIM_EMPTY_KEY:-}" \ + --setenv SIM_ORPHANS_EMPTY_ERROR "${SIM_ORPHANS_EMPTY_ERROR:-0}" \ + --setenv SIM_INSPECT_TARGET "${SIM_INSPECT_TARGET:-}" \ + --setenv SIM_INSPECT_MODE "${SIM_INSPECT_MODE:-}" \ + --setenv SIM_SIGNAL_KEY "${SIM_SIGNAL_KEY:-}" \ + --setenv SIM_SIGNAL "${SIM_SIGNAL:-TERM}" \ + --setenv SIM_FINAL_FAIL "${SIM_FINAL_FAIL:-0}" \ + --setenv SIM_FINAL_COLLIDE "${SIM_FINAL_COLLIDE:-0}" \ + --setenv SIM_FINAL_SIGNAL_PHASE "${SIM_FINAL_SIGNAL_PHASE:-}" \ + --setenv SIM_FINAL_SIGNAL "${SIM_FINAL_SIGNAL:-TERM}" \ + --setenv SIM_HASH_MODE "${SIM_HASH_MODE:-}" \ + -- /mnt/repo/scripts/backup.sh "$@" +} +run_case() { + local name="$1"; shift + set +e + sandbox "$@" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + cat "$case_dir/stdout" "$case_dir/stderr" > "$case_dir/output" + if [[ "$name" != legacy_partial && "$name" != invalid && "$name" != missing_arg && "$name" != missing_arg_option ]]; then + if (( rc == 0 )); then + if [[ "$name" == dry_relative_xdg ]]; then + [[ "$(cat "$case_dir/stderr")" == '[!] XDG_STATE_HOME relatif ignoré ; défaut sous HOME utilisé.' ]] || fail 'diagnostic XDG inattendu' + else + [[ ! -s "$case_dir/stderr" ]] || fail "stderr de succès inattendu : $(cat "$case_dir/stderr")" + fi + else + if grep -Ev '^\[!\] ' "$case_dir/stderr" > "$case_dir/unexpected-stderr"; then + fail "stderr d’erreur inattendu : $(cat "$case_dir/unexpected-stderr")" + fi + fi + fi + printf '[test] %s : rc=%s\n' "$name" "$rc" +} + +# Reproduction du script initial sur sa copie seulement. Un échec tardif +# laisse déjà une liste remplacée alors que l'ancien inventaire demeure. +new_case +git -C "$ROOT" show 074d5dd0b2a1b362c8c0cd6413ab77f868461e25:scripts/backup.sh > "$case_dir/repo/scripts/backup.sh" +SIM_LEGACY=1 SIM_FAIL_KEY=lsblk run_case legacy_partial +expect_rc 47 +[[ "$(cat "$case_dir/repo/pkglist/pacman.txt")" == alpha ]] || fail 'défaut initial non reproduit' +[[ "$(cat "$case_dir/repo/inventory/storage/lsblk.txt")" == partial ]] || fail 'sortie partielle initiale non reproduite' +[[ "$(cat "$case_dir/repo/inventory/storage/findmnt.txt")" == old-findmnt ]] || fail 'mélange ancien/nouveau non reproduit' +pass + +new_case +run_case help --help; expect_rc 0; contains 'Export système privé'; [[ ! -s "$case_dir/calls" ]] || fail 'appel en aide'; pass +run_case invalid --bad; expect_rc 2; pass +run_case missing_arg --output-dir; expect_rc 2; pass +run_case missing_arg_option --output-dir --dry-run; expect_rc 2; pass +SIM_AS_ROOT=1 run_case root_refused --output-dir /mnt/exports +expect_rc 1; contains 'exécution root refusée'; [[ ! -s "$case_dir/calls" ]] || fail 'appel en root refusé'; pass + +new_case +run_case dry_default --dry-run; expect_rc 0; contains '/mnt/home/.local/state/arch-system/exports' +[[ ! -e "$case_dir/home/.local" && ! -s "$case_dir/calls" ]] || fail 'simulation avec effet' +pass +new_case +SIM_XDG=/mnt/state run_case dry_xdg --dry-run; expect_rc 0; contains '/mnt/state/arch-system/exports'; pass +new_case +SIM_XDG=relative run_case dry_relative_xdg --dry-run; expect_rc 0 +contains 'XDG_STATE_HOME relatif ignoré'; contains '/mnt/home/.local/state/arch-system/exports'; pass +new_case +run_case dry_spaces --dry-run --output-dir '/mnt/parent with spaces' +expect_rc 0; contains '/mnt/parent with spaces'; pass +new_case +run_case dry_normalized --dry-run --output-dir /mnt/home/../exports +expect_rc 0; contains 'Parent : /mnt/exports'; pass +new_case +run_case inside_repo --dry-run --output-dir /mnt/repo/exports +expect_rc 1; contains 'destination dans le dépôt interdite'; pass +new_case +ln -s /mnt/repo "$case_dir/alias" +run_case repo_alias --dry-run --output-dir /mnt/alias/exports +expect_rc 1; contains 'destination dans le dépôt interdite'; pass +new_case +ln -s /mnt/home "$case_dir/alias" +run_case linked_parent --dry-run --output-dir /mnt/alias/exports +expect_rc 1; contains 'lien dans le parent refusé'; pass + +# Vérification séparée du fragment réel, sans privilèges dans le même bwrap. +new_case +inspect_code="$(python3 - "$ROOT/scripts/backup.sh" <<'PY' +from pathlib import Path +import sys +s=Path(sys.argv[1]).read_text() +print(s.split("readonly INSPECT_CODE='",1)[1].split("\n'",1)[0]) +PY +)" +mkdir "$case_dir/etc/target-directory" +ln -s /mnt/missing "$case_dir/etc/target-link" +ln -s /mnt/missing "$case_dir/etc/parent-link" +inspect_fragment() { + local target="$1" expected_rc="$2" expected_state="$3" actual + set +e + bwrap --unshare-user --unshare-pid --unshare-net --ro-bind / / \ + --bind "$case_dir/etc" /etc --tmpfs /home --tmpfs /root --tmpfs /run \ + --bind "$case_dir/tmp" /tmp --bind "$case_dir" /mnt --dev /dev --proc /proc \ + --setenv PATH /mnt/bin \ + -- /usr/bin/bash -c "$inspect_code" -- "$target" > "$case_dir/inspect.out" 2> "$case_dir/inspect.err" + actual=$? + set -e + [[ "$actual" == "$expected_rc" && "$(cat "$case_dir/inspect.out")" == "$expected_state" ]] || + fail "inspection réelle : $target = $actual/$(cat "$case_dir/inspect.out")" + pass +} +inspect_fragment /etc/pacman.conf 0 EXISTING +inspect_fragment /etc/missing 0 ABSENT +inspect_fragment /etc/missing/child 0 ABSENT +inspect_fragment /etc/target-directory 0 INVALID +inspect_fragment /etc/target-link 0 LINK +inspect_fragment /etc/parent-link/child 0 LINK +inspect_fragment relative 2 '' +inspect_fragment / 2 '' + +new_case +run_case nominal --output-dir /mnt/exports; expect_rc 0 +[[ ! -s "$case_dir/stderr" ]] || fail "stderr nominal : $(cat "$case_dir/stderr")" +one_final; check_hashes "$final_dir"; no_repo_change +grep -Fq 'global_status' "$final_dir/manifest.tsv" || fail 'statut global absent' +grep -Fq 'root=UUID=@ROOT_UUID@' "$final_dir/templates/limine.conf" || fail 'UUID non remplacé' +grep -Fq 'root=PARTUUID=@ROOT_PARTUUID@' "$final_dir/templates/limine.conf" || fail 'PARTUUID non remplacé' +! grep -Fq 'Auto-generated' "$final_dir/templates/limine.conf" || fail 'section générée conservée' +! grep -R -Fq 'SECRET_NEVER_EXPORT' "$final_dir" || fail 'fichier interdit collecté' +pass +run_case second_export --output-dir /mnt/exports; expect_rc 0 +[[ "$(find "$case_dir/exports" -maxdepth 1 -type d -name 'export-*' | wc -l)" == 2 ]] || fail 'collision des invocations successives' +pass +new_case +run_case actual_default; expect_rc 0 +default_final="$(find "$case_dir/home/.local/state/arch-system/exports" -maxdepth 1 -type d -name 'export-*' -print -quit)" +[[ -n "$default_final" ]] || fail 'export par défaut absent' +check_hashes "$default_final"; no_repo_change; pass +new_case +SIM_XDG=/mnt/custom-state run_case actual_xdg; expect_rc 0 +xdg_final="$(find "$case_dir/custom-state/arch-system/exports" -maxdepth 1 -type d -name 'export-*' -print -quit)" +[[ -n "$xdg_final" ]] || fail 'export XDG absent' +check_hashes "$xdg_final"; pass +new_case +run_case actual_spaces --output-dir '/mnt/parent with spaces'; expect_rc 0 +space_final="$(find "$case_dir/parent with spaces" -maxdepth 1 -type d -name 'export-*' -print -quit)" +[[ -n "$space_final" ]] || fail 'export avec espaces absent' +check_hashes "$space_final"; pass +new_case +run_case prior_for_backslash --output-dir /mnt/exports; expect_rc 0 +one_final; prior_dir="$final_dir" +run_case actual_backslash --output-dir '/mnt/parent\backslash'; expect_rc 0 +backslash_final="$(find "$case_dir/parent\backslash" -maxdepth 1 -type d -name 'export-*' -print -quit)" +[[ -n "$backslash_final" ]] || fail 'candidat avec antislash absent' +check_hashes "$backslash_final"; check_hashes "$prior_dir"; no_repo_change; pass +new_case +mkdir -m 0755 "$case_dir/exports" +run_case public_parent --output-dir /mnt/exports; expect_rc 1 +contains 'parent final non privé' +[[ -z "$(find "$case_dir/exports" -mindepth 1 -print -quit)" ]] || fail 'zone créée dans parent public' +pass + +new_case +set +e +sandbox --output-dir /mnt/exports > "$case_dir/concurrent-a.out" 2> "$case_dir/concurrent-a.err" & pid_a=$! +sandbox --output-dir /mnt/exports > "$case_dir/concurrent-b.out" 2> "$case_dir/concurrent-b.err" & pid_b=$! +wait "$pid_a"; rc_a=$? +wait "$pid_b"; rc_b=$? +set -e +[[ "$rc_a" == 0 && "$rc_b" == 0 ]] || fail 'concurrence : échec d’une invocation' +[[ "$(find "$case_dir/exports" -maxdepth 1 -type d -name 'export-*' | wc -l)" == 2 ]] || fail 'collision simultanée' +for candidate in "$case_dir"/exports/export-*; do check_hashes "$candidate"; done +no_repo_change; pass + +new_case +SIM_FAIL_KEY=pacman_native run_case early_failure --output-dir /mnt/exports +expect_rc 1; contains 'collecte obligatoire échouée : pacman_native'; no_complete; no_repo_change +incomplete_marked +! find "$case_dir/exports" -type f -name pacman.txt | grep -q . || fail 'sortie partielle conservée comme fichier' +pass +new_case +SIM_FAIL_KEY=btrfs_filesystem_usage run_case late_failure --output-dir /mnt/exports +expect_rc 1; contains 'collecte obligatoire échouée : btrfs_usage'; no_complete; no_repo_change +incomplete_marked +pass +new_case +SIM_INSPECT_TARGET=/etc/fstab SIM_INSPECT_MODE=fail run_case sudo_refusal --output-dir /mnt/exports +expect_rc 1; contains 'contrôle obligatoire impossible : fstab'; no_complete; no_repo_change; pass +new_case +SIM_INSPECT_TARGET=/etc/nftables.conf SIM_INSPECT_MODE=fail run_case optional_sudo_refusal --output-dir /mnt/exports +expect_rc 0; one_final +grep -Fq "$(printf 'nftables_conf\toptional\tCHECK_ERROR\t')" "$final_dir/manifest.tsv" || fail 'refus facultatif assimilé à absence' +[[ ! -e "$final_dir/rootfs/etc/nftables.conf" ]] || fail 'lecture facultative malgré refus' +pass +for mode in parent_fail empty unknown valid_fail; do + new_case + SIM_INSPECT_TARGET=/etc/fstab SIM_INSPECT_MODE="$mode" run_case "inspect_$mode" --output-dir /mnt/exports + expect_rc 1; no_complete; no_repo_change; pass +done +new_case +rm "$case_dir/etc/fstab" +run_case missing_required --output-dir /mnt/exports +expect_rc 1; contains 'source obligatoire absente : fstab'; no_complete; pass +new_case +rm "$case_dir/etc/nftables.conf" +SIM_EMPTY_KEY=pacman_foreign run_case empty_and_absent --output-dir /mnt/exports +expect_rc 0; one_final; check_hashes "$final_dir" +grep -Fq "$(printf 'pacman_foreign\trequired\tEMPTY\t')" "$final_dir/manifest.tsv" || fail 'liste vide non distinguée' +grep -Fq "$(printf 'nftables_conf\toptional\tABSENT\t')" "$final_dir/manifest.tsv" || fail 'absence facultative non distinguée' +grep -Fq "$(printf 'nvme_health\toptional\tUNAVAILABLE\t')" "$final_dir/manifest.tsv" || fail 'NVMe indisponible non distingué' +pass +new_case +SIM_FAIL_KEY=pacman_orphans run_case optional_failure --output-dir /mnt/exports +expect_rc 0; one_final +grep -Fq "$(printf 'pacman_orphans\toptional\tCOLLECT_ERROR\t')" "$final_dir/manifest.tsv" || fail 'échec facultatif dissimulé' +pass +new_case +SIM_ORPHANS_EMPTY_ERROR=1 run_case orphan_ambiguous --output-dir /mnt/exports +expect_rc 0; one_final +grep -Fq "$(printf 'pacman_orphans\toptional\tCOLLECT_ERROR\t')" "$final_dir/manifest.tsv" || fail 'code 1 d’orphelins assimilé à vide' +[[ ! -e "$final_dir/pkglist/orphans.txt" ]] || fail 'fausse liste vide d’orphelins' +pass +new_case +SIM_FAIL_KEY='read:/etc/pacman.conf' run_case privileged_read_partial --output-dir /mnt/exports +expect_rc 1; contains 'lecture obligatoire échouée : pacman_conf'; no_complete +! find "$case_dir/exports" -type f -name pacman.conf | grep -q . || fail 'lecture partielle publiée' +pass +new_case +SIM_FAIL_KEY=user_state run_case optional_user_failure --output-dir /mnt/exports +expect_rc 0; one_final +grep -Fq "$(printf 'user_enabled\toptional\tCOLLECT_ERROR\t')" "$final_dir/manifest.tsv" || fail 'échec systemd utilisateur dissimulé' +pass +new_case +printf '/Arch\n cmdline: root=/dev/sda2 rw\n' > "$case_dir/boot/limine.conf" +run_case limine_unsupported --output-dir /mnt/exports; expect_rc 0; one_final +grep -Fq 'root_non_pris_en_charge' "$final_dir/manifest.tsv" || fail 'forme Limine non prise en charge masquée' +pass +new_case +SIM_FINAL_FAIL=1 run_case finalize_fail --output-dir /mnt/exports +expect_rc 1; contains 'renommage final impossible'; no_complete; no_repo_change; pass +new_case +SIM_FINAL_COLLIDE=1 run_case finalize_collision --output-dir /mnt/exports +expect_rc 1; contains 'renommage final non effectué' +[[ "$(find "$case_dir/exports" -maxdepth 1 -type d -name 'export-*' | wc -l)" == 1 ]] || fail 'collision mal traitée' +[[ "$(cat "$case_dir"/exports/export-*/keep)" == 'existing candidate' ]] || fail 'destination préexistante écrasée' +pass +for hash_mode in fail malformed; do + new_case + run_case prior_for_hash --output-dir /mnt/exports; expect_rc 0 + one_final; prior_dir="$final_dir" + SIM_HASH_MODE="$hash_mode" run_case "hash_$hash_mode" --output-dir /mnt/exports + expect_rc 1 + if [[ "$hash_mode" == fail ]]; then contains 'empreinte impossible' + else contains "réponse d'empreinte invalide"; fi + incomplete_marked + [[ "$(find "$case_dir/exports" -maxdepth 1 -type d -name 'export-*' | wc -l)" == 1 ]] || fail 'faux candidat après erreur de somme' + check_hashes "$prior_dir"; no_repo_change; pass +done +for phase in before after; do + for signal in INT TERM; do + new_case + run_case prior_for_final_signal --output-dir /mnt/exports; expect_rc 0 + one_final; prior_dir="$final_dir" + SIM_FINAL_SIGNAL_PHASE="$phase" SIM_FINAL_SIGNAL="$signal" run_case "final_${phase}_${signal}" --output-dir /mnt/exports + if [[ "$signal" == INT ]]; then expect_rc 130; else expect_rc 143; fi + contains "Interruption $signal (" + check_hashes "$prior_dir"; no_repo_change + if [[ "$phase" == before ]]; then + incomplete_marked + contains 'Export incomplet conservé : /mnt/exports/.incomplete.' + not_contains 'Finalisation acquise' + [[ "$(find "$case_dir/exports" -maxdepth 1 -type d -name 'export-*' | wc -l)" == 1 ]] || fail 'finalisation revendiquée avant renommage' + else + [[ -z "$(find "$case_dir/exports" -maxdepth 1 -type d -name '.incomplete.*' -print -quit)" ]] || fail 'staging restant après renommage' + [[ "$(find "$case_dir/exports" -maxdepth 1 -type d -name 'export-*' | wc -l)" == 2 ]] || fail 'candidat finalisé non conservé' + for candidate in "$case_dir"/exports/export-*; do + [[ "$candidate" == "$prior_dir" ]] && continue + check_hashes "$candidate" + contains "Finalisation acquise, terminaison interrompue ; candidat conservé : /mnt/exports/${candidate##*/}" + done + fi + pass + done +done +for signal in INT TERM; do + new_case + run_case prior_export --output-dir /mnt/exports; expect_rc 0 + one_final; prior_dir="$final_dir" + SIM_SIGNAL_KEY=btrfs_filesystem_usage SIM_SIGNAL="$signal" run_case "signal_$signal" --output-dir /mnt/exports + if [[ "$signal" == INT ]]; then expect_rc 130; else expect_rc 143; fi + contains "Interruption $signal" + incomplete_marked + [[ -d "$prior_dir" && "$(find "$case_dir/exports" -maxdepth 1 -type d -name 'export-*' | wc -l)" == 1 ]] || fail 'export antérieur affecté par le signal' + check_hashes "$prior_dir"; no_repo_change; pass +done + +printf 'Résultat : %s scénarios synthétiques confinés validés.\n' "$passes"