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