#!/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