fix(backup): isoler les exports et fiabiliser leur finalisation
This commit is contained in:
parent
074d5dd0b2
commit
473b98a17b
5 changed files with 1072 additions and 254 deletions
16
README.md
16
README.md
|
|
@ -33,14 +33,24 @@ restent des observations datées, pas une cible automatique de déploiement.
|
|||
|
||||
## Mise à jour du dépôt
|
||||
|
||||
**Changement de comportement :** sans argument, `backup.sh` crée désormais un
|
||||
candidat d’export **privé hors du dépôt** pour revue. Il ne modifie plus
|
||||
automatiquement les listes, configurations ou inventaires suivis par Git et
|
||||
ne sauvegarde ni données personnelles ni clés. Sa présence locale ne prouve
|
||||
pas une copie hors disque. Voir le [contrat d’export](docs/backup.md).
|
||||
|
||||
```bash
|
||||
./scripts/check-repo.sh
|
||||
./scripts/backup.sh --dry-run
|
||||
# Après décision explicite de collecter sur la machine concernée :
|
||||
./scripts/backup.sh
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
Toujours relire le diff avant de commit : un inventaire généré peut révéler un paquet ajouté par erreur ou un service réactivé automatiquement.
|
||||
Relire le manifeste et **tous** les fichiers candidats avant toute reprise
|
||||
manuelle dans Git ou publication : noms de services, métadonnées de stockage,
|
||||
configurations et modèle Limine peuvent contenir des informations sensibles ou
|
||||
propres à la machine. Aucun candidat n’est promu automatiquement en référence
|
||||
de reconstruction.
|
||||
|
||||
## Restauration
|
||||
|
||||
|
|
|
|||
140
docs/backup.md
140
docs/backup.md
|
|
@ -1,44 +1,118 @@
|
|||
# Sauvegarde du dépôt
|
||||
# Export système pour revue
|
||||
|
||||
## Actualisation
|
||||
`scripts/backup.sh` produit un **candidat privé hors du dépôt**. La commande
|
||||
sans argument ne met plus à jour `pkglist/`, `inventory/`, `rootfs/` ni
|
||||
`templates/` suivis par Git. Elle ne sauvegarde ni données personnelles,
|
||||
dotfiles, clés Secure Boot, connexions réseau, secrets, ni fichiers Trainlog.
|
||||
La zone locale d’export n’est pas une preuve de sauvegarde hors disque ou de
|
||||
restauration possible. Ce code n’a été testé ici que sur des fixtures.
|
||||
|
||||
```bash
|
||||
./scripts/backup.sh
|
||||
## Interface et destination
|
||||
|
||||
```text
|
||||
backup.sh [--output-dir REPERTOIRE_PARENT]
|
||||
backup.sh --dry-run [--output-dir REPERTOIRE_PARENT]
|
||||
backup.sh --help
|
||||
```
|
||||
|
||||
Le script exporte :
|
||||
`--output-dir` désigne un **parent** : chaque invocation crée un sous-répertoire
|
||||
unique. Par défaut, le parent est
|
||||
`${XDG_STATE_HOME:-$HOME/.local/state}/arch-system/exports`. Un
|
||||
`XDG_STATE_HOME` relatif est ignoré avec diagnostic, selon la
|
||||
[spécification XDG](https://specifications.freedesktop.org/basedir/0.8/) ;
|
||||
le chemin par défaut sous `HOME` est alors employé. Un parent explicite
|
||||
relatif est résolu depuis le répertoire courant. Les chemins sont normalisés ;
|
||||
la destination égale au dépôt ou située dedans, les alias vers celui-ci et les
|
||||
parents traversant un lien symbolique sont refusés. Un parent final existant
|
||||
doit appartenir à l’utilisateur et ne pas être ouvert au groupe ou aux autres.
|
||||
Les nouveaux répertoires sont créés privés, sans `chmod`/`chown` des ancêtres.
|
||||
Un parent contenant des espaces ou un antislash littéral reste accepté ; le
|
||||
calcul des empreintes ne dépend pas de la représentation de son nom.
|
||||
|
||||
- paquets officiels explicites avec `pacman -Qqen` ;
|
||||
- paquets étrangers/AUR explicites avec `pacman -Qqem` ;
|
||||
- paquets orphelins ;
|
||||
- unités systemd actives, activées et en échec ;
|
||||
- timers système et utilisateur ;
|
||||
- état Btrfs, zram, mémoire et SSD NVMe ;
|
||||
- configurations système explicitement autorisées ;
|
||||
- un modèle Limine sans la section d’entrées de snapshots générées.
|
||||
`--dry-run` affiche les sources, les catégories obligatoire/facultatif, le
|
||||
parent et les transformations ; il ne crée rien et ne lance ni sudo, ni
|
||||
collecteur, ni demande de mot de passe. Il ne prouve pas la réussite des
|
||||
lectures futures. La collecte refuse une invocation en root ; les fichiers de
|
||||
sortie sont écrits par l’utilisateur sous `umask 077`. Les lectures privilégiées
|
||||
utilisent `sudo -n`, un contrôle d’état strict et seulement les sources
|
||||
explicitement autorisées. Aucun résultat n’est installé avec sudo.
|
||||
|
||||
`backup.sh` ne sauvegarde ni les scripts et unités utilisateur de `~/.dotfiles`,
|
||||
ni les données, connexions réseau, secrets, clés Secure Boot ou sauvegardes
|
||||
hors disque. Les inventaires qu’il produit sont des observations datées ; les
|
||||
manifests demeurent l’intention de services. Une copie du dépôt sur un autre
|
||||
support, accessible et récupérable, doit être vérifiée séparément.
|
||||
## Collecteurs et états
|
||||
|
||||
## Contrôle avant commit
|
||||
| Classe | Collecteurs | Raison |
|
||||
|---|---|---|
|
||||
| Obligatoire | `pacman -Qqen`, `-Qqem` ; unités système activées, en cours et timers ; `lsblk`, `findmnt`, `/etc/fstab`, Btrfs subvolumes/usage, mémoire et swap ; `pacman.conf`, `makepkg.conf`, `mkinitcpio.conf` | Sans ces éléments, le candidat annoncé manquerait son noyau d’inventaire et de configuration. |
|
||||
| Facultatif | `pacman -Qdtq` ; unités en échec et unités utilisateur ; scrub Btrfs ; SMART de `/dev/nvme0` ; `nftables.conf`, limine-snapper-sync, TLP, zram-generator, Snapper, deux helpers root ; modèle Limine | Leur absence ou indisponibilité peut être légitime selon la machine, l’utilisateur ou le matériel. Elle reste visible dans le manifeste. |
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git diff --stat
|
||||
git status --short
|
||||
```
|
||||
`SUCCESS` indique un fichier collecté non vide ; `EMPTY`, une commande
|
||||
réussie avec résultat vide permis ; `ABSENT`, une absence effectivement
|
||||
établie pour une source facultative ; `UNAVAILABLE`, un outil ou `nvme0`
|
||||
indisponible ; `CHECK_ERROR`, un contrôle impossible ou une réponse
|
||||
invalide ; `COLLECT_ERROR`, une commande ou lecture échouée.
|
||||
`PORTABILITY_REVIEW` marque le modèle Limine à relire, même après une
|
||||
transformation reconnue. Une erreur obligatoire interdit la finalisation ;
|
||||
une erreur facultative produit `COMPLETE_WITH_WARNINGS` si le noyau
|
||||
obligatoire a réussi. `COMPLETE` est réservé à un ensemble sans avertissement.
|
||||
En pratique, un modèle Limine présent entraîne une alerte de portabilité.
|
||||
|
||||
Inspecter particulièrement :
|
||||
Les listes sont des **observations** : « natif » ne prouve pas dans tous les
|
||||
cas « dépôt officiel », et « étranger » ne prouve pas « AUR ».
|
||||
`pacman -Qdtq` désigne les dépendances orphelines selon
|
||||
[pacman(8)](https://man.archlinux.org/man/pacman.8.en). La documentation
|
||||
primaire ne justifie pas une règle générale « code 1 = aucun orphelin » :
|
||||
une sortie vide avec code non nul reste `COLLECT_ERROR`, avec une cause à
|
||||
examiner. Les résultats vides de commandes revenues avec succès sont
|
||||
distingués. Le relevé SMART éventuel concerne **seulement** `/dev/nvme0`,
|
||||
pas tous les disques.
|
||||
|
||||
- `pkglist/orphans.txt` ;
|
||||
- les services qui apparaissent nouvellement activés ;
|
||||
- les modifications de `nftables.conf`, `mkinitcpio.conf` et `pacman.conf` ;
|
||||
- l’absence de secrets.
|
||||
## Finalisation, manifeste et limites
|
||||
|
||||
Relire également le modèle Limine généré : le script retire la section
|
||||
automatique et remplace `root=UUID=…`, mais ne démontre pas que tout autre champ
|
||||
propre à la machine est devenu portable. Ne pas publier un export sans revue
|
||||
de ses valeurs et de ses fichiers ajoutés.
|
||||
La collecte commence dans un répertoire `.incomplete.*` privé, doté d’un
|
||||
`manifest.tsv` au statut `INCOMPLETE`. Les sorties sont d’abord reçues dans
|
||||
une zone de travail privée ; une commande qui écrit partiellement puis échoue
|
||||
ne publie pas ce fichier comme relevé valide. Après les éléments obligatoires,
|
||||
le manifeste est terminé et le répertoire est renommé localement en
|
||||
`export-*` sans remplacer un nom existant. Chaque invocation a son espace ;
|
||||
avant un renommage réussi, une erreur ou un `INT`/`TERM` conserve son reliquat
|
||||
marqué `INCOMPLETE` et annoncé. Après le renommage réussi, un `INT`/`TERM`
|
||||
conserve le candidat complet et annonce son chemin avec le diagnostic
|
||||
« finalisation acquise, terminaison interrompue ». L'exporteur reconnaît ce
|
||||
renommage par la disparition du staging et l'identité de l'objet au chemin
|
||||
final ; la simple présence d'une destination préexistante ne suffit pas.
|
||||
Une collision conserve le staging incomplet sans revendiquer cet autre export.
|
||||
Les exports antérieurs ne sont ni supprimés ni remplacés. Les codes sont `0` pour
|
||||
simulation ou candidat finalisé, `1` pour erreur, `2` pour usage invalide,
|
||||
`130`/`143` pour `INT`/`TERM`. `SIGKILL` et une coupure d’alimentation
|
||||
ne sont pas interceptés ; aucune garantie de durabilité après coupure n’est
|
||||
revendiquée.
|
||||
|
||||
Le manifeste contient version, début/fin UTC, révision source, statut global,
|
||||
état de chaque collecteur et SHA-256 des **fichiers de sortie finalisés**.
|
||||
Chaque empreinte est calculée via l'entrée standard, après vérification de
|
||||
l'ouverture du fichier et du succès de la commande ; seule une réponse de
|
||||
64 chiffres hexadécimaux est acceptée. Un échec ou une réponse malformée
|
||||
interdit la finalisation. Le manifeste ne contient pas son propre hash.
|
||||
Vérifier les empreintes et lire le contenu
|
||||
avant toute utilisation. Les lectures ne forment pas un instantané atomique :
|
||||
services et fichiers peuvent changer pendant la collecte. Les vérifications
|
||||
de chemins réduisent les risques d’alias et de liens ; elles ne remplacent pas
|
||||
une exclusion des modifications concurrentes de la machine.
|
||||
|
||||
Le modèle Limine candidat est produit **uniquement dans l’export**. La section
|
||||
commençant par `### Auto-generated by limine-snapper-sync` est retirée.
|
||||
Les formes `root=UUID=<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.
|
||||
|
|
|
|||
|
|
@ -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
143
tests/backup/mock-command.sh
Executable 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
409
tests/backup/test.sh
Executable 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"
|
||||
Loading…
Reference in a new issue