feat(reconstruction): finaliser la préparation, les contrôles et les procédures

This commit is contained in:
fy59 2026-10-02 22:45:08 +02:00
parent 473b98a17b
commit 6b4bea0ae9
17 changed files with 1243 additions and 370 deletions

View file

@ -12,3 +12,15 @@
- remplacement de la configuration TLP monolithique par un drop-in minimal ;
- retrait du swapfile de 16 Gio, maintien de zram 6 Gio ;
- suppression des références obsolètes à MangoWM et au service de batterie à 80 %.
## Finalisation de la reconstruction (2 octobre 2026)
- export privé hors dépôt et déploiement par étapes sécurisés, avec suites
confinées conservées ;
- préparation de disque reconstruite autour de contrôles structurés, d’une
confirmation liée à l’identité, d’un verrou coopératif, d’étapes vérifiées
et du sous-volume `@/.snapshots` créé sans conflit ;
- contrôle statique du dépôt, entrée unique des tests et diagnostic de lecture
seule avec erreurs visibles ;
- procédure de réinstallation conditionnelle, matrice des décisions et bilan
des vérifications physiques non réalisées.

122
README.md
View file

@ -1,92 +1,56 @@
# Arch System
Configuration et outillage de reconstruction de mon poste **ArchASP**.
Référence datée et outils de reconstruction du poste **ArchASP**. Ce dépôt
contient des listes de paquets, manifests de services, configurations système,
modèles, inventaires historiques et procédures. Les dotfiles, données, clés et
connexions restent hors de ce dépôt. Les listes de juillet sont des
**observations historiques**, pas une installation actuelle garantie.
Ce dépôt couvre la partie système : paquets, services, stockage, énergie, démarrage, outils root et automatisations locales. Les dotfiles complets restent dans un dépôt séparé.
## Parcours
## Objectifs
1. Lire la [référence de reconstruction](docs/reconstruction-baseline.md) et
régler les décisions sur réseau, Secure Boot, swaps et copies externes.
2. Vérifier les copies indépendantes et suivre la [procédure complète sur
papier](docs/reinstall.md), avec contrôles et arrêts à chaque phase.
3. Sur un support live UEFI identifié seulement, utiliser
`scripts/prepare-disk.sh --dry-run DISQUE` avant toute confirmation
destructive. La commande sans `--dry-run` efface le disque explicitement
choisi ; elle n’a pas été exécutée sur ArchASP dans ce travail.
4. Installer le socle officiel, démarrer et vérifier, puis restaurer les
fonctions du poste une à une. [deploy.sh](docs/deploy-safety.md) ne restaure
ni le boot, ni les clés, ni les données. Tant que le pare-feu n’est pas
arbitré, ne pas lancer `deploy.sh --all`.
- documenter l’état réel du poste ;
- reconstruire une installation Arch sans recopier aveuglément des UUID ou des secrets ;
- conserver les outils personnalisés nécessaires au quotidien ;
- distinguer les services permanents des services lancés à la demande ;
- préparer les migrations de disque Btrfs/Limine/Snapper/Secure Boot.
## Organisation
- `scripts/backup.sh` : actualise les inventaires et les configurations suivies ;
- `scripts/audit.sh` : contrôle rapide de l’état du système ;
- `scripts/deploy.sh` : restauration par étapes, sans écraser automatiquement `fstab` ni le boot ;
- `scripts/prepare-disk.sh` : préparation destructive d’un disque pour une nouvelle installation ;
- `inventory/` : informations générées sur le poste ;
- `manifests/` : services intentionnellement activés ;
- `rootfs/` : configurations et helpers installables en root ;
- `Dotfiles_ArchAP` : dépôt séparé pour les configurations, scripts et unités utilisateur ;
- `templates/` : fichiers nécessitant une adaptation à la machine ;
- `docs/` : procédures d’entretien, de restauration et de migration SSD.
La [référence de reconstruction](docs/reconstruction-baseline.md) distingue la cible
documentée de l’état observé sur ArchASP le 2 octobre 2026. Son
[suivi d’audit](docs/audits/2026-10-02-followup.md) précise les preuves, les
limites d’accès et les décisions encore ouvertes. Les inventaires du 26 juillet
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).
## Contrôles locaux
```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
bash tests/run.sh
```
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.
Ce point d’entrée vérifie la syntaxe des scripts, les listes, manifests,
placeholders et liens locaux, puis les suites confinées de déploiement,
d’export, de préparation de disque et de diagnostic. Les tests exécutent des
**copies** dans `bwrap`, sans réseau ni accès aux périphériques blocs hôtes.
ShellCheck est exécuté seulement s’il est déjà disponible. Une réussite ne
certifie ni démarrage ni restauration physique.
## Restauration
`scripts/check-repo.sh` est le contrôle statique seul. `scripts/audit.sh` est
un diagnostic **en lecture seule** de la machine où il est lancé ; il peut
rencontrer des lectures refusées et ne doit pas servir de preuve automatique
de disponibilité. Il n’a pas été lancé sur les données réelles dans ce lot.
Le déploiement est volontairement découpé :
## Export privé et portée des fichiers
```bash
./scripts/deploy.sh --packages
./scripts/deploy.sh --configs
./scripts/deploy.sh --tools
./scripts/deploy.sh --services
```
`scripts/backup.sh` crée un candidat **privé hors Git** pour revue. Il ne met
pas à jour automatiquement `pkglist/`, `inventory/`, `rootfs/` ou `templates/`
et n’est ni une sauvegarde des données ni une copie hors SSD. Lire son
[contrat](docs/backup.md) et inspecter tout candidat avant promotion ou
publication ; les fichiers peuvent contenir des informations sensibles.
Pour tout appliquer :
```bash
./scripts/deploy.sh --all
```
`/etc/fstab`, `limine.conf`, les clés Secure Boot et les connexions réseau ne sont jamais restaurés automatiquement.
## Socle historiquement documenté
- Arch Linux, Sway/SwayFX et Wayland ;
- Btrfs avec `@`, `@home`, `@log`, `@pkg` et `@/.snapshots` ;
- Limine + limine-snapper-sync ;
- Secure Boot géré par `sbctl` ;
- zram 6 Gio ; l’affirmation ancienne « sans swapfile permanent » ne décrit
plus à elle seule le poste observé le 2 octobre 2026 ;
- TLP avec seuil de charge ASUS à 60 % ;
- Nextcloud, Proton Mail Bridge, VMware et Samba lancés uniquement à la demande.
Ce socle n’est pas une certification de réinstallation. Le poste observé possède
aussi des swaps supplémentaires, CUPS disponible au démarrage et nginx utilisé
localement pour Trainlog. Leur portée sur une future installation reste à
décider dans la référence de reconstruction.
## Sécurité
Le dépôt est public. Aucun mot de passe, token, clé privée, fichier `.netrc`, base SQLite, connexion NetworkManager ou configuration WireGuard ne doit y être ajouté.
`rootfs/` regroupe des configurations déployables **à sélectionner**, non
une vérité courante. `manifests/` décrit des intentions datées ; `inventory/`
conserve des observations. `templates/` contient des placeholders à remplacer
sur la machine cible. [Architecture](docs/architecture.md),
[maintenance](docs/maintenance.md), [migration SSD](docs/ssd-migration.md),
[sécurité](docs/security.md) et [bilan AS-001 à AS-009](docs/audits/2026-10-02-finalisation.md)
précisent les limites.

View file

@ -0,0 +1,38 @@
# Finalisation logicielle et limites physiques — 2 octobre 2026
Ce bilan complète le [suivi historique](2026-10-02-followup.md) sans le
réécrire. Les suites de cette mission utilisent des copies et fixtures
confinées ; aucune collecte ou préparation réelle d’ArchASP n’a eu lieu.
Le point d’entrée `bash tests/run.sh` rassemble le contrôle statique et les
harnais : 55 scénarios d’export, 40 de déploiement, 45 de préparation et
5 de diagnostic. Pour la préparation, les commandes `lsblk` et `findmnt`
sélectionnent explicitement leurs colonnes JSON ; `lsblk --tree` force les
descendants nécessaires aux contrôles. Les essais incluent SATA/NVMe, un
overlay live synthétique, swaps, holders, concurrence, partitions, signaux et
nettoyage ciblé. La simulation ne prouve pas le résultat matériel.
| Constat | État de cette mission | Preuve et reste à faire |
|---|---|---|
| AS-001 signature/démarrage | Choix opérateur, non vérifié physiquement | La procédure recense EFI, noyaux, hook masqué et contrôles sbctl ; signatures et chaîne réelle non lues. |
| AS-002 séquence de réinstallation | Procédure décrite, non éprouvée physiquement | `docs/reinstall.md` couvre copies, stockage, socle, chroot, Limine, contrôle et arrêt ; premier démarrage absent. |
| AS-003 AUR/yay | Procédure décrite | Amorçage comme utilisateur non root après socle, revue PKGBUILD ; aucune construction réelle. |
| AS-004 réseau/nftables | Choix opérateur | Variantes VPN obligatoire/facultatif et flux VM documentés ; règles effectives non lues. |
| AS-005 disque | Correction logicielle testée en confinement | Contrôles identité/montages/swaps/holders, verrou, confirmation, `.snapshots`, étapes et reprises ; aucun disque réel testé. |
| AS-006 swaps | Choix opérateur | Zram historique séparé des swaps de charge ; aucun swap modifié. |
| AS-007 services | Procédure décrite | CUPS/scan, nginx local, Avahi à qualifier, VMware/Samba à la demande ; essais fonctionnels absents. |
| AS-008 export/déploiement | Correction logicielle conservée | Suites antérieures de 55 et 40 scénarios confinés ; aucun export ni déploiement réel. |
| AS-009 snapshots/restauration | Non vérifié physiquement | `.snapshots` cohérent avec le schéma et procédure Snapper conditionnelle ; aucun démarrage de snapshot ou retour `replace` testé. |
L’essai déclaré du 29 mai demeure une information historique et ne valide
pas l’état actuel. Les commandes dépendant des versions ont été recoupées
avec les paquets locaux (util-linux 2.42.4, btrfs-progs 7.1, parted 3.8,
linux-lts 6.18.54, Limine 12.9.1, sbctl 0.18, Snapper 0.13.2,
limine-snapper-sync 1.32, mkinitcpio 42.1) et les sources primaires
[Installation Arch](https://wiki.archlinux.org/title/Installation_guide),
[Limine](https://wiki.archlinux.org/title/Limine),
[AUR](https://wiki.archlinux.org/title/Arch_User_Repository),
[sbctl(8)](https://man.archlinux.org/man/sbctl.8.en),
[snapper(8)](https://man.archlinux.org/man/snapper.8.en) et
[Btrfs](https://btrfs.readthedocs.io/en/latest/btrfs-subvolume.html).
Les versions sur le futur support live doivent être revérifiées.

View file

@ -60,3 +60,13 @@ Pour chaque sauvegarde, distinguer quatre preuves : **destination mentionnée**,
4. Rôle et persistance des swaps supplémentaires, ainsi que destination vérifiable des copies hors SSD.
Ces arbitrages précèdent toute réécriture de `pkglist/`, `manifests/`, `rootfs/` ou des scripts.
## État du dépôt après finalisation logicielle
La [procédure de réinstallation](reinstall.md) décrit maintenant les commandes,
préconditions, contrôles et arrêts. Le [bilan](audits/2026-10-02-finalisation.md)
distingue corrections logicielles simulées, choix opérateur et preuves
physiques manquantes. Cette mise à jour ne modifie ni les listes de juillet,
ni les manifests, ni la politique Secure Boot/réseau. AS-001 et AS-009
restent ouverts tant que signatures, amorçage et restauration actuels ne sont
pas observés sur la cible. L’essai déclaré du 29 mai reste historique.

View file

@ -1,81 +1,280 @@
# Réinstallation — trame partielle
# Réinstallation d’ArchASP — procédure conditionnelle
> **État au 2 octobre 2026 : procédure partielle, non éprouvée sur un SSD
> vierge dans ce lot.** Avant de l’employer, résoudre les décisions et
> dépendances de la [référence de reconstruction](reconstruction-baseline.md),
> vérifier une copie indépendante du dépôt, des dotfiles, des données et des
> moyens de récupération Secure Boot. Les étapes Limine, signature, Snapper et
> restauration des données ci-dessous ne forment pas encore une séquence
> complète validée jusqu’au premier démarrage.
Cette procédure est un **parcours sur papier** : aucun SSD neuf, premier
redémarrage, retour de snapshot ou restauration externe n’a été essayé dans ce
lot. Lire la [référence de reconstruction](reconstruction-baseline.md) et le
[bilan de finalisation](audits/2026-10-02-finalisation.md). Les commandes
marquées « live » s’exécutent depuis un support Arch UEFI vérifié ; celles
marquées « chroot » dans `arch-chroot /mnt` ; celles marquées « utilisateur »
après le premier démarrage. **Arrêter à chaque contrôle manquant**. Ne jamais
copier les exemples en bloc.
## 1. Préparer le disque
## 1. Copies et moyens de retour — avant de toucher au SSD
Depuis l’ISO Arch :
**Contexte : poste actuel, utilisateur.** Obtenir une copie indépendante du SSD
à remplacer de `arch-system`, des dotfiles séparés et des données personnelles,
dont Trainlog. Conserver les moyens de récupération de clés, réseau et Secure
Boot hors Git public. Pour chaque copie noter : support, date, empreinte et un
fichier récupéré lors d’un essai. Un remote déclaré, un export privé de
`backup.sh` et un snapshot sur le même SSD ne sont pas des restaurations
testées. **Arrêt** si l’un de ces accès est absent ; les sauvegardes ne sont pas
réalisées par cette procédure.
## 2. Support live et cible — live, avant toute mutation
Démarrer le support Arch en UEFI et vérifier `test -d /sys/firmware/efi`.
Contrôler le réseau de secours et l’accès aux copies indépendantes sans
publier de secrets. Installer sur le support live uniquement les dépendances
manquantes **après choix explicite de l’opérateur** ; la présente mission n’en
a installé aucune. Le script exige Bash, Python 3, `lsblk`, `findmnt`,
`swapon`, `readlink`, `wipefs`, `parted`, `partprobe`, `udevadm`, `mkfs.fat`,
`mkfs.btrfs`, `btrfs`, `mount` et `umount`. La suite demande aussi `pacstrap`,
`genfstab`, `arch-chroot` et Neovim. Vérifier leur présence et leur version
sur le support réellement démarré ; arrêter si l’éditeur ou un outil requis
manque.
Identifier manuellement la cible, le support live, le dépôt et les sauvegardes :
```bash
sudo ./scripts/prepare-disk.sh /dev/nvme0n1
lsblk -e7 -o PATH,TYPE,MAJ:MIN,SIZE,MODEL,SERIAL,WWN,FSTYPE,MOUNTPOINTS
findmnt -o SOURCE,TARGET,FSTYPE
swapon --show
```
La commande est destructive et exige une confirmation explicite.
## 2. Installer la base
Exemple :
Aucun disque par défaut n’est fourni. Renseigner une variable dans **ce shell**
après recoupement de l’identité physique ; un nom `/dev/nvme*` seul ne suffit
pas :
```bash
pacstrap -K /mnt base linux-lts linux-firmware amd-ucode btrfs-progs networkmanager git sudo neovim
: "${DISK:?Renseigner le disque entier confirmé avant toute suite}"
./scripts/prepare-disk.sh --dry-run "$DISK"
```
Générer ensuite le `fstab` depuis les montages réels :
Le mode simulation n’écrit rien, ne confirme rien et ne prouve pas qu’un
contrôle privilégié inaccessible a réussi. Un overlay live n’est admis que si
son support physique apparaît séparément sous `/run/archiso/bootmnt`, reste
distinct de la cible et que chaque couche déclarée renvoie à ce support ou
à un `tmpfs` ; les overlays non attribuables et les Btrfs
multi-périphériques ambigus sont refusés. Dans ce cas **arrêt et revue de la
provenance du support**, pas de contournement. Examiner `/mnt`, les montages,
swaps et holders. Ne pas démonter ou désactiver une utilisation pour faire
passer le contrôle.
## 3. Préparation — live, destructive et humaine
Après les copies vérifiées et une seconde lecture de l’identité affichée :
```bash
genfstab -U /mnt >> /mnt/etc/fstab
: "${DISK:?Cible obligatoire}"
./scripts/prepare-disk.sh "$DISK"
```
Ne jamais recopier directement l’ancien `fstab`.
Le script exige root dans un contexte live Arch UEFI, un verrou coopératif et
la saisie exacte `ERASE <chemin-canonique> <major:minor>`. Il revérifie la cible
avant `wipefs`. Résultat attendu : GPT, ESP FAT32 d’environ 1 Gio montée sur
`/mnt/boot`, Btrfs pour le reste et montages `@`, `@home`, `@log`, `@pkg`,
`@/.snapshots`. Vérifier les chemins réels avec `findmnt -R /mnt` et
`lsblk -f "$DISK"`. **Arrêt** si une étape échoue : le partitionnement est
irréversible, non transactionnel ; la dernière commande peut avoir eu un
effet même avec code non nul. Lire le bilan des étapes et les montages restants,
inspecter manuellement, ne jamais relancer aveuglément.
## 3. Configuration de base
## 4. Socle officiel — live, après montages vérifiés
Après `arch-chroot /mnt` :
- fuseau horaire, locale et hostname ;
- utilisateur et sudo ;
- réseau ;
- Limine ;
- initramfs ;
- Secure Boot ;
- Snapper.
## 4. Cloner le dépôt
Vérifier la disponibilité des paquets sur le miroir configuré et le processeur
avant `pacstrap`. Pour la variante AMD observée, le socle proposé est :
```bash
git clone https://git.labfytools.com/fy59/arch-system
cd arch-system
pacman -Si base linux-lts linux-firmware amd-ucode btrfs-progs dosfstools \
networkmanager git sudo neovim limine mkinitcpio efibootmgr
pacstrap -K /mnt base linux-lts linux-firmware amd-ucode btrfs-progs \
dosfstools networkmanager git sudo neovim limine mkinitcpio efibootmgr
```
## 5. Restaurer par étapes
Pour un autre CPU, choisir le microcode correspondant et revalider la ligne.
Le noyau, l’initramfs, l’ESP, Limine et le réseau minimal doivent suffire au
premier démarrage sans `yay` ni liste AUR historique. **Arrêt** si un paquet
ou la signature du miroir n’est pas disponible ; ne pas substituer au hasard.
`--packages` requiert `yay` pour la liste AUR, alors que `yay` figure lui-même
dans cette liste : sur une installation neuve, cette partie est reportée.
L’amorçage vérifié de l’outil AUR et le choix des paquets étrangers nécessaires
restent à documenter. Le noyau et le premier démarrage doivent être obtenus
avec le socle disponible avant cette intégration facultative.
## 5. `fstab` — live, depuis les montages réels
Générer d’abord un candidat, le relire puis **remplacer** un nouveau fichier,
jamais `>>` sur un `fstab` existant :
```bash
./scripts/deploy.sh --packages
./scripts/deploy.sh --configs
./scripts/deploy.sh --tools
./scripts/deploy.sh --services
genfstab -U /mnt > /mnt/etc/fstab.candidate
nvim /mnt/etc/fstab.candidate
findmnt -R /mnt
```
## 6. Finaliser manuellement
Vérifier une seule entrée pour `/`, `/home`, `/var/log`,
`/var/cache/pacman/pkg`, `/.snapshots` et `/boot`, leurs UUID et options.
Si `/mnt/etc/fstab` existe déjà, ouvrir aussi ce fichier avec Neovim et
comparer les deux versions ; ne pas écraser une configuration inconnue.
Après revue seulement : `mv /mnt/etc/fstab.candidate /mnt/etc/fstab`.
Comparer aux [modèles](../templates/fstab.btrfs) comme schéma, sans copier
l’ancien `fstab` ni ses swaps/espaces de travail. **Arrêt** si doublon ou
UUID incohérent.
- adapter `templates/fstab.btrfs` aux nouveaux UUID ;
- adapter `templates/limine.conf` à l’UUID Btrfs ;
- lancer `mkinitcpio -P` ;
- installer et configurer Limine ;
- créer la configuration Snapper ;
- lancer limine-snapper-sync ;
- enrôler puis signer avec `sbctl` ;
- restaurer les dotfiles séparément ;
- tester `nftables`, SSH, audio, veille et seuil de batterie avant le premier usage normal.
## 6. Chroot et identité — live puis chroot root
Depuis le live, rendre le dépôt de reconstruction disponible dans le nouveau
système à partir de la copie indépendante **déjà vérifiée** ; ne pas présumer
qu’un remote sera accessible :
```bash
: "${REPO_SOURCE:?Chemin de la copie indépendante vérifiée obligatoire}"
test -f "$REPO_SOURCE/templates/limine.conf"
test ! -e /mnt/root/arch-system
cp -a "$REPO_SOURCE" /mnt/root/arch-system
```
Entrer par `arch-chroot /mnt` après vérification des montages et du dépôt.
Dans le chroot,
choisir explicitement fuseau, locale, nom de machine et identités ; exemple à
adapter :
```bash
ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime
hwclock --systohc
nvim /etc/locale.gen
locale-gen
nvim /etc/locale.conf
nvim /etc/hostname
nvim /etc/hosts
useradd -m -G wheel -s /bin/bash NOM_UTILISATEUR
passwd NOM_UTILISATEUR
passwd
EDITOR=nvim visudo
systemctl enable NetworkManager.service
```
Dans `visudo`, n’accorder à `wheel` que la règle minimale choisie. Vérifier
`getent passwd NOM_UTILISATEUR`, `visudo -c`, les locales produites et les
fichiers édités. Le réseau de secours doit rester utilisable sans politique
nftables non décidée. **Arrêt** si l’identité, le mot de passe ou le réseau ne
sont pas contrôlables. Les commandes de construction AUR se feront plus tard
comme utilisateur non root.
## 7. Initramfs et Limine — chroot root
Vérifier d’abord les artefacts **effectivement fournis** par les paquets :
```bash
pacman -Ql linux-lts limine amd-ucode
ls -l /usr/share/limine/BOOTX64.EFI /boot/vmlinuz-linux-lts
findmnt /boot
blkid
mkinitcpio -P
ls -l /boot/initramfs-linux-lts.img /boot/amd-ucode.img
```
Vérifier l’ESP réelle sous `/boot` et les UUID relevés. Après avoir confirmé
qu’aucun fichier préexistant ne serait écrasé, déployer le binaire fourni par
le paquet et rédiger la configuration sur l’ESP :
```bash
test "$(findmnt -nro TARGET /boot)" = /boot
test -f /usr/share/limine/BOOTX64.EFI
test ! -e /boot/EFI/arch-limine/BOOTX64.EFI
install -Dm0644 /usr/share/limine/BOOTX64.EFI /boot/EFI/arch-limine/BOOTX64.EFI
cp /root/arch-system/templates/limine.conf /boot/limine.conf.candidate
nvim /boot/limine.conf.candidate
```
Dans Neovim, remplacer l’UUID racine, supprimer l’entrée Zen si `linux-zen`
n’a pas été installé, choisir `default_entry` correspondant à l’entrée LTS,
et contrôler `rootflags=subvol=@`. Vérifier l’absence de `@ROOT_UUID@`,
l’existence de chaque `kernel_path`/`module_path` et la syntaxe de la version
Limine installée avant `mv /boot/limine.conf.candidate /boot/limine.conf`.
Pour l’entrée UEFI, fournir séparément le **disque entier de l’ESP** et son
numéro de partition, puis vérifier `efibootmgr -v` :
```bash
: "${DISK:?Disque de l’ESP vérifié obligatoire}"
: "${ESP_PART_NUMBER:?Numéro de partition EFI vérifié obligatoire}"
efibootmgr --create --disk "$DISK" --part "$ESP_PART_NUMBER" \
--label 'Arch Linux Limine' --loader '\EFI\arch-limine\BOOTX64.EFI' --unicode
efibootmgr -v
```
La [documentation Limine](https://wiki.archlinux.org/title/Limine) distingue
le chemin UEFI et le fichier de configuration. Ne pas modifier l’EFI pendant
cette mission. Garder une entrée LTS normale et un retour au support live.
**Arrêt** si un artefact, UUID, numéro de partition ou chemin diffère ; aucun
démarrage n’a été éprouvé ici.
## 8. Secure Boot — décision conditionnelle, chroot/firmware par opérateur
Avant activation, déterminer la politique de clés et les moyens de récupération
existants. Si cette politique retient `sbctl`, vérifier sa disponibilité dans
les dépôts (`pacman -Si sbctl`) et l’installer après décision
(`pacman -S sbctl`). Avec la version réellement présente de `sbctl`, vérifier les
commandes `status`, `list-files` et `verify`, les fichiers EFI suivis, le
chargeur effectivement sélectionné, les noyaux et les copies historiques.
Comparer les signatures au chemin de confiance Limine retenu. Le hook ALPM
`zz-sbctl.hook` était masqué sur ArchASP ; **ne pas le réactiver du seul fait
de ce masquage**. Le post-hook mkinitcpio observé ne démontre que la voie
conditionnelle du noyau, pas une signature de toute la chaîne. Ne pas créer
ni enrôler de clés, supprimer des clés firmware ou imposer `ESP_PATH` par
habitude. **Arrêt** si les clés, fichiers à signer et déclencheurs ne sont pas
établis ; procéder avec le support live de secours. Voir
[la sécurité](security.md) et [sbctl(8)](https://man.archlinux.org/man/sbctl.8.en).
## 9. Avant et après le premier démarrage — opérateur
Avant de quitter le live : `findmnt -R /mnt`, `cat /mnt/etc/fstab`, présence
du noyau/initramfs et du chargeur sur l’ESP, entrée normale Limine, accès
réseau de secours et copies. Démonter uniquement après revue et sortir du
chroot. Le **redémarrage humain n’a pas été fait ici**. Après démarrage normal,
vérifier `findmnt`, `lsblk -f`, `systemctl --failed`, réseau, heure, entrée
réellement amorcée et, si choisie, la chaîne Secure Boot. Revenir au support
live en cas d’échec ; ne pas confondre présence de fichiers et démarrage réussi.
## 10. AUR et `yay` — utilisateur non root, après démarrage
Installer `base-devel` et `git` depuis les dépôts officiels, choisir une
source AUR explicite et vérifier `PKGBUILD`, fichiers annexes, provenance,
révision et dépendances. Comme utilisateur ordinaire, dans un répertoire de
travail privé : `git clone https://aur.archlinux.org/yay.git`, examiner avec
`nvim PKGBUILD`, puis seulement `makepkg -si`. Vérifier la version et la
provenance installées. Ne pas lancer `makepkg` en root ni `curl | sh`.
`deploy.sh --packages` refuse actuellement la liste AUR sans `yay` ; ce refus
ne réalise pas l’amorçage. Sélectionner les paquets étrangers nécessaires
individuellement, sans importer toute la liste de juillet.
## 11. Snapper et retour d’essai — root après premier démarrage
Installer d’abord `snapper` et `snap-pac` depuis leurs sources officielles
vérifiées (`pacman -Si snapper snap-pac`, puis `pacman -S snapper snap-pac`).
`limine-snapper-sync` était classé étranger sur ArchASP ; le sélectionner,
examiner sa provenance et le construire comme utilisateur non root seulement
après l’amorçage AUR du §10. Le schéma crée déjà `@/.snapshots` : vérifier
`findmnt /.snapshots`, `btrfs subvolume show /.snapshots` et la version Snapper.
Ne pas lancer `snapper create-config /` sur un chemin existant en supposant
qu’il l’écrasera. Pour cette topologie, préparer la configuration `root` à
partir du modèle de la version installée (`/usr/share/snapper/config-templates/default`
observé ici), renseigner `SUBVOLUME="/"`, `FSTYPE="btrfs"`, enregistrer
`SNAPPER_CONFIGS="root"` dans `/etc/conf.d/snapper` et vérifier
`snapper -c root get-config`/`list`. N’écraser aucun fichier préexistant sans
comparaison dans Neovim. Choisir ensuite les timers cleanup/timeline et hooks
réellement disponibles ; vérifier leur déclenchement et les entrées
limine-snapper-sync générées. Un retour de snapshot `replace` recrée la racine
`@` avec un nouvel identifiant ; il ne restaure pas `@home`, `@log`, `@pkg` ni
les données hors SSD. Faire un essai de démarrage sur snapshot et de retour
normal **avant** de déclarer cette voie opérationnelle ; aucun essai ici.
## 12. Fonctions du poste — utilisateur puis root ciblé
Restaurer les dotfiles depuis leur copie indépendante, puis sélectionner les
fonctions nécessaires : TLP et seuil de batterie, CUPS/impression et
numérisation, éventuellement Avahi selon la découverte réellement requise,
nginx local en boucle locale pour le proxy Trainlog, puis VMware/Samba à la
demande. Pour Trainlog, ce dépôt ne transporte que les dépendances système ;
backend, unité utilisateur et base restent dans leurs périmètres séparés.
Vérifier chaque fonction après installation, sans activer tous les services
historiques. Zram 6 Gio est le socle historique ; swaps additionnels et
espaces de travail demandent une décision de charge séparée. Ne pas copier
`rootfs/etc/nftables.conf` ni lancer `deploy.sh --all` tant que la politique
VPN/flux n’est pas décidée. Voir [les choix réseau et services](security.md),
[la matrice](reconstruction-baseline.md) et [le déploiement](deploy-safety.md).

View file

@ -13,3 +13,23 @@ Le dépôt est public. Ne jamais y stocker :
- sauvegardes brutes de `/root`.
Les règles sudoers fournies sont limitées à des helpers root contrôlés. Ne jamais autoriser sans mot de passe `/usr/bin/systemctl`, `/usr/bin/tee`, un shell ou un éditeur généraliste.
## Décisions avant restauration du réseau et de la chaîne de démarrage
Le fichier nftables versionné vise `wg0` ; le relevé du poste visait
`wlp1s0`, avec WireGuard en échec. Deux politiques sont possibles, à choisir
explicitement : **VPN obligatoire** (bloquer le trafic non autorisé quand le
tunnel tombe, tout en prévoyant les flux de connexion et de secours) ou
**VPN facultatif** (autoriser des flux directs définis et surveiller la panne
du tunnel). Pour chacune, dresser une table DNS/Web/SSH, boucle locale,
impression et flux VM, puis tester la coupure du VPN et le retour du réseau
sans perdre l’accès d’administration. Avant ce choix, ne pas copier le
pare-feu versionné et ne pas utiliser `deploy.sh --all`.
Pour Secure Boot, comparer les clés conservées hors Git, l’ESP réellement
montée, le chargeur EFI utilisé, les noyaux et les fichiers suivis par
`sbctl list-files`/`verify` selon la version installée. Le hook pacman sbctl
masqué sur ArchASP ne suffit pas à justifier sa réactivation ; la signature
éventuelle par post-hook mkinitcpio ne prouve pas la chaîne complète. Ne pas
recréer/enrôler de clés ou changer `ESP_PATH` sans la décision de l’opérateur
et un chemin de secours démarrable. Voir [la procédure](reinstall.md).

View file

@ -1,4 +1,9 @@
# Migration du SSD 512 Go vers 1 To
# Migration du SSD 512 Go vers 1 To — scénario historique
Ce document décrit un scénario de clonage antérieur. Les modèles et noms ci-dessous
ne sélectionnent **aucun** disque actuel. La [procédure de réinstallation](reinstall.md)
fait foi pour une préparation neuve ; pour un clonage, recouper source et cible,
copies indépendantes et démarrage de secours avant toute commande.
## Support de secours
@ -8,7 +13,10 @@ Carte SD Ventoy contenant :
- Clonezilla Live ;
- GParted Live.
Avec des clés Secure Boot personnelles, désactiver temporairement Secure Boot pour démarrer Ventoy sans supprimer les clés du firmware.
Avec des clés Secure Boot personnelles, tester d’abord la compatibilité du
support choisi et prévoir le retour à l’état initial. Une modification de la
politique firmware relève de l’opérateur ; ne jamais supprimer les clés pour
faire passer un essai de support live.
## Avant le clonage
@ -22,7 +30,8 @@ Commandes de contrôle :
```bash
lsblk -p -o NAME,MODEL,SIZE,TRAN,FSTYPE,MOUNTPOINTS
sudo smartctl -a /dev/nvme0n1
: "${SOURCE_DISK:?Identifier et vérifier la source avant lecture SMART}"
smartctl -a "$SOURCE_DISK"
```
## Clonezilla
@ -35,7 +44,8 @@ Beginner
disk_to_local_disk
```
Source : Samsung 512 Go. Cible : Fanxiang 1 To.
Source et cible historiques : Samsung 512 Go et Fanxiang 1 To. Leur identité
actuelle doit être établie à nouveau par modèle, capacité et identifiant stable.
La cible est entièrement effacée.
@ -57,3 +67,12 @@ sudo btrfs filesystem usage /
```
Garder le Samsung intact plusieurs jours avant de le reformater comme SSD externe.
## Point d’arrêt actuel
La [réinstallation structurée](reinstall.md) remplace toute utilisation de ce
document historique comme recette directe de partitionnement. Les copies
indépendantes et leur récupération d’essai, le SSD cible, la politique réseau
et Secure Boot doivent être établis avant une migration. Le script de
préparation corrigé n’a été testé que sur des copies confinées ; aucun disque
réel n’a été préparé dans cette finalisation.

View file

@ -1,55 +1,65 @@
#!/usr/bin/env bash
set -u
# Diagnostic de lecture seule. Les échecs de lecture restent visibles.
set -uo pipefail
status=0
section() { printf '\n=== %s ===\n' "$1"; }
section "CHARGE"
uptime
section "MÉMOIRE"
free -h
swapon --show
section "PRESSION"
for file in /proc/pressure/{cpu,memory,io}; do
printf '%s\n' "--- $file"
cat "$file"
read_command() {
if ! "$@"; then printf '[!] Lecture échouée : %s\n' "$1" >&2; status=1; fi
}
top_processes() {
if ! ps -eo pid,comm,%cpu,%mem,rss "--sort=$1" | sed -n '1,20p'; then
printf '[!] Lecture échouée : ps (%s)\n' "$1" >&2
status=1
fi
}
service_state() {
local scope="$1" unit="$2" enabled active rc=0
local prefix=(systemctl)
[[ "$scope" == user ]] && prefix+=(--user)
enabled="$("${prefix[@]}" is-enabled "$unit" 2>/dev/null)" || rc=$?
case "$enabled" in enabled|disabled|static|masked|indirect|generated|linked|linked-runtime|alias|enabled-runtime|transient) ;;
*) enabled="ERREUR($rc)"; status=1 ;;
esac
rc=0
active="$("${prefix[@]}" is-active "$unit" 2>/dev/null)" || rc=$?
case "$active" in active|inactive|failed|activating|deactivating|reloading|maintenance) ;;
*) active="ERREUR($rc)"; status=1 ;;
esac
printf '%-36s enabled=%-16s active=%s\n' "$unit" "$enabled" "$active"
}
section CHARGE; read_command uptime
section MÉMOIRE; read_command free -h; read_command swapon --show
section PRESSION
for file in /proc/pressure/cpu /proc/pressure/memory /proc/pressure/io; do
printf '%s\n' "--- $file"; read_command cat "$file"
done
section "DISQUE"
df -h /
sudo btrfs filesystem usage /
section "TOP CPU"
ps -eo pid,comm,%cpu,%mem,rss --sort=-%cpu | head -n 20
section "TOP RAM"
ps -eo pid,comm,%cpu,%mem,rss --sort=-rss | head -n 20
section "SERVICES EN ÉCHEC"
systemctl --failed
systemctl --user --failed
section "SERVICES À LA DEMANDE"
for unit in vmware-networks.service vmware-usbarbitrator.service smb.service nmb.service; do
printf '%-36s enabled=%-10s active=%s\n' \
"$unit" \
"$(systemctl is-enabled "$unit" 2>/dev/null || true)" \
"$(systemctl is-active "$unit" 2>/dev/null || true)"
done
printf '%-36s enabled=%-10s active=%s\n' \
protonmail-bridge.service \
"$(systemctl --user is-enabled protonmail-bridge.service 2>/dev/null || true)" \
"$(systemctl --user is-active protonmail-bridge.service 2>/dev/null || true)"
section "PROCESSUS INDÉSIRABLES AU REPOS"
pgrep -a -f 'nextcloud|thunderbird|protonmail-bridge|/protonmail/bridge|proton-pass|ollama|llama-server' \
|| echo "Aucun"
section "BATTERIE"
cat /sys/class/power_supply/BAT1/charge_control_end_threshold 2>/dev/null || true
section "DÉMARRAGE"
systemd-analyze
systemd-analyze blame | head -n 15
section DISQUE
read_command df -h /
# Aucun prompt : la lecture privilégiée refusée est rapportée, jamais contournée.
read_command sudo -n btrfs filesystem usage /
section 'TOP CPU'; top_processes -%cpu
section 'TOP RAM'; top_processes -rss
section 'SERVICES EN ÉCHEC'
read_command systemctl --failed
read_command systemctl --user --failed
section 'SERVICES À LA DEMANDE'
for unit in vmware-networks.service vmware-usbarbitrator.service smb.service nmb.service; do service_state system "$unit"; done
service_state user protonmail-bridge.service
section 'PROCESSUS OBSERVÉS (qualification manuelle)'
if pgrep -a -f 'nextcloud|thunderbird|protonmail-bridge|/protonmail/bridge|proton-pass|ollama|llama-server'; then
:
else
rc=$?
if (( rc == 1 )); then echo 'Aucun processus correspondant observé.'
else printf '[!] Recherche de processus échouée (%s).\n' "$rc" >&2; status=1; fi
fi
section BATTERIE
if [[ -r /sys/class/power_supply/BAT1/charge_control_end_threshold ]]; then
read_command cat /sys/class/power_supply/BAT1/charge_control_end_threshold
else
echo 'Seuil BAT1 indisponible ou illisible ; état non établi.'
fi
section DÉMARRAGE
read_command systemd-analyze
read_command systemd-analyze blame
exit "$status"

53
scripts/check-repo-data.py Executable file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Static repository references; no privilege, network or host inventory reads."""
from pathlib import Path
import re
import sys
root = Path(sys.argv[1])
errors = []
def error(value): errors.append(value)
def lines(path):
try: return (root/path).read_text(encoding='utf-8').splitlines()
except OSError as exc: error(f'{path}: {exc}'); return []
for name in ('pacman','aur','orphans'):
path = f'pkglist/{name}.txt'
values = lines(path)
if values != sorted(set(values)) or any(not re.fullmatch(r'[A-Za-z0-9@._+:-]+', x) for x in values):
error(f'{path}: tri, doublon ou nom invalide')
if set(lines('pkglist/pacman.txt')) & set(lines('pkglist/aur.txt')):
error('paquet présent dans les deux listes')
for name in ('system-services','system-timers','on-demand-system-services'):
path = f'manifests/{name}.txt'
values = lines(path)
suffix = '.timer' if name == 'system-timers' else '.service'
if values != sorted(set(values)) or any(not re.fullmatch(r'[A-Za-z0-9@._:-]+', x) or not x.endswith(suffix) for x in values):
error(f'{path}: unité invalide, désordre ou doublon')
for name,needles in {
'templates/fstab.btrfs':('@ROOT_UUID@','@EFI_UUID@','subvol=/@/.snapshots'),
'templates/limine.conf':('@ROOT_UUID@',),
'templates/sudoers.d/batlimit':('@USER@',),
'templates/sudoers.d/vmware-stack':('@USER@',),
}.items():
value='\n'.join(lines(name))
for needle in needles:
if needle not in value: error(f'{name}: {needle} absent')
for path in [root/'README.md',*sorted((root/'docs').rglob('*.md'))]:
try: text=path.read_text(encoding='utf-8')
except OSError as exc: error(str(exc)); continue
for target in re.findall(r'\]\(([^)]+)\)',text):
if '://' in target or target.startswith('#') or target.startswith('mailto:'): continue
local=target.split('#',1)[0].split(' ',1)[0]
if local:
candidate=(path.parent/local).resolve()
if not candidate.is_relative_to(root.resolve()) or not candidate.exists():
error(f'{path.relative_to(root)}: lien local absent ou hors dépôt {target}')
for path in root.rglob('*'):
if '.git' in path.parts or not path.is_file(): continue
if path.suffix in ('.key','.pem','.p12','.sqlite','.sqlite3','.db') or path.name == '.netrc':
error(f'fichier potentiellement sensible : {path.relative_to(root)}')
for forbidden in ('userfs','inventory/packages','rootfs/etc/sudoers.d'):
if (root/forbidden).exists(): error(f'répertoire interdit : {forbidden}')
for item in errors: print('[!] '+item,file=sys.stderr)
print(f'Contrôle des listes, manifests, modèles et liens : {len(errors)} erreur(s).')
sys.exit(bool(errors))

View file

@ -1,121 +1,31 @@
#!/usr/bin/env bash
set -Eeuo pipefail
readonly REPO_DIR="$(
cd "$(dirname "${BASH_SOURCE[0]}")/.."
pwd
)"
# Contrat : contrôle local, sans collecte, privilège ni mutation du poste.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
status=0
fail() {
printf '[!] %s\n' "$*" >&2
status=1
shopt -s nullglob
bash_files=("$ROOT"/scripts/*.sh "$ROOT"/tests/*.sh "$ROOT"/tests/*/*.sh "$ROOT"/rootfs/usr/local/sbin/*)
python_files=("$ROOT"/scripts/*.py "$ROOT"/tests/*/*.py)
(( ${#bash_files[@]} >= 10 && ${#python_files[@]} >= 2 )) || {
echo '[!] Scripts attendus absents.' >&2; exit 1;
}
echo '[*] Validation syntaxique Bash'
while IFS= read -r -d '' file; do
bash -n "$file" || status=1
done < <(
find \
"$REPO_DIR/scripts" \
"$REPO_DIR/rootfs/usr/local/sbin" \
-type f \
-print0 2>/dev/null
)
echo '[*] Validation des listes de paquets'
for file in \
"$REPO_DIR/pkglist/pacman.txt" \
"$REPO_DIR/pkglist/aur.txt" \
"$REPO_DIR/pkglist/orphans.txt"
do
[[ -f "$file" ]] || {
fail "Liste absente : $file"
continue
}
LC_ALL=C sort -cu "$file" ||
fail "Liste non triée ou contenant des doublons : $file"
for file in "${bash_files[@]}"; do
[[ -f "$file" && -r "$file" ]] && bash -n "$file" || status=1
done
if [[ -f "$REPO_DIR/pkglist/pacman.txt" &&
-f "$REPO_DIR/pkglist/aur.txt" ]]; then
if LC_ALL=C comm -12 \
"$REPO_DIR/pkglist/pacman.txt" \
"$REPO_DIR/pkglist/aur.txt" |
grep -q .
then
fail 'Des paquets apparaissent dans les listes Pacman et AUR.'
fi
fi
echo '[*] Recherche de fichiers sensibles'
while IFS= read -r file; do
fail "Fichier potentiellement sensible : $file"
done < <(
find "$REPO_DIR" \
-type f \
\( \
-name '*.key' \
-o -name '*.pem' \
-o -name '*.p12' \
-o -name '.netrc' \
-o -name '*.sqlite' \
-o -name '*.sqlite3' \
-o -name '*.db' \
\) \
-not -path "$REPO_DIR/.git/*"
)
echo '[*] Vérification de la séparation des dépôts'
for forbidden in \
"$REPO_DIR/userfs" \
"$REPO_DIR/inventory/packages" \
"$REPO_DIR/rootfs/etc/sudoers.d"
do
[[ ! -e "$forbidden" ]] ||
fail "Répertoire interdit dans arch-system : $forbidden"
for file in "${python_files[@]}"; do
[[ -f "$file" && -r "$file" ]] || { status=1; continue; }
python3 - "$file" <<'PY' || status=1
import ast,sys
try: ast.parse(open(sys.argv[1],encoding='utf-8').read(),filename=sys.argv[1])
except (SyntaxError,OSError) as exc: print(exc,file=sys.stderr); sys.exit(1)
PY
done
echo '[*] Vérification des templates'
grep -q '@ROOT_UUID@' \
"$REPO_DIR/templates/fstab.btrfs" ||
fail 'Placeholder @ROOT_UUID@ absent de fstab.btrfs.'
grep -q '@EFI_UUID@' \
"$REPO_DIR/templates/fstab.btrfs" ||
fail 'Placeholder @EFI_UUID@ absent de fstab.btrfs.'
grep -q '@ROOT_UUID@' \
"$REPO_DIR/templates/limine.conf" ||
fail 'Placeholder @ROOT_UUID@ absent de limine.conf.'
grep -q '@USER@' \
"$REPO_DIR/templates/sudoers.d/batlimit" ||
fail 'Placeholder @USER@ absent du sudoers batlimit.'
grep -q '@USER@' \
"$REPO_DIR/templates/sudoers.d/vmware-stack" ||
fail 'Placeholder @USER@ absent du sudoers VMware.'
if git -C "$REPO_DIR" \
rev-parse --is-inside-work-tree >/dev/null 2>&1
then
echo '[*] Contrôle du diff Git'
git -C "$REPO_DIR" diff --check || status=1
fi
python3 "$ROOT/scripts/check-repo-data.py" "$ROOT" || status=1
git -C "$ROOT" diff --no-ext-diff --check || status=1
git -C "$ROOT" diff --cached --check || status=1
if (( status == 0 )); then
echo '[+] Dépôt valide.'
echo '[+] Syntaxe et données du dépôt contrôlées ; aucun système installé ni démarré.'
else
echo '[!] Le dépôt contient encore des erreurs.' >&2
echo '[!] Contrôle du dépôt échoué.' >&2
fi
exit "$status"

354
scripts/prepare-disk.py Executable file
View file

@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""Conservative, explicit disk preparation for an inspected Arch live environment.
CONTRACT: no implicit target or safety bypass. JSON inspection failures forbid erasure.
INVARIANT: every destructive command is checked before the next one runs.
"""
import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import sys
MOUNT = Path('/mnt')
SUBVOLS = ('@', '@home', '@log', '@pkg')
OPTS = 'rw,relatime,ssd,discard=async,space_cache=v2'
STEPS = []
MOUNTS = []
CURRENT = 'préconditions'
class Stop(Exception):
pass
class Interrupted(Stop):
def __init__(self, number):
super().__init__(f'signal {number}')
self.code = 128 + number
def command(args, *, input_text=None):
try:
p = subprocess.run(args, input=input_text, text=True, capture_output=True, check=False)
except OSError as exc:
raise Stop(f"commande indisponible {args[0]} : {exc}") from exc
if p.returncode:
raise Stop(f"{args[0]} a échoué (code {p.returncode}) : {p.stderr.strip()[:200]}")
return p.stdout
def structured(args, key):
try:
data = json.loads(command(args))
except (ValueError, TypeError) as exc:
raise Stop(f"réponse structurée invalide de {args[0]}") from exc
if not isinstance(data, dict) or not isinstance(data.get(key), list):
raise Stop(f"réponse structurée incomplète de {args[0]}")
return data[key]
def disk_tree(path):
rows = structured(['lsblk', '--json', '--tree', '--bytes', '--output',
'PATH,TYPE,MAJ:MIN,SIZE,MODEL,SERIAL,WWN,MOUNTPOINTS,PKNAME,PARTN', path], 'blockdevices')
if len(rows) != 1 or not isinstance(rows[0], dict):
raise Stop('identité du disque ambiguë')
return rows[0]
def descendants(node):
if not isinstance(node, dict) or not re.fullmatch(r'\d+:\d+', str(node.get('maj:min', ''))):
raise Stop('topologie bloc incomplète')
yield node
children = node.get('children', [])
if not isinstance(children, list):
raise Stop('descendants invalides')
for child in children:
yield from descendants(child)
def mounts():
rows = structured(['findmnt', '--json', '--output', 'TARGET,SOURCE,MAJ:MIN,FSTYPE,OPTIONS,ID'], 'filesystems')
def flatten(items):
for item in items:
if not isinstance(item, dict) or not isinstance(item.get('target'), str):
raise Stop('montage illisible')
yield item
children = item.get('children', [])
if not isinstance(children, list):
raise Stop('arbre de montage invalide')
yield from flatten(children)
return list(flatten(rows))
def containing_mount(path, rows):
path = os.path.realpath(path)
matches = [m for m in rows if path == m['target'] or path.startswith(m['target'].rstrip('/') + '/')]
if not matches:
raise Stop(f'montage porteur inconnu : {path}')
return max(matches, key=lambda m: len(m['target']))
def target_identity(path):
# readlink -e rejects a missing path and resolves stable /dev/disk/by-id aliases.
return command(['readlink', '-e', '--', path]).strip()
def device_source(value):
# findmnt prints Btrfs subvolumes as /dev/name[/@]; the bracketed suffix
# describes the mount, not a different block device.
return os.path.realpath(str(value).split('[', 1)[0])
def mounted_subvolume(options):
match = re.search(r'(?:^|,)subvol=/?([^,]+)(?:,|$)', str(options))
return match.group(1) if match else None
def overlay_backing_safe(root_mount, all_mounts, target_ids, boot_source):
# A visible boot medium alone is insufficient: the overlay may use another
# disk for a lower or upper layer. Resolve every declared backing path.
options = {}
for field in str(root_mount.get('options', '')).split(','):
if '=' in field:
key, value = field.split('=', 1)
options[key] = value
if not options.get('lowerdir') or not options.get('upperdir') or not options.get('workdir'):
return False
paths = options['lowerdir'].split(':') + [options['upperdir'], options['workdir']]
for path in paths:
if not path.startswith('/run/archiso/') or not Path(path).exists():
return False
carrier = containing_mount(path, all_mounts)
if carrier['target'] == '/' or carrier.get('maj:min') in target_ids:
return False
if carrier.get('fstype') == 'tmpfs':
continue
if carrier['target'] == '/run/archiso/bootmnt' and device_source(carrier.get('source', '')) == boot_source:
continue
source = device_source(carrier.get('source', ''))
if not source.startswith('/dev/loop'):
return False
backing_file = Path('/sys/block') / Path(source).name / 'loop/backing_file'
try:
backing = backing_file.read_text(encoding='utf-8').strip()
except OSError:
return False
if not backing.startswith('/run/archiso/bootmnt/'):
return False
if containing_mount(backing, all_mounts)['target'] != '/run/archiso/bootmnt':
return False
return True
def inspect(path, *, application):
resolved = target_identity(path)
if not resolved.startswith('/dev/') or '\n' in resolved:
raise Stop('cible hors /dev ou chemin ambigu')
tree = disk_tree(resolved)
if tree.get('type') != 'disk' or tree.get('path') != resolved:
raise Stop('la cible doit être un disque entier pris en charge')
majmin = str(tree.get('maj:min', ''))
size = tree.get('size')
if not re.fullmatch(r'\d+:\d+', majmin) or not isinstance(size, int) or size < 2 * 1024**3:
raise Stop('identité ou capacité du disque invalide')
nodes = list(descendants(tree))
ids = {n['maj:min'] for n in nodes}
if len(ids) != len(nodes) or any(n.get('type') not in ('disk', 'part') for n in nodes):
raise Stop('pile bloc non prise en charge')
for n in nodes:
if any(x is not None for x in (n.get('mountpoints') or [])):
raise Stop('disque ou descendant monté selon lsblk')
holders = Path('/sys/dev/block') / n['maj:min'] / 'holders'
try:
if any(holders.iterdir()):
raise Stop(f'holder actif : {n["maj:min"]}')
except OSError as exc:
raise Stop(f'holders illisibles : {exc}') from exc
all_mounts = mounts()
for m in all_mounts:
if m.get('maj:min') in ids:
raise Stop('disque ou descendant monté selon findmnt')
# An Arch ISO overlay is acceptable only when its physical boot medium is
# visible as a separate block mount; all visible mounts are compared above.
# Other overlays and multi-device Btrfs remain ambiguous and fail closed.
boot = [m for m in all_mounts if m['target'] == '/run/archiso/bootmnt']
live_overlay_mapped = False
if len(boot) == 1 and str(boot[0].get('source', '')).startswith('/dev/'):
boot_device = disk_tree(str(boot[0]['source']))
live_overlay_mapped = (boot_device.get('type') in ('disk', 'part') and
boot_device.get('maj:min') == boot[0].get('maj:min') and
boot_device.get('maj:min') not in ids and
boot_device.get('pkname') != Path(resolved).name)
if live_overlay_mapped:
root_mount = containing_mount('/', all_mounts)
if root_mount.get('fstype') == 'overlay':
live_overlay_mapped = overlay_backing_safe(root_mount, all_mounts, ids,
device_source(boot[0]['source']))
for protected in ('/', str(Path(__file__).resolve().parent.parent), '/run/archiso'):
m = containing_mount(protected, all_mounts)
if m.get('fstype') == 'overlay' and m['target'] == '/' and live_overlay_mapped:
continue
if m.get('fstype') in ('overlay', 'btrfs') and m.get('maj:min') not in ids:
if application:
raise Stop(f'stockage protégé non attribuable sans ambiguïté : {protected}')
print(f'[!] Simulation : provenance non établie pour {protected}; application interdite sans résolution.', file=sys.stderr)
if m.get('maj:min') in ids:
raise Stop(f'disque utilisé par {protected}')
swaps = command(['swapon', '--show=NAME', '--noheadings', '--raw'])
for line in swaps.splitlines():
if not line or not line.startswith('/'):
raise Stop('entrée swap ambiguë')
real = os.path.realpath(line)
if real.startswith('/dev/'):
root = disk_tree(real)
if root['maj:min'] in ids:
raise Stop('swap actif sur la cible')
# Other block stacks are not interpreted as evidence that target is free.
if root.get('type') not in ('disk', 'part'):
raise Stop('swap sur pile bloc ambiguë')
elif containing_mount(real, all_mounts).get('maj:min') in ids:
raise Stop('swapfile actif sur la cible')
try:
if MOUNT.is_symlink() or not MOUNT.is_dir() or any(MOUNT.iterdir()):
raise Stop('/mnt lié, absent ou non vide')
except OSError as exc:
raise Stop(f'/mnt illisible : {exc}') from exc
if any(m['target'] == '/mnt' or m['target'].startswith('/mnt/') for m in all_mounts):
raise Stop('/mnt ou descendant déjà monté')
if application:
if os.geteuid() != 0 or not Path('/sys/firmware/efi').is_dir() or not Path('/run/archiso').is_dir():
raise Stop('application réservée au live Arch UEFI avec privilèges root')
return resolved, majmin, size, tree
def step(label, args):
global CURRENT
CURRENT = label
print(f'[étape] {label}: {" ".join(args)}', flush=True)
command(args)
STEPS.append(label)
def mount(label, args, path, source):
step(label, args)
found = [m for m in mounts() if m['target'] == path]
if len(found) != 1 or device_source(found[0].get('source', '')) != source:
raise Stop(f'effet du montage non établi : {path}')
mount_id = found[0].get('id')
if not isinstance(mount_id, int) or mount_id <= 0:
raise Stop(f'identité du montage indisponible : {path}')
if '-o' in args and mounted_subvolume(found[0].get('options', '')) != args[2].split('subvol=', 1)[-1]:
raise Stop(f'option de sous-volume non établie : {path}')
# Record only the expected mount made by this invocation; never unmount
# an object that another actor replaced at the same path.
MOUNTS.append((path, source, mount_id))
def cleanup():
remaining = []
for path, source, mount_id in reversed(MOUNTS):
try:
found = [m for m in mounts() if m['target'] == path]
if len(found) != 1 or device_source(found[0].get('source', '')) != source or found[0].get('id') != mount_id:
remaining.append(path)
print(f'[!] montage à examiner, non démonté : {path}', file=sys.stderr)
continue
command(['umount', '--', path])
except Stop as exc:
remaining.append(path)
print(f'[!] démontage ciblé échoué : {path}: {exc}', file=sys.stderr)
return remaining
def main():
parser = argparse.ArgumentParser(description='Préparation destructive GPT/ESP/Btrfs ; application réservée au live Arch UEFI.')
parser.add_argument('--dry-run', action='store_true', help='plan et inspections lisibles, aucun effet ni confirmation')
parser.add_argument('disk', help='disque entier explicite, jamais une partition')
args = parser.parse_args()
resolved, majmin, size, tree = inspect(args.disk, application=not args.dry_run)
print(f'Cible : {resolved} ; identité {majmin} ; taille {size} octets ; modèle {tree.get("model") or "inconnu"} ; identifiant stable {tree.get("wwn") or tree.get("serial") or "indisponible"}', flush=True)
print('Plan : effacer signatures, GPT, ESP FAT32 1 Gio /boot, Btrfs @ @home @log @pkg @/.snapshots sous /mnt.', flush=True)
if args.dry_run:
print('Simulation : aucun privilège, confirmation, verrou, montage ou effacement demandé. Les propriétés non lisibles ne sont pas validées.', flush=True)
return 0
for tool in ('wipefs','parted','partprobe','udevadm','mkfs.fat','mkfs.btrfs','btrfs','mount','umount','mkdir'):
if shutil.which(tool) is None:
raise Stop(f'dépendance absente : {tool}')
lockpath = f'/run/lock/arch-system-prepare-{majmin.replace(":", "_")}.lock'
try:
fd = os.open(lockpath, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
lock = os.fdopen(fd, 'r+', encoding='ascii')
except OSError as exc:
raise Stop(f'verrou inaccessible : {exc}') from exc
with lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise Stop('autre invocation coopérante sur cette cible') from exc
answer = input(f'Taper exactement ERASE {resolved} {majmin} : ')
if answer != f'ERASE {resolved} {majmin}':
raise Stop('confirmation refusée')
again = inspect(args.disk, application=True)
if again[:3] != (resolved, majmin, size):
raise Stop('identité changée avant effacement')
try:
step('effacement des signatures', ['wipefs','-af','--',resolved])
step('GPT', ['parted','-s',resolved,'mklabel','gpt'])
step('ESP', ['parted','-s',resolved,'mkpart','ESP','fat32','1MiB','1025MiB'])
step('drapeau ESP', ['parted','-s',resolved,'set','1','esp','on'])
step('partition Btrfs', ['parted','-s',resolved,'mkpart','ARCH','btrfs','1025MiB','100%'])
step('partprobe', ['partprobe',resolved])
step('udev', ['udevadm','settle'])
rows = list(descendants(disk_tree(resolved)))
parts = [r for r in rows if r.get('type') == 'part']
if len(parts) != 2 or {str(p.get('partn')) for p in parts} != {'1', '2'} or any(not isinstance(p.get('path'), str) or not p['path'].startswith('/dev/') or p.get('pkname') != Path(resolved).name for p in parts):
raise Stop('partitions attendues non établies')
efi = next(p['path'] for p in parts if str(p['partn']) == '1')
root = next(p['path'] for p in parts if str(p['partn']) == '2')
if efi == root or any(p.get('maj:min') == majmin for p in parts):
raise Stop('identité des partitions invalide')
step('FAT32', ['mkfs.fat','-F','32','-n','EFI',efi])
step('Btrfs', ['mkfs.btrfs','-f','-L','ARCH',root])
mount('montage top-level', ['mount',root,str(MOUNT)], str(MOUNT), root)
for sub in SUBVOLS:
step('sous-volume '+sub, ['btrfs','subvolume','create',str(MOUNT / sub)])
# Create the nested snapshot subvolume inside @, before mounting @.
step('sous-volume @/.snapshots', ['btrfs','subvolume','create',str(MOUNT / '@' / '.snapshots')])
step('démontage top-level', ['umount','--',str(MOUNT)])
if any(m['target'] == str(MOUNT) for m in mounts()):
raise Stop('démontage top-level non établi')
MOUNTS.clear()
mount('montage racine', ['mount','-o',OPTS+',subvol=@',root,str(MOUNT)], str(MOUNT), root)
for rel in ('boot','home','var/log','var/cache/pacman/pkg'):
step('point de montage '+rel, ['mkdir','-p','--',str(MOUNT / rel)])
for rel, sub in (('home','@home'),('var/log','@log'),('var/cache/pacman/pkg','@pkg'),('.snapshots','@/.snapshots')):
mount('montage '+rel, ['mount','-o',OPTS+',subvol='+sub,root,str(MOUNT / rel)], str(MOUNT / rel), root)
mount('montage ESP', ['mount',efi,str(MOUNT / 'boot')], str(MOUNT / 'boot'), efi)
final = mounts()
expected = {str(MOUNT):root, str(MOUNT/'boot'):efi, **{str(MOUNT/rel):root for rel in ('home','var/log','var/cache/pacman/pkg','.snapshots')}}
for path, source in expected.items():
found = [m for m in final if m['target'] == path]
if len(found) != 1 or device_source(found[0].get('source','')) != source:
raise Stop('schéma final non vérifié : '+path)
expected_subvol = {'/mnt':'@','/mnt/home':'@home','/mnt/var/log':'@log',
'/mnt/var/cache/pacman/pkg':'@pkg','/mnt/.snapshots':'@/.snapshots'}.get(path)
if expected_subvol and mounted_subvolume(found[0].get('options','')) != expected_subvol:
raise Stop('sous-volume final non vérifié : '+path)
print('[+] Disque préparé et montages vérifiés sous /mnt ; Arch non installé, démarrage non testé.', flush=True)
return 0
except Stop:
raise
def entry():
try:
return main()
except (Stop, EOFError, KeyboardInterrupt) as exc:
print(f'[!] Arrêt : {exc or "entrée interrompue"}; étape à vérifier : {CURRENT}', file=sys.stderr)
print('[!] Étapes accomplies : '+(', '.join(STEPS) or 'aucune'), file=sys.stderr)
if MOUNTS:
cleanup()
try:
remaining = [m['target'] for m in mounts() if m['target'] == '/mnt' or m['target'].startswith('/mnt/')]
print('[!] Montages restants à examiner : '+(', '.join(remaining) or 'aucun'), file=sys.stderr)
except Stop as read_error:
print(f'[!] Montages restants non établis : {read_error}', file=sys.stderr)
print('[!] Ne pas relancer sans inspection du disque et des effets de la dernière étape.', file=sys.stderr)
return exc.code if isinstance(exc, Interrupted) else (130 if isinstance(exc, KeyboardInterrupt) else 1)
def handle_term(number, frame):
# Keep the signal's conventional exit code while reporting any mounts
# that still need human inspection after a partially completed operation.
raise Interrupted(number)
if __name__ == '__main__':
signal.signal(signal.SIGTERM, handle_term)
sys.exit(entry())

View file

@ -1,70 +1,5 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if (( EUID != 0 )); then
echo "Lance ce script avec sudo." >&2
exit 1
fi
if (( $# != 1 )); then
printf 'Usage : %s /dev/nvmeXnY\n' "$0" >&2
exit 2
fi
readonly DISK="$(readlink -f "$1")"
readonly MOUNT_ROOT="/mnt"
[[ -b "$DISK" ]] || { echo "Périphérique invalide : $DISK" >&2; exit 1; }
if findmnt -rn "$MOUNT_ROOT" >/dev/null 2>&1; then
echo "$MOUNT_ROOT est déjà monté. Démonte-le avant de continuer." >&2
exit 1
fi
if lsblk -nr -o MOUNTPOINTS "$DISK" | grep -q '/'; then
echo "Le disque ou une de ses partitions est monté. Abandon." >&2
lsblk -o NAME,MODEL,SIZE,FSTYPE,MOUNTPOINTS "$DISK"
exit 1
fi
lsblk -o NAME,MODEL,SIZE,TRAN,FSTYPE,MOUNTPOINTS "$DISK"
printf '\nCette opération efface entièrement %s.\n' "$DISK"
read -rp "Tape exactement ERASE $DISK : " confirmation
[[ "$confirmation" == "ERASE $DISK" ]] || { echo "Annulé."; exit 0; }
part_suffix=""
[[ "$DISK" =~ [0-9]$ ]] && part_suffix="p"
readonly EFI_PART="${DISK}${part_suffix}1"
readonly ROOT_PART="${DISK}${part_suffix}2"
wipefs -af "$DISK"
parted -s "$DISK" mklabel gpt
parted -s "$DISK" mkpart ESP fat32 1MiB 1025MiB
parted -s "$DISK" set 1 esp on
parted -s "$DISK" mkpart ARCH btrfs 1025MiB 100%
partprobe "$DISK"
udevadm settle
mkfs.fat -F 32 -n EFI "$EFI_PART"
mkfs.btrfs -f -L ARCH "$ROOT_PART"
mount "$ROOT_PART" "$MOUNT_ROOT"
btrfs subvolume create "$MOUNT_ROOT/@"
btrfs subvolume create "$MOUNT_ROOT/@home"
btrfs subvolume create "$MOUNT_ROOT/@log"
btrfs subvolume create "$MOUNT_ROOT/@pkg"
umount "$MOUNT_ROOT"
readonly OPTS="rw,relatime,ssd,discard=async,space_cache=v2"
mount -o "$OPTS,subvol=@" "$ROOT_PART" "$MOUNT_ROOT"
mkdir -p "$MOUNT_ROOT"/{boot,home,var/log,var/cache/pacman/pkg,.snapshots}
btrfs subvolume create "$MOUNT_ROOT/.snapshots"
mount -o "$OPTS,subvol=@home" "$ROOT_PART" "$MOUNT_ROOT/home"
mount -o "$OPTS,subvol=@log" "$ROOT_PART" "$MOUNT_ROOT/var/log"
mount -o "$OPTS,subvol=@pkg" "$ROOT_PART" "$MOUNT_ROOT/var/cache/pacman/pkg"
mount -o "$OPTS,subvol=@/.snapshots" "$ROOT_PART" "$MOUNT_ROOT/.snapshots"
mount "$EFI_PART" "$MOUNT_ROOT/boot"
printf '\n[+] Disque préparé et monté sous %s\n' "$MOUNT_ROOT"
lsblk -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINTS "$DISK"
# WHY: keep the public entry point while the topology checks parse structured data.
# CONTRACT: Python must be available on the live medium; no fallback to unchecked shell parsing.
set -euo pipefail
exec python3 "$(dirname "${BASH_SOURCE[0]}")/prepare-disk.py" "$@"

22
tests/audit/mock-command.sh Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
name="${0##*/}"
printf '%s %s\n' "$name" "$*" >> /tmp/calls
[[ "${FAIL:-}" != "$name" ]] || exit 47
case "$name" in
sudo) [[ "$*" == '-n btrfs filesystem usage /' ]] || exit 90; printf 'fixture btrfs\n' ;;
systemctl)
if [[ "$*" == *'is-enabled'* ]]; then
[[ "${FAIL:-}" != service ]] || exit 47
printf 'disabled\n'; exit 1
elif [[ "$*" == *'is-active'* ]]; then
[[ "${FAIL:-}" != service ]] || exit 47
printf 'inactive\n'; exit 3
else printf 'fixture no failed units\n'; fi ;;
pgrep) [[ "$*" == '-a -f nextcloud|thunderbird|protonmail-bridge|/protonmail/bridge|proton-pass|ollama|llama-server' ]] || exit 90
[[ "${FAIL:-}" != pgrep_error ]] || exit 2
exit 1 ;;
cat) case "$1" in /proc/pressure/*) printf 'fixture pressure\n';; /sys/class/power_supply/*) printf '60\n';; *) exit 90;; esac ;;
uptime|free|swapon|df|ps|systemd-analyze) printf 'fixture %s\n' "$name" ;;
*) exit 90 ;;
esac

43
tests/audit/test.sh Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
command -v bwrap >/dev/null || { echo 'bwrap requis' >&2; exit 1; }
BASE="$(mktemp -d)"; trap 'rm -rf -- "$BASE"' EXIT
mkdir -p "$BASE"/{bin,tmp,proc/pressure,sys/class/power_supply/BAT1,repo/scripts}
for p in cpu memory io; do printf 'fixture\n' > "$BASE/proc/pressure/$p"; done
printf '60\n' > "$BASE/sys/class/power_supply/BAT1/charge_control_end_threshold"
cp "$ROOT/scripts/audit.sh" "$BASE/repo/scripts/audit.sh"
cp "$ROOT/tests/audit/mock-command.sh" "$BASE/mock-command.sh"
for cmd in uptime free swapon cat df sudo ps systemctl pgrep systemd-analyze; do ln -s /opt/mock-command.sh "$BASE/bin/$cmd"; done
ln -s /usr/bin/bash "$BASE/bin/bash"
ln -s /usr/bin/sed "$BASE/bin/sed"
run_case() {
local label="$1"; shift
: > "$BASE/tmp/calls"
set +e
bwrap --unshare-user --unshare-pid --unshare-net --tmpfs / \
--ro-bind /usr /usr --symlink usr/lib /lib --symlink usr/lib /lib64 \
--symlink usr/bin /bin --tmpfs /opt \
--ro-bind "$BASE/repo" /opt/repo --ro-bind "$BASE/bin" /opt/bin \
--ro-bind "$BASE/mock-command.sh" /opt/mock-command.sh \
--bind "$BASE/tmp" /tmp --bind "$BASE/proc" /proc --bind "$BASE/sys" /sys --dev /dev \
--setenv PATH /opt/bin --setenv FAIL "${FAIL:-}" \
-- /opt/repo/scripts/audit.sh > "$BASE/out" 2>&1
rc=$?
set -e
printf '[test] %s : rc=%s\n' "$label" "$rc"
}
run_case nominal; [[ "$rc" == 0 ]] || { cat "$BASE/out"; exit 1; }
grep -q 'Aucun processus correspondant observé' "$BASE/out"
grep -q 'enabled=disabled' "$BASE/out"
for scenario in pgrep_error service sudo ps; do
FAIL="$scenario" run_case "$scenario"
[[ "$rc" == 1 ]] || { cat "$BASE/out"; exit 1; }
case "$scenario" in
pgrep_error) grep -q 'Recherche de processus échouée' "$BASE/out"; ! grep -q 'Aucun processus correspondant observé' "$BASE/out" ;;
service) grep -q 'ERREUR(47)' "$BASE/out" ;;
sudo) grep -q 'Lecture échouée : sudo' "$BASE/out" ;;
ps) grep -q 'Lecture échouée : ps' "$BASE/out" ;;
esac
done
printf 'Résultat : 5 scénarios confinés de diagnostic validés.\n'

View file

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Strict synthetic command boundary; only the private /tmp fixture is writable."""
import json
import os
from pathlib import Path
import signal
import sys
name = Path(sys.argv[0]).name
args = sys.argv[1:]
root = Path('/tmp/fixture')
state_path = root / 'state.json'
state = json.loads(state_path.read_text())
fail = os.environ.get('CASE_FAIL', '')
nvme = os.environ.get('CASE_STYLE', '') == 'nvme'
disk_path = '/dev/nvme9n9' if nvme else '/dev/target'
efi_path = disk_path + ('p1' if nvme else '1')
root_path = disk_path + ('p2' if nvme else '2')
with (root / 'calls').open('a') as stream:
stream.write(name + ' ' + ' '.join(args) + '\n')
def save():
state_path.write_text(json.dumps(state))
def die(message):
print(message, file=sys.stderr)
sys.exit(47)
if fail == name or fail == name + ':inspection' and name in ('lsblk', 'findmnt', 'swapon'):
die('injected failure')
if name == 'readlink':
if args[:2] != ['-e','--'] or len(args) != 3 or args[2] not in (disk_path,'/dev/alias',efi_path):
die('unexpected readlink')
print(efi_path if args[2] == efi_path else disk_path)
elif name == 'lsblk':
if args[:5] != ['--json','--tree','--bytes','--output','PATH,TYPE,MAJ:MIN,SIZE,MODEL,SERIAL,WWN,MOUNTPOINTS,PKNAME,PARTN'] or len(args) != 6:
die('unexpected lsblk args')
target = args[5]
state['inspections'] = state.get('inspections', 0) + (target == disk_path)
save()
disk = {'path':disk_path,'type':'disk','maj:min':'8:16','size':100*1024**3,'model':'SYNTHETIC','serial':'TEST-NOT-REAL','wwn':None,'mountpoints':[None]}
if fail == 'identity_change' and state['inspections'] >= 2:
disk['maj:min'] = '8:20'
if state['partitioned']:
disk['children'] = [
{'path':efi_path,'type':'part','maj:min':'8:17','size':1024**3,'partn':1,'pkname':Path(disk_path).name,'mountpoints':[None]},
{'path':root_path,'type':'part','maj:min':'8:18','size':99*1024**3,'partn':2,'pkname':Path(disk_path).name,'mountpoints':[None]},
]
if fail == 'bad_partitions': disk['children'][1]['partn'] = 3
if fail == 'mounted_child': disk['children'] = [{'path':'/dev/target1','type':'part','maj:min':'8:17','size':1024**3,'mountpoints':['/work']}]
if fail == 'wrong_type': disk['type'] = 'rom'
if target == efi_path: disk = {'path':efi_path,'type':'part','maj:min':'8:17','size':1024**3,'mountpoints':[None]}
if target == '/dev/live': disk = {'path':'/dev/live','type':'part','maj:min':'8:99','size':8*1024**3,'pkname':'live','mountpoints':['/run/archiso/bootmnt']}
if target not in (disk_path,efi_path,'/dev/live'): die('unexpected lsblk target')
print(json.dumps({'blockdevices':[disk]}))
elif name == 'findmnt':
if args != ['--json','--output','TARGET,SOURCE,MAJ:MIN,FSTYPE,OPTIONS,ID']:
die('unexpected findmnt')
entries = [{'target':'/','source':'/dev/live','maj:min':'8:99','fstype':'ext4','options':'rw'}, {'target':'/run/archiso','source':'/dev/live','maj:min':'8:99','fstype':'ext4','options':'rw'}]
if fail in ('overlay_live','overlay_unmapped'):
entries[0].update({'source':'overlay','maj:min':'0:99','fstype':'overlay',
'options':'rw,lowerdir=/run/archiso/bootmnt/airootfs,upperdir=/run/archiso/cowspace/upper,workdir=/run/archiso/cowspace/work'})
if fail == 'overlay_live':
entries.append({'target':'/run/archiso/bootmnt','source':'/dev/live','maj:min':'8:99','fstype':'iso9660','options':'ro'})
entries.append({'target':'/run/archiso/cowspace','source':'tmpfs','maj:min':'0:98','fstype':'tmpfs','options':'rw'})
if fail == 'source_target': entries[0]['maj:min'] = '8:16'
if fail == 'mounted_disk': entries.append({'target':'/work','source':'/dev/target1','maj:min':'8:17','fstype':'ext4'})
if fail == 'mnt_occupied': entries.append({'target':'/mnt/other','source':'/dev/live','maj:min':'8:99','fstype':'ext4'})
if fail == 'swapfile': entries.append({'target':'/work','source':'/dev/target2','maj:min':'8:18','fstype':'ext4'})
entries += state['mounts']
print(json.dumps({'filesystems':entries}))
elif name == 'swapon':
if args != ['--show=NAME','--noheadings','--raw']: die('unexpected swapon')
if fail == 'swap_partition': print(efi_path)
elif fail == 'swapfile': print('/work/swapfile')
elif name in ('wipefs','parted','partprobe','udevadm','mkfs.fat','mkfs.btrfs','btrfs','mount','umount'):
if fail == name or fail == name + ':mutate': die('injected mutation failure')
if name == 'wipefs' and args != ['-af','--',disk_path]: die('unexpected wipefs')
if name == 'parted':
allowed = (
['-s',disk_path,'mklabel','gpt'],
['-s',disk_path,'mkpart','ESP','fat32','1MiB','1025MiB'],
['-s',disk_path,'set','1','esp','on'],
['-s',disk_path,'mkpart','ARCH','btrfs','1025MiB','100%'],
)
if args not in allowed: die('unexpected parted')
if args == ['-s',disk_path,'mkpart','ARCH','btrfs','1025MiB','100%']:
state['partitioned'] = True; save()
if name == 'partprobe' and args != [disk_path]: die('unexpected partprobe')
if name == 'partprobe' and fail in ('signal_INT','signal_TERM'):
os.kill(os.getppid(), signal.SIGINT if fail == 'signal_INT' else signal.SIGTERM)
sys.exit(130 if fail == 'signal_INT' else 143)
if name == 'udevadm' and args != ['settle']: die('unexpected udevadm')
if name == 'mkfs.fat' and args != ['-F','32','-n','EFI',efi_path]: die('unexpected mkfs.fat')
if name == 'mkfs.btrfs' and args != ['-f','-L','ARCH',root_path]: die('unexpected mkfs.btrfs')
if name == 'btrfs':
if fail == 'snapshot_conflict' and args[-1] == '/mnt/@/.snapshots': Path(args[-1]).mkdir(exist_ok=True)
allowed = {'/mnt/@','/mnt/@home','/mnt/@log','/mnt/@pkg','/mnt/@/.snapshots'}
if args[:2] != ['subvolume','create'] or len(args) != 3 or args[2] not in allowed or Path(args[2]).exists():
die('subvolume destination already exists or invalid')
Path(args[2]).mkdir()
if name == 'mount':
if fail in ('mount:third','mount_cleanup_fail','mount_replaced') and len(state['mounts']) == 1 and state.get('mount_count', 0) >= 2:
if fail == 'mount_replaced':
state['mounts'][0]['id'] += 500
save()
die('injected mount failure')
if args[0] == '-o':
expected_options = {'rw,relatime,ssd,discard=async,space_cache=v2,subvol='+v for v in ('@','@home','@log','@pkg','@/.snapshots')}
if len(args) != 4 or args[1] not in expected_options:
die('unexpected mount options')
source, target = args[2:]
options = args[1]
else:
if len(args) != 2: die('unexpected mount')
source, target = args
options = 'rw'
allowed_mounts = {('/mnt',root_path),('/mnt/home',root_path),('/mnt/var/log',root_path),
('/mnt/var/cache/pacman/pkg',root_path),('/mnt/.snapshots',root_path),
('/mnt/boot',efi_path)}
if (target,source) not in allowed_mounts:
die('unexpected mount source/target')
if fail == 'canonical_subvol':
options = options.replace('subvol=', 'subvol=/')
state['mount_count'] = state.get('mount_count', 0) + 1
state['mounts'].append({'target':target,'source':source,'maj:min':'8:17' if source.endswith('1') else '8:18','fstype':'btrfs' if source.endswith('2') else 'vfat','options':options,'id':100+state['mount_count']})
save()
if name == 'umount':
allowed_targets = {'/mnt','/mnt/home','/mnt/var/log','/mnt/var/cache/pacman/pkg','/mnt/.snapshots','/mnt/boot'}
if len(args) != 2 or args[0] != '--' or args[1] not in allowed_targets: die('unexpected umount')
if fail == 'mount_cleanup_fail' and args[1] == '/mnt' and state.get('mount_count', 0) >= 2:
die('injected cleanup failure')
state['mounts'] = [m for m in state['mounts'] if m['target'] != args[1]]
save()
else:
die('unknown command')

132
tests/prepare-disk/test.sh Executable file
View file

@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Copies and strict mocks only: no host block device or privileged command is touched.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
command -v bwrap >/dev/null || { echo 'bwrap requis' >&2; exit 1; }
BASE="$(mktemp -d)"
trap 'rm -rf -- "$BASE"' EXIT
passes=0
fail() { printf 'ÉCHEC : %s\n' "$*" >&2; exit 1; }
new_case() {
case_dir="$(mktemp -d "$BASE/case.XXXXXXXX")"
mkdir -p "$case_dir"/{repo/scripts,bin,mnt,tmp/fixture,sys/dev/block,sys/firmware/efi,run/archiso,run/lock}
mkdir -p "$case_dir/run/archiso"/{bootmnt/airootfs,cowspace/upper,cowspace/work}
for mm in 8:16 8:17 8:18 8:20; do mkdir -p "$case_dir/sys/dev/block/$mm/holders"; done
cp "$ROOT/scripts/prepare-disk.sh" "$ROOT/scripts/prepare-disk.py" "$case_dir/repo/scripts/"
cp "$ROOT/tests/prepare-disk/mock-command.py" "$case_dir/mock-command.py"
for cmd in readlink lsblk findmnt swapon wipefs parted partprobe udevadm mkfs.fat mkfs.btrfs btrfs mount umount; do
ln -s /opt/mock-command.py "$case_dir/bin/$cmd"
done
for cmd in bash python3 dirname mkdir; do ln -s "/usr/bin/$cmd" "$case_dir/bin/$cmd"; done
printf '{"partitioned":false,"mounts":[]}' > "$case_dir/tmp/fixture/state.json"
: > "$case_dir/tmp/fixture/calls"
}
run_case() {
local label="$1"; shift
local mnt_args=(--bind "$case_dir/mnt" /mnt)
[[ "${CASE_MNT_LINK:-0}" != 1 ]] || mnt_args=(--symlink /tmp/fixture /mnt)
set +e
printf '%s\n' "${ANSWER:-}" | bwrap --unshare-user --unshare-pid --unshare-net --uid "${TEST_UID:-0}" --gid "${TEST_UID:-0}" \
--tmpfs / --ro-bind /usr /usr --symlink usr/lib /lib --symlink usr/lib /lib64 \
--symlink usr/bin /bin "${mnt_args[@]}" --bind "$case_dir/tmp" /tmp \
--bind "$case_dir/sys" /sys --bind "$case_dir/run" /run --tmpfs /opt \
--ro-bind "$case_dir/repo" /opt/repo --ro-bind "$case_dir/mock-command.py" /opt/mock-command.py \
--ro-bind "$case_dir/bin" /opt/bin --dev /dev --proc /proc \
--setenv PATH /opt/bin --setenv CASE_FAIL "${CASE_FAIL:-}" --setenv CASE_STYLE "${CASE_STYLE:-}" \
-- /opt/repo/scripts/prepare-disk.sh "$@" > "$case_dir/out" 2>&1
rc=$?
set -e
printf '[test] %s : rc=%s\n' "$label" "$rc"
}
no_mutation() { ! grep -Eq '^(wipefs|parted|partprobe|udevadm|mkfs\.fat|mkfs\.btrfs|btrfs|mount|umount) ' "$case_dir/tmp/fixture/calls" || fail 'mutation imprévue'; }
expect() { [[ "$rc" == "$1" ]] || fail "code $rc attendu $1: $(cat "$case_dir/out")"; }
new_case; run_case help --help; expect 0; no_mutation; ((passes+=1))
new_case; TEST_UID=1000 run_case dry --dry-run /dev/target; expect 0; no_mutation; ((passes+=1))
new_case; CASE_FAIL=overlay_live TEST_UID=1000 run_case dry_overlay --dry-run /dev/target
expect 0; no_mutation; ((passes+=1))
for scenario in wrong_type mounted_child mounted_disk source_target swap_partition swapfile mnt_occupied overlay_unmapped lsblk:inspection findmnt:inspection swapon:inspection; do
new_case; CASE_FAIL="$scenario" run_case "$scenario" /dev/target; expect 1; no_mutation; ((passes+=1))
done
new_case; touch "$case_dir/sys/dev/block/8:16/holders/dm-test"
run_case holder /dev/target; expect 1; grep -q 'holder actif' "$case_dir/out" || fail 'holder non diagnostiqué'; no_mutation; ((passes+=1))
new_case; rmdir "$case_dir/sys/dev/block/8:16/holders"
run_case holders_unreadable /dev/target; expect 1; grep -q 'holders illisibles' "$case_dir/out" || fail 'lecture holders ignorée'; no_mutation; ((passes+=1))
new_case; touch "$case_dir/mnt/old-data"
run_case mnt_data /dev/target; expect 1; grep -q '/mnt lié, absent ou non vide' "$case_dir/out" || fail 'données /mnt ignorées'; no_mutation; ((passes+=1))
new_case; CASE_MNT_LINK=1 run_case mnt_link /dev/target; expect 1
grep -q '/mnt lié, absent ou non vide' "$case_dir/out" || fail 'lien /mnt ignoré'; no_mutation; ((passes+=1))
new_case; rm "$case_dir/bin/parted"; ANSWER='ERASE /dev/target 8:16' run_case missing_dependency /dev/target
expect 1; grep -q 'dépendance absente : parted' "$case_dir/out" || fail 'dépendance non diagnostiquée'; no_mutation; ((passes+=1))
new_case; rm -r "$case_dir/run/archiso"; run_case no_live /dev/target
expect 1; grep -q 'live Arch UEFI' "$case_dir/out" || fail 'contexte live non vérifié'; no_mutation; ((passes+=1))
new_case; run_case partition /dev/target1; expect 1; no_mutation; ((passes+=1))
new_case; CASE_FAIL=source_target run_case alias_source /dev/alias; expect 1
grep -q 'disque ou descendant monté' "$case_dir/out" || fail 'alias source non protégé'; no_mutation; ((passes+=1))
new_case; CASE_FAIL=readlink run_case alias_missing /dev/alias; expect 1; no_mutation; ((passes+=1))
new_case; ANSWER=no run_case refusal /dev/target; expect 1; no_mutation; ((passes+=1))
new_case; run_case eof /dev/target; expect 1; no_mutation; ((passes+=1))
new_case; ANSWER='ERASE /dev/target 8:16' CASE_FAIL=identity_change run_case identity_change /dev/target
expect 1; grep -q 'identité changée' "$case_dir/out" || fail 'changement identité non bloqué'; no_mutation; ((passes+=1))
new_case; ANSWER='ERASE /dev/target 8:16' run_case nominal /dev/alias; expect 0
[[ "$(grep -c '^mount ' "$case_dir/tmp/fixture/calls")" == 7 ]] || fail 'montages incomplets'
[[ -d "$case_dir/mnt/@/.snapshots" ]] || fail 'sous-volume imbriqué absent'
((passes+=1))
new_case; CASE_STYLE=nvme ANSWER='ERASE /dev/nvme9n9 8:16' run_case nominal_nvme /dev/nvme9n9; expect 0
grep -q '^mkfs.fat -F 32 -n EFI /dev/nvme9n9p1$' "$case_dir/tmp/fixture/calls" || fail 'partition NVMe non vérifiée'
((passes+=1))
new_case; CASE_FAIL=overlay_live ANSWER='ERASE /dev/target 8:16' run_case nominal_overlay /dev/target
expect 0; ((passes+=1))
new_case; CASE_FAIL=canonical_subvol ANSWER='ERASE /dev/target 8:16' run_case canonical_subvol /dev/target
expect 0; ((passes+=1))
new_case
exec 9> "$case_dir/run/lock/arch-system-prepare-8_16.lock"
flock -n 9 || fail 'verrou fixture indisponible'
ANSWER='ERASE /dev/target 8:16' run_case concurrent /dev/target; expect 1
grep -q 'autre invocation coopérante' "$case_dir/out" || fail 'concurrence non bloquée'
no_mutation
exec 9>&-
((passes+=1))
new_case; ANSWER='ERASE /dev/target 8:16' CASE_FAIL=bad_partitions run_case bad_partitions /dev/target; expect 1
! grep -q '^mkfs' "$case_dir/tmp/fixture/calls" || fail 'formatage malgré partitions invalides'
((passes+=1))
new_case; ANSWER='ERASE /dev/target 8:16' CASE_FAIL=mkfs.btrfs run_case fail_mkfs /dev/target; expect 1
! grep -q '^mount ' "$case_dir/tmp/fixture/calls" || fail 'montage après formatage échoué'
((passes+=1))
for scenario in wipefs parted partprobe udevadm mkfs.fat btrfs; do
new_case; ANSWER='ERASE /dev/target 8:16' CASE_FAIL="$scenario" run_case "fail_$scenario" /dev/target; expect 1
grep -q "^$scenario " "$case_dir/tmp/fixture/calls" || fail "étape $scenario non atteinte"
grep -q "étape à vérifier" "$case_dir/out" || fail 'bilan d’échec absent'
case "$scenario" in
wipefs) ! grep -q '^parted ' "$case_dir/tmp/fixture/calls" || fail 'parted après wipefs échoué' ;;
parted) [[ "$(grep -c '^parted ' "$case_dir/tmp/fixture/calls")" == 1 ]] || fail 'parted poursuivi après échec' ;;
partprobe) ! grep -q '^udevadm ' "$case_dir/tmp/fixture/calls" || fail 'udev après partprobe échoué' ;;
udevadm) ! grep -q '^mkfs' "$case_dir/tmp/fixture/calls" || fail 'formatage après udev échoué' ;;
mkfs.fat) ! grep -q '^mkfs.btrfs ' "$case_dir/tmp/fixture/calls" || fail 'Btrfs après FAT32 échoué' ;;
btrfs) [[ "$(grep -c '^btrfs ' "$case_dir/tmp/fixture/calls")" == 1 ]] || fail 'sous-volume poursuivi après échec'
grep -q '^umount -- /mnt$' "$case_dir/tmp/fixture/calls" || fail 'montage top-level non nettoyé' ;;
esac
((passes+=1))
done
new_case; ANSWER='ERASE /dev/target 8:16' CASE_FAIL=snapshot_conflict run_case snapshot_conflict /dev/target; expect 1
grep -q 'subvolume destination already exists' "$case_dir/out" || fail 'précondition .snapshots non testée'
! grep -q '^mount -o' "$case_dir/tmp/fixture/calls" || fail 'montage final malgré conflit .snapshots'
((passes+=1))
for scenario in mount:third mount_cleanup_fail mount_replaced; do
new_case; ANSWER='ERASE /dev/target 8:16' CASE_FAIL="$scenario" run_case "$scenario" /dev/target; expect 1
if [[ "$scenario" == mount:third ]]; then
grep -q '^umount -- /mnt$' "$case_dir/tmp/fixture/calls" || fail 'nettoyage ciblé absent'
elif [[ "$scenario" == mount_cleanup_fail ]]; then
grep -q 'Montages restants à examiner : /mnt' "$case_dir/out" || fail 'échec nettoyage non signalé'
else
grep -q 'montage à examiner, non démonté : /mnt' "$case_dir/out" || fail 'montage remplacé démonté'
[[ "$(grep -c '^umount -- /mnt$' "$case_dir/tmp/fixture/calls")" == 1 ]] || fail 'montage remplacé démonté'
fi
((passes+=1))
done
for scenario in signal_INT signal_TERM; do
new_case; ANSWER='ERASE /dev/target 8:16' CASE_FAIL="$scenario" run_case "$scenario" /dev/target
[[ "$rc" == 130 || "$rc" == 143 ]] || fail "code interruption inattendu : $rc"
! grep -q '^mkfs' "$case_dir/tmp/fixture/calls" || fail 'formatage après interruption'
((passes+=1))
done
printf 'Résultat : %s scénarios confinés de préparation validés.\n' "$passes"

16
tests/run.sh Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Point d'entrée unique : les suites de production s'exécutent sur copies bwrap.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
"$ROOT/scripts/check-repo.sh"
for suite in backup deploy prepare-disk audit; do
printf '[suite] %s\n' "$suite"
bash "$ROOT/tests/$suite/test.sh"
done
if command -v shellcheck >/dev/null 2>&1; then
mapfile -d '' scripts < <(find "$ROOT/scripts" "$ROOT/tests" -type f -name '*.sh' -print0)
shellcheck "${scripts[@]}"
else
printf '[info] ShellCheck absent : contrôle non exécuté.\n'
fi
printf '[+] Toutes les suites disponibles ont réussi ; aucun déploiement ni réinstallation réelle.\n'