diff --git a/CHANGELOG.md b/CHANGELOG.md index 963b151..c77b78d 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/README.md b/README.md index d7aee3c..1a8e806 100644 --- a/README.md +++ b/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. diff --git a/docs/audits/2026-10-02-finalisation.md b/docs/audits/2026-10-02-finalisation.md new file mode 100644 index 0000000..c5e9ffb --- /dev/null +++ b/docs/audits/2026-10-02-finalisation.md @@ -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. diff --git a/docs/reconstruction-baseline.md b/docs/reconstruction-baseline.md index 88e80e7..a5a598f 100644 --- a/docs/reconstruction-baseline.md +++ b/docs/reconstruction-baseline.md @@ -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. diff --git a/docs/reinstall.md b/docs/reinstall.md index 50529d6..ff1f649 100644 --- a/docs/reinstall.md +++ b/docs/reinstall.md @@ -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 `. 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). diff --git a/docs/security.md b/docs/security.md index 7d7a8ce..7a53611 100644 --- a/docs/security.md +++ b/docs/security.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). diff --git a/docs/ssd-migration.md b/docs/ssd-migration.md index 6e3cafc..610d5be 100644 --- a/docs/ssd-migration.md +++ b/docs/ssd-migration.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. diff --git a/scripts/audit.sh b/scripts/audit.sh index 4f7c1a8..8b0bdc3 100755 --- a/scripts/audit.sh +++ b/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" diff --git a/scripts/check-repo-data.py b/scripts/check-repo-data.py new file mode 100755 index 0000000..1885c41 --- /dev/null +++ b/scripts/check-repo-data.py @@ -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)) diff --git a/scripts/check-repo.sh b/scripts/check-repo.sh index 85cb65c..ed5faea 100755 --- a/scripts/check-repo.sh +++ b/scripts/check-repo.sh @@ -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" diff --git a/scripts/prepare-disk.py b/scripts/prepare-disk.py new file mode 100755 index 0000000..aebc99d --- /dev/null +++ b/scripts/prepare-disk.py @@ -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()) diff --git a/scripts/prepare-disk.sh b/scripts/prepare-disk.sh index fe39fe7..f63da8a 100755 --- a/scripts/prepare-disk.sh +++ b/scripts/prepare-disk.sh @@ -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" "$@" diff --git a/tests/audit/mock-command.sh b/tests/audit/mock-command.sh new file mode 100755 index 0000000..80aeed5 --- /dev/null +++ b/tests/audit/mock-command.sh @@ -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 diff --git a/tests/audit/test.sh b/tests/audit/test.sh new file mode 100755 index 0000000..fd57729 --- /dev/null +++ b/tests/audit/test.sh @@ -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' diff --git a/tests/prepare-disk/mock-command.py b/tests/prepare-disk/mock-command.py new file mode 100755 index 0000000..e9a62ed --- /dev/null +++ b/tests/prepare-disk/mock-command.py @@ -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') diff --git a/tests/prepare-disk/test.sh b/tests/prepare-disk/test.sh new file mode 100755 index 0000000..8db86d6 --- /dev/null +++ b/tests/prepare-disk/test.sh @@ -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" diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..7d26a89 --- /dev/null +++ b/tests/run.sh @@ -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'