arch-system/tests/backup/test.sh

409 lines
21 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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