fix(backup): isoler les exports et fiabiliser leur finalisation

This commit is contained in:
fy59 2026-10-02 21:58:20 +02:00
parent 074d5dd0b2
commit 473b98a17b
5 changed files with 1072 additions and 254 deletions

View file

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

View file

@ -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=<hexadécimal/tirets>` et
`root=PARTUUID=<hexadécimal/tirets>` 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.

View file

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

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

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

409
tests/backup/test.sh Executable file
View file

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