feat(reconstruction): finaliser la préparation, les contrôles et les procédures
This commit is contained in:
parent
473b98a17b
commit
6b4bea0ae9
17 changed files with 1243 additions and 370 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -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
122
README.md
|
|
@ -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.
|
||||
|
|
|
|||
38
docs/audits/2026-10-02-finalisation.md
Normal file
38
docs/audits/2026-10-02-finalisation.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
114
scripts/audit.sh
114
scripts/audit.sh
|
|
@ -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
53
scripts/check-repo-data.py
Executable 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))
|
||||
|
|
@ -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
354
scripts/prepare-disk.py
Executable 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())
|
||||
|
|
@ -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
22
tests/audit/mock-command.sh
Executable 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
43
tests/audit/test.sh
Executable 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'
|
||||
136
tests/prepare-disk/mock-command.py
Executable file
136
tests/prepare-disk/mock-command.py
Executable 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
132
tests/prepare-disk/test.sh
Executable 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
16
tests/run.sh
Executable 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'
|
||||
Loading…
Reference in a new issue