arch-system/scripts/backup.sh

416 lines
21 KiB
Bash
Executable file

#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
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
'
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
}
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
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
# 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
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'
}
(( 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
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
reconcile_finalization
if (( FINAL_ACQUIRED )); then fail 'renommage accompli mais commande terminée en erreur'
else fail 'renommage final impossible'; fi
fi
reconcile_finalization
(( FINAL_ACQUIRED )) || fail 'renommage final non effectué'
STAGE=''
printf '[+] Candidat privé finalisé (%s) : %s\n' "$final_status" "$FINAL"
FINAL_ANNOUNCED=1