diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index a44afb1..b1a9b13 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -79,6 +79,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The third-party image is verified against the sum the repository pins for it on the Proxmox path too, where a bare `wget` used to be enough. One accessor carries that sum for both paths, and the check runs even on a cached image — the case aimed at is a file substituted or truncated between deployments, which a presence test cannot see - One entry of the download cache can be removed, by URL. A checksum that does not match invalidated nothing: the store kept serving the same bytes, and re-downloading changed nothing since the store is what answers. Neither existing purge reaches it — `--purge` erases everything, and `--purge-older-than` skips what is recent while every service resets that date, so a poisoned object that keeps being served never ages. `--oublie` is symmetric to `--detient`: same input lines, same key functions, same refusals, and a present object that resists is reported as a refusal rather than a forget. Both checksum failures now name it, with the exact URL - The install log carries what the host decided before launching: the download cache authority placed or refused, the bypass, the mirror. Those lines were said on a console that scrolls away, while the file reopened after a failure held only the symptom — on a guest with no trust store, a refused certificate, hundreds of derivations to build and six hundred lines of errors, without a word on the cause +- One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified @@ -130,6 +131,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - L'image tierce est vérifiée contre la somme que le dépôt épingle pour elle sur le chemin Proxmox aussi, où un `wget` nu suffisait. Un seul accesseur porte cette somme pour les deux chemins, et le contrôle vaut même pour une image déjà en cache — le cas visé est un fichier substitué ou tronqué entre deux déploiements, qu'un test de présence ne voit pas - Une entrée du cache de téléchargement peut être retirée, par son URL. Une somme qui ne correspond pas n'invalidait rien : le magasin continuait de servir les mêmes octets, et retélécharger ne changeait rien puisque c'est lui qui répond. Aucune des deux purges ne l'atteint — `--purge` efface tout, et `--purge-older-than` saute ce qui est récent alors que chaque service remet cette date, si bien qu'un objet empoisonné qui sert ne vieillit jamais. `--oublie` est le symétrique de `--detient` : mêmes lignes en entrée, mêmes fonctions de clé, mêmes refus, et un objet présent qui résiste est dit refusé plutôt qu'oublié. Les deux échecs de somme le nomment désormais, avec l'URL exacte - Le journal d'installation porte ce que l'hôte a décidé avant de lancer : l'autorité du cache de téléchargement posée ou refusée, l'exception, le miroir. Ces lignes se disaient sur une console qui défile, pendant que le fichier qu'on rouvre après un échec ne portait que le symptôme — sur un invité sans magasin de confiance, un certificat refusé, des centaines de dérivations à construire et six cents lignes d'erreurs, sans un mot sur la cause +- Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier ## Changed @@ -217,6 +219,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The connection guide is displayed on NixOS. Deployment writes `/etc/motd` everywhere and relies on pam_motd to show it — true of the four cloud images, false here, where sshd reports « printmotd no » and the PAM stack holds no pam_motd: the guide was written, complete, and nobody read it. It also gains a NixOS block naming the trap it exists for — `/etc/nixos/erplibre.nix` is rewritten by `make install_os`, and declarations added there vanish without a word - « make db_drop_all » no longer announces databases as dropped that were not. It built a parallel command, discarded its exit status and printed the list; the case is reachable as soon as parallel is missing from the PATH, and the operator moves on believing their databases are gone - A download cache mirror refused for lack of space names its threshold and its measurement. It echoed a field every ordinary caller leaves at zero — « less than 0 B free on disk » announces no threshold and does not say what was measured +- A downloaded image is checked against the sum its publisher ships, for every distribution that publishes one and WITHOUT asking. The check existed behind a flag and for Ubuntu only, so the other images arrived with nothing looking at them. Six now enter, read off the repositories rather than guessed: Debian publishes sha512 where everything else is sha256, the RHEL families name the file « CHECKSUM », Rocky writes the BSD form, and Arch and openSUSE ship a sum per image. An unreachable sums file no longer stops a deployment — that is an availability failure — while a mismatch still stops everything and removes the image +- The locale a deployed VM is given applies on NixOS. cloud-init applies it through locale-gen and update-locale, absent there, and the VM kept the distribution's default — « fr_CA.UTF-8 » asked for, « en_US.UTF-8 » obtained. Both it and the timezone are declared by the module now, and neither is imposed on a NixOS one already had @@ -268,6 +272,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le guide de connexion s'affiche sur NixOS. Le déploiement écrit `/etc/motd` partout et compte sur pam_motd pour le montrer — vrai des quatre images cloud, faux ici, où sshd rend « printmotd no » et où la pile PAM ne contient aucun pam_motd : le guide était écrit, complet, et personne ne le lisait. Il gagne aussi un bloc propre à NixOS, qui nomme le piège pour lequel il existe — `/etc/nixos/erplibre.nix` est réécrit par `make install_os`, et les déclarations qu'on y ajoute disparaissent sans un mot - « make db_drop_all » n'annonce plus détruites des bases qui ne le sont pas. Il composait une commande parallel, jetait son code de retour et imprimait la liste ; le cas s'atteint dès que parallel manque du PATH, et l'opérateur passe à la suite en croyant ses bases parties - Un miroir du cache de téléchargement refusé faute de place nomme son seuil et sa mesure. Il reprenait un champ que tout appelant ordinaire laisse à zéro — « moins de 0 o libres sur le disque » n'annonce aucun seuil et ne dit pas ce qui a été mesuré +- Une image téléchargée est vérifiée contre la somme que son éditeur publie, pour toute distribution qui en publie une et SANS le demander. La vérification existait sous un drapeau et pour Ubuntu seulement, si bien que les autres images arrivaient sans que rien ne les regarde. Six y entrent, relevées sur les dépôts plutôt que devinées : Debian publie du sha512 quand tout le reste est en sha256, les familles RHEL nomment le fichier « CHECKSUM », Rocky l'écrit en forme BSD, et Arch comme openSUSE posent une somme par image. Un fichier de sommes injoignable n'arrête plus un déploiement — c'est une panne de disponibilité — quand un écart arrête toujours tout et supprime l'image +- La locale qu'une VM déployée reçoit s'applique sur NixOS. cloud-init l'applique par locale-gen et update-locale, absents là-bas, et la VM gardait le défaut de la distribution — « fr_CA.UTF-8 » demandé, « en_US.UTF-8 » obtenu. Elle et le fuseau sont désormais déclarés par le module, et ni l'un ni l'autre n'est imposé à une NixOS qu'on avait déjà ## Removed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 5fe800e..1ef15f2 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -59,6 +59,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - L'image tierce est vérifiée contre la somme que le dépôt épingle pour elle sur le chemin Proxmox aussi, où un `wget` nu suffisait. Un seul accesseur porte cette somme pour les deux chemins, et le contrôle vaut même pour une image déjà en cache — le cas visé est un fichier substitué ou tronqué entre deux déploiements, qu'un test de présence ne voit pas - Une entrée du cache de téléchargement peut être retirée, par son URL. Une somme qui ne correspond pas n'invalidait rien : le magasin continuait de servir les mêmes octets, et retélécharger ne changeait rien puisque c'est lui qui répond. Aucune des deux purges ne l'atteint — `--purge` efface tout, et `--purge-older-than` saute ce qui est récent alors que chaque service remet cette date, si bien qu'un objet empoisonné qui sert ne vieillit jamais. `--oublie` est le symétrique de `--detient` : mêmes lignes en entrée, mêmes fonctions de clé, mêmes refus, et un objet présent qui résiste est dit refusé plutôt qu'oublié. Les deux échecs de somme le nomment désormais, avec l'URL exacte - Le journal d'installation porte ce que l'hôte a décidé avant de lancer : l'autorité du cache de téléchargement posée ou refusée, l'exception, le miroir. Ces lignes se disaient sur une console qui défile, pendant que le fichier qu'on rouvre après un échec ne portait que le symptôme — sur un invité sans magasin de confiance, un certificat refusé, des centaines de dérivations à construire et six cents lignes d'erreurs, sans un mot sur la cause +- Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier ## Modifié @@ -124,6 +125,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le guide de connexion s'affiche sur NixOS. Le déploiement écrit `/etc/motd` partout et compte sur pam_motd pour le montrer — vrai des quatre images cloud, faux ici, où sshd rend « printmotd no » et où la pile PAM ne contient aucun pam_motd : le guide était écrit, complet, et personne ne le lisait. Il gagne aussi un bloc propre à NixOS, qui nomme le piège pour lequel il existe — `/etc/nixos/erplibre.nix` est réécrit par `make install_os`, et les déclarations qu'on y ajoute disparaissent sans un mot - « make db_drop_all » n'annonce plus détruites des bases qui ne le sont pas. Il composait une commande parallel, jetait son code de retour et imprimait la liste ; le cas s'atteint dès que parallel manque du PATH, et l'opérateur passe à la suite en croyant ses bases parties - Un miroir du cache de téléchargement refusé faute de place nomme son seuil et sa mesure. Il reprenait un champ que tout appelant ordinaire laisse à zéro — « moins de 0 o libres sur le disque » n'annonce aucun seuil et ne dit pas ce qui a été mesuré +- Une image téléchargée est vérifiée contre la somme que son éditeur publie, pour toute distribution qui en publie une et SANS le demander. La vérification existait sous un drapeau et pour Ubuntu seulement, si bien que les autres images arrivaient sans que rien ne les regarde. Six y entrent, relevées sur les dépôts plutôt que devinées : Debian publie du sha512 quand tout le reste est en sha256, les familles RHEL nomment le fichier « CHECKSUM », Rocky l'écrit en forme BSD, et Arch comme openSUSE posent une somme par image. Un fichier de sommes injoignable n'arrête plus un déploiement — c'est une panne de disponibilité — quand un écart arrête toujours tout et supprime l'image +- La locale qu'une VM déployée reçoit s'applique sur NixOS. cloud-init l'applique par locale-gen et update-locale, absents là-bas, et la VM gardait le défaut de la distribution — « fr_CA.UTF-8 » demandé, « en_US.UTF-8 » obtenu. Elle et le fuseau sont désormais déclarés par le module, et ni l'un ni l'autre n'est imposé à une NixOS qu'on avait déjà ## Retiré diff --git a/CHANGELOG.md b/CHANGELOG.md index c0bb2f4..b8d1527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The third-party image is verified against the sum the repository pins for it on the Proxmox path too, where a bare `wget` used to be enough. One accessor carries that sum for both paths, and the check runs even on a cached image — the case aimed at is a file substituted or truncated between deployments, which a presence test cannot see - One entry of the download cache can be removed, by URL. A checksum that does not match invalidated nothing: the store kept serving the same bytes, and re-downloading changed nothing since the store is what answers. Neither existing purge reaches it — `--purge` erases everything, and `--purge-older-than` skips what is recent while every service resets that date, so a poisoned object that keeps being served never ages. `--oublie` is symmetric to `--detient`: same input lines, same key functions, same refusals, and a present object that resists is reported as a refusal rather than a forget. Both checksum failures now name it, with the exact URL - The install log carries what the host decided before launching: the download cache authority placed or refused, the bypass, the mirror. Those lines were said on a console that scrolls away, while the file reopened after a failure held only the symptom — on a guest with no trust store, a refused certificate, hundreds of derivations to build and six hundred lines of errors, without a word on the cause +- One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified ## Changed @@ -124,6 +125,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The connection guide is displayed on NixOS. Deployment writes `/etc/motd` everywhere and relies on pam_motd to show it — true of the four cloud images, false here, where sshd reports « printmotd no » and the PAM stack holds no pam_motd: the guide was written, complete, and nobody read it. It also gains a NixOS block naming the trap it exists for — `/etc/nixos/erplibre.nix` is rewritten by `make install_os`, and declarations added there vanish without a word - « make db_drop_all » no longer announces databases as dropped that were not. It built a parallel command, discarded its exit status and printed the list; the case is reachable as soon as parallel is missing from the PATH, and the operator moves on believing their databases are gone - A download cache mirror refused for lack of space names its threshold and its measurement. It echoed a field every ordinary caller leaves at zero — « less than 0 B free on disk » announces no threshold and does not say what was measured +- A downloaded image is checked against the sum its publisher ships, for every distribution that publishes one and WITHOUT asking. The check existed behind a flag and for Ubuntu only, so the other images arrived with nothing looking at them. Six now enter, read off the repositories rather than guessed: Debian publishes sha512 where everything else is sha256, the RHEL families name the file « CHECKSUM », Rocky writes the BSD form, and Arch and openSUSE ship a sum per image. An unreachable sums file no longer stops a deployment — that is an availability failure — while a mismatch still stops everything and removes the image +- The locale a deployed VM is given applies on NixOS. cloud-init applies it through locale-gen and update-locale, absent there, and the VM kept the distribution's default — « fr_CA.UTF-8 » asked for, « en_US.UTF-8 » obtained. Both it and the timezone are declared by the module now, and neither is imposed on a NixOS one already had ## Removed diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 8cba05b..45a1590 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1706,34 +1706,111 @@ def verify_pinned_sha256( print(f" Somme sha256 conforme à celle du dépôt ({attendu[:12]}…).") -def verify_sha256(url: str, image: Path, dry_run: bool) -> None: - """Vérifie l'empreinte via le SHA256SUMS publié dans le même répertoire.""" +# Où chaque distribution publie la somme de ses images, et avec quel +# algorithme. +# +# Relevé sur les dépôts eux-mêmes, pas déduit. Les FORMATS se ressemblent — +# « », l'astérisque d'Ubuntu en plus — mais ni le nom du +# fichier ni l'algorithme ne se devinent : Debian publie du sha512 quand tout +# le reste est en sha256, les familles RHEL nomment leur fichier « CHECKSUM » +# sans dire l'algorithme, et Arch comme openSUSE posent une somme PAR image +# plutôt qu'un fichier de répertoire. +# +# « {image} » dans le nom désigne la somme voisine ; sans lui, le fichier est +# cherché dans le répertoire de l'image. +# +# Fedora n'y est pas : son image porte un numéro de construction dans son +# nom, et le fichier de sommes le porte aussi — il ne se déduit pas de l'URL +# de l'image. Le dire plutôt que de vérifier à moitié. +SUMS_SOURCE: dict[str, tuple[str, str]] = { + "ubuntu": ("SHA256SUMS", "sha256"), + "debian": ("SHA512SUMS", "sha512"), + "almalinux": ("CHECKSUM", "sha256"), + "rocky": ("CHECKSUM", "sha256"), + "opensuse": ("{image}.sha256", "sha256"), + "arch": ("{image}.SHA256", "sha256"), +} + + +def sums_url_for(url: str, distro: str) -> tuple[str, str]: + """(URL du fichier de sommes, algorithme) pour cette image, ou ("", "").""" + source = SUMS_SOURCE.get(distro) + if not source: + return "", "" + nom, algo = source + repertoire, image = url.rsplit("/", 1) + if "{image}" in nom: + return f"{repertoire}/{nom.format(image=image)}", algo + return f"{repertoire}/{nom}", algo + + +def expected_sum(sums: str, filename: str) -> str: + """L'empreinte que `sums` donne pour `filename`, ou "". + + Le nom est comparé au DERNIER champ, et en entier. Les six formats du + catalogue s'écrivent « » — Ubuntu met une étoile + devant le sien, que l'on retire. Une comparaison par suffixe confondrait + « …-amd64.qcow2 » avec « …-ext4-amd64.qcow2 », qui vivent dans le même + fichier chez AlmaLinux : on rendrait alors la somme d'une autre image, et + la vérification échouerait en accusant l'image juste. + + DEUX formes, et la seconde ne se devine pas : Rocky publie « SHA256 + () = », la forme des outils BSD, là où les cinq autres + écrivent l'empreinte en tête. Ne lire que la première rendait "" pour + Rocky, donc une image jamais vérifiée sans que rien ne le dise. + + Un fichier signé porte aussi ses lignes de PGP, et le CHECKSUM de Rocky + des lignes de commentaire donnant la taille : ni les unes ni les autres + n'ont la forme attendue, et elles tombent d'elles-mêmes. + """ + for ligne in sums.splitlines(): + champs = ligne.split() + if len(champs) == 2: + empreinte, nom = champs + if nom.lstrip("*") == filename: + return empreinte + elif len(champs) == 4 and champs[2] == "=": + if champs[1].strip("()") == filename: + return champs[3] + return "" + + +def verify_sha256( + url: str, image: Path, dry_run: bool, distro: str = "ubuntu" +) -> None: + """Vérifie l'empreinte via les sommes que la distribution publie. + + UN ÉCHEC DE RÉCUPÉRATION N'ARRÊTE PAS le déploiement, un écart si. Les + deux ne disent pas la même chose : un fichier de sommes injoignable est + une panne de disponibilité — miroir en travaux, réseau coupé — et refuser + de déployer pour cela rendrait la vérification plus coûteuse que le risque + qu'elle couvre. Une empreinte qui ne correspond PAS est une panne + d'intégrité, et elle arrête tout. + """ if dry_run: - print(" [dry-run] vérification SHA256 ignorée") + print(" [dry-run] vérification des sommes ignorée") + return + sums_url, algo = sums_url_for(url, distro) + if not sums_url: + print(f" Note : {distro} ne publie pas de sommes lisibles d'ici.") return - sums_url = url.rsplit("/", 1)[0] + "/SHA256SUMS" filename = url.rsplit("/", 1)[1] - print(f" Vérification SHA256 via {sums_url}") + print(f" Vérification {algo} via {sums_url}") try: with urllib.request.urlopen( # noqa: S310 sums_url, timeout=DOWNLOAD_TIMEOUT ) as resp: sums = resp.read().decode() except Exception as exc: # pragma: no cover - sys.exit(f"Impossible de récupérer SHA256SUMS : {exc}") + print(f" ⚠ sommes injoignables, image NON vérifiée : {exc}") + return - expected = next( - ( - line.split()[0] - for line in sums.splitlines() - if line.strip().endswith(filename) - ), - None, - ) - if expected is None: - sys.exit(f"Empreinte introuvable pour {filename} dans SHA256SUMS") + expected = expected_sum(sums, filename) + if not expected: + print(f" ⚠ empreinte absente pour {filename}, image NON vérifiée") + return - h = hashlib.sha256() + h = hashlib.new(algo) with image.open("rb") as fh: for chunk in iter(lambda: fh.read(1 << 20), b""): h.update(chunk) @@ -1742,10 +1819,10 @@ def verify_sha256(url: str, image: Path, dry_run: bool) -> None: missing_ok=True ) # évite la réutilisation du cache corrompu sys.exit( - f"SHA256 NON conforme ! Image supprimée : {image}\n" + f"Somme {algo} NON conforme ! Image supprimée : {image}\n" f" attendu : {expected}\n obtenu : {h.hexdigest()}" ) - print(" SHA256 conforme.") + print(f" Somme {algo} conforme.") def hash_password(plain: str) -> str: @@ -4797,7 +4874,16 @@ def build_parser() -> argparse.ArgumentParser: g_img.add_argument( "--verify", action="store_true", - help="Vérifie l'empreinte SHA256 après téléchargement (recommandé).", + help="Sans effet : la vérification est désormais le DÉFAUT pour " + "toute distribution qui publie ses sommes. Gardé pour que les " + "commandes déjà écrites continuent de marcher.", + ) + g_img.add_argument( + "--no-verify", + action="store_true", + help="Ne vérifie PAS l'image contre les sommes publiées par sa " + "distribution. À réserver aux essais hors ligne : une image " + "substituée sur un miroir passerait alors sans un mot.", ) g_vm = p.add_argument_group("VM") @@ -5191,13 +5277,16 @@ def main() -> None: args.distro, code, args.arch, args.version, args.dry_run ) url = urls[0] - # --verify s'appuie sur un SHA256SUMS style Ubuntu ; Debian/Fedora - # publient des sommes dans un autre format -> on saute proprement. - do_verify = args.verify and args.distro == "ubuntu" - if args.verify and not do_verify: + # PAR DÉFAUT, et pour toute distribution qui publie ses sommes. C'était + # un drapeau, et réservé à Ubuntu : les huit autres images arrivaient + # sans que rien ne les vérifie, alors que leurs éditeurs publient tous + # une somme à côté. Le coût est une lecture du fichier téléchargé ; le + # risque couvert est une image substituée sur un miroir. + do_verify = not args.no_verify and args.distro in SUMS_SOURCE + if not args.no_verify and not do_verify: print( - f" Note : --verify n'est pris en charge que pour ubuntu " - f"(ignoré pour {args.distro})." + f" Note : aucune somme publiée n'est lisible d'ici pour " + f"{args.distro}." ) # Chemin de l'image : déduit automatiquement si non fourni. @@ -5222,7 +5311,7 @@ def main() -> None: args.distro, args.image_path, args.dry_run, tuple(urls) ) if do_verify: - verify_sha256(url, args.image_path, args.dry_run) + verify_sha256(url, args.image_path, args.dry_run, args.distro) print("\nTerminé (téléchargement seul).") return @@ -5339,7 +5428,7 @@ def main() -> None: args.distro, args.image_path, args.dry_run, tuple(urls) ) if do_verify: - verify_sha256(url, args.image_path, args.dry_run) + verify_sha256(url, args.image_path, args.dry_run, args.distro) print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==") prepare_disk(args.image_path, disk, args.disk_size, runner, args.force) diff --git a/script/todo/qemu_cache_menu.py b/script/todo/qemu_cache_menu.py index e6cac9a..ed3c22c 100644 --- a/script/todo/qemu_cache_menu.py +++ b/script/todo/qemu_cache_menu.py @@ -1449,6 +1449,7 @@ class QemuCacheMenuMixin: ) }, {"prompt_description": t("Clean - Everything")}, + {"prompt_description": t("Clean - Forget one URL")}, ] grains = {"1": "jour", "2": "semaine", "3": "mois"} help_info = self.fill_help_info(choices) @@ -1465,6 +1466,8 @@ class QemuCacheMenuMixin: self._cache_nettoyer_age() elif status == "5": self._cache_nettoyer_tout() + elif status == "6": + self._cache_oublier_url() else: print(t("Command not found !")) @@ -1498,6 +1501,30 @@ class QemuCacheMenuMixin: return self._cache_lancer(f"--purge-older-than {shlex.quote(delai)}") + def _cache_oublier_url(self): + """Retire UNE entrée, par son URL. Montrée d'abord, effacée ensuite. + + Le seul geste qui vise une entrée : « effacer ce qui n'a plus servi » + n'atteint jamais un objet que le service rajeunit à chaque fois qu'il + le rend, et « tout effacer » coûte le cache entier pour un fichier. + Le cas qui l'appelle est une somme qui ne correspond pas — le magasin + continue alors de servir les mêmes octets, et retélécharger ne change + rien puisque c'est lui qui répond. + + « --detient » AVANT, et c'est l'aperçu : il répond à la même ligne, + par la même clé, sans rien modifier. Un « absent » dit que l'URL n'est + pas celle qu'on croit, avant d'avoir effacé quoi que ce soit. + """ + url = click.prompt(t("URL to forget (or METHOD URL)"), default="") + url = url.strip() + if not url: + return + print(f"\n {t('What the store holds for it:')}\n") + self._cache_lancer(f"--detient <<< {shlex.quote(url)}", sudo=False) + if not click.confirm(t("Remove this entry from the store?")): + return + self._cache_lancer(f"--oublie <<< {shlex.quote(url)}") + def _cache_nettoyer_tout(self): """Tout, objets ET dépôts. Montré à blanc d'abord, comme le reste.""" print(f" ⚠ {t('This empties the objects AND the git mirrors.')}") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index a86b9e6..974bf4f 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -7268,6 +7268,22 @@ TRANSLATIONS = { "fr": "autorité du cache de téléchargement non posée", "en": "download cache authority not installed", }, + "Clean - Forget one URL": { + "fr": "Nettoyer - Oublier une URL", + "en": "Clean - Forget one URL", + }, + "URL to forget (or METHOD URL)": { + "fr": "URL à oublier (ou MÉTHODE URL)", + "en": "URL to forget (or METHOD URL)", + }, + "What the store holds for it:": { + "fr": "Ce que le magasin en tient :", + "en": "What the store holds for it:", + }, + "Remove this entry from the store?": { + "fr": "Retirer cette entrée du magasin ?", + "en": "Remove this entry from the store?", + }, "Prepared by the host:": { "fr": "Préparé par l'hôte :", "en": "Prepared by the host:", diff --git a/test/test_qemu_cache_menu.py b/test/test_qemu_cache_menu.py index 795a420..5709a08 100644 --- a/test/test_qemu_cache_menu.py +++ b/test/test_qemu_cache_menu.py @@ -256,7 +256,9 @@ class TestSousMenusDuCache(unittest.TestCase): self.verifier("_cache_nettoyage_auto", "_cache_nettoyage_etat", 4) def test_le_menu_de_lage(self): - self.verifier("_cache_age", "_cache_lancer", 5) + """Six depuis qu'on peut oublier UNE entrée : les cinq autres + montrent l'âge ou effacent en gros, celle-là vise une URL.""" + self.verifier("_cache_age", "_cache_lancer", 6) def test_letat_du_service_est_la_troisieme(self): """Sous le diagnostic, comme demandé : le décalage du guide et des @@ -1124,5 +1126,51 @@ class TestLesReglagesDuNettoyage(unittest.TestCase): faux.execute.exec_command_live.assert_not_called() +class OublierUneUrlDepuisLeMenu(unittest.TestCase): + """Le seul geste qui vise UNE entrée. + + « Effacer ce qui n'a plus servi » ne l'atteint jamais — le service + rajeunit un objet chaque fois qu'il le rend — et « tout effacer » coûte + le cache entier pour un fichier. Le cas qui l'appelle est une somme qui + ne correspond pas : le magasin sert alors les mêmes octets, et + retélécharger ne change rien puisque c'est lui qui répond. + """ + + def _src(self): + import inspect + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return inspect.getsource(TODO._cache_oublier_url) + + def test_the_preview_comes_first(self): + """« --detient » répond à la même ligne, par la même clé, sans rien + modifier : un « absent » dit que l'URL n'est pas celle qu'on croit, + avant d'avoir effacé quoi que ce soit.""" + src = self._src() + self.assertLess(src.index("--detient"), src.index("--oublie")) + + def test_nothing_is_erased_without_a_yes(self): + src = self._src() + self.assertLess(src.index("confirm"), src.index("--oublie")) + + def test_the_url_is_quoted(self): + """Une URL signée porte des « & » et des « ? » : non échappée, le + shell y verrait des opérateurs.""" + self.assertIn("shlex.quote(url)", self._src()) + + def test_the_entry_is_offered_in_the_cleanup_menu(self): + from pathlib import Path + + racine = Path(__file__).resolve().parent.parent + menu = (racine / "script/todo/qemu_cache_menu.py").read_text( + encoding="utf-8" + ) + self.assertIn('t("Clean - Forget one URL")', menu) + self.assertIn("self._cache_oublier_url()", menu) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_qemu_sommes.py b/test/test_qemu_sommes.py new file mode 100644 index 0000000..05e4b92 --- /dev/null +++ b/test/test_qemu_sommes.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""L'image téléchargée est vérifiée contre la somme que son éditeur publie. + +La vérification existait, sous un drapeau, et pour Ubuntu SEULEMENT : les +huit autres images arrivaient sans que rien ne les regarde, alors que leurs +éditeurs publient tous une somme à côté. Le risque couvert est une image +substituée sur un miroir, et le coût une lecture du fichier qu'on vient de +télécharger. + +Ni le nom du fichier de sommes ni l'algorithme ne se devinent, et les +extraits plus bas sont pris des dépôts eux-mêmes : Debian publie du sha512 +quand tout le reste est en sha256, les familles RHEL nomment leur fichier +« CHECKSUM » sans dire l'algorithme, Rocky l'écrit en forme BSD, et Arch +comme openSUSE posent une somme PAR image. +""" + +import importlib.util +import sys +import unittest +from pathlib import Path + +sys.argv = ["todo.py"] + +RACINE = Path(__file__).resolve().parents[1] + + +def _deploy_qemu(): + chemin = RACINE / "script/qemu/deploy_qemu.py" + spec = importlib.util.spec_from_file_location("deploy_qemu", chemin) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +DQ = _deploy_qemu() + +# Un extrait de chaque format, relevé sur le dépôt de la distribution. Les +# empreintes sont RACCOURCIES et inventées : ce qu'on éprouve est +# l'appariement, pas la valeur, et une somme réelle figée ici vieillirait au +# premier renouvellement d'image. +FORMATS = { + "ubuntu": ( + "aaaa *autre-image-amd64.img\n" + "bbbb *ubuntu-24.04-server-cloudimg-amd64.img\n", + "ubuntu-24.04-server-cloudimg-amd64.img", + "bbbb", + ), + "debian": ( + "cccc debian-12-genericcloud-arm64.qcow2\n" + "dddd debian-12-genericcloud-amd64.qcow2\n", + "debian-12-genericcloud-amd64.qcow2", + "dddd", + ), + "rocky": ( + "# Rocky-10-GenericCloud.latest.x86_64.qcow2: 544997376 bytes\n" + "SHA256 (Rocky-10-GenericCloud.latest.x86_64.qcow2) = eeee\n", + "Rocky-10-GenericCloud.latest.x86_64.qcow2", + "eeee", + ), + "opensuse": ( + "ffff Leap-16.0-Minimal-VM.x86_64-Cloud.qcow2\n", + "Leap-16.0-Minimal-VM.x86_64-Cloud.qcow2", + "ffff", + ), +} + + +class LAppariement(unittest.TestCase): + def test_every_published_format_is_read(self): + for distro, (texte, nom, attendu) in FORMATS.items(): + with self.subTest(distro=distro): + self.assertEqual(attendu, DQ.expected_sum(texte, nom)) + + def test_a_longer_name_ending_the_same_is_not_a_match(self): + """Le nom est comparé EN ENTIER, pas par sa fin. Un fichier de sommes + porte toutes les images d'un répertoire, et un nom plus long qui se + termine par celui qu'on cherche rendrait la somme d'une AUTRE image — + la vérification échouerait alors en accusant l'image juste. + + La forme d'origine comparait la fin de la LIGNE, ce qui revient au + même défaut.""" + texte = "1111 prefixe-image.qcow2\n2222 image.qcow2\n" + self.assertEqual("2222", DQ.expected_sum(texte, "image.qcow2")) + + def test_signature_and_comment_lines_fall_away(self): + texte = ( + "-----BEGIN PGP SIGNED MESSAGE-----\n" + "Hash: SHA256\n" + "\n" + "# une ligne de commentaire\n" + "3333 image.qcow2\n" + ) + self.assertEqual("3333", DQ.expected_sum(texte, "image.qcow2")) + + def test_an_absent_name_yields_nothing(self): + self.assertEqual("", DQ.expected_sum("4444 autre.qcow2\n", "x.qcow2")) + + +class OuChaqueDistributionPublie(unittest.TestCase): + def test_the_six_that_publish_are_resolved(self): + """Une somme par répertoire chez les uns, à côté de l'image chez les + autres : le nom ne se déduit pas de l'image seule.""" + for distro in DQ.SUMS_SOURCE: + with self.subTest(distro=distro): + url = f"https://exemple.invalid/d/image-{distro}.qcow2" + su, algo = DQ.sums_url_for(url, distro) + self.assertTrue(su.startswith("https://exemple.invalid/d/")) + self.assertIn(algo, ("sha256", "sha512")) + + def test_debian_is_the_one_in_sha512(self): + """L'algorithme vient de la table, pas du nom du fichier.""" + self.assertEqual("sha512", DQ.SUMS_SOURCE["debian"][1]) + for distro, (_nom, algo) in DQ.SUMS_SOURCE.items(): + if distro != "debian": + with self.subTest(distro=distro): + self.assertEqual("sha256", algo) + + def test_the_per_image_forms_carry_the_image_name(self): + for distro in ("opensuse", "arch"): + with self.subTest(distro=distro): + url = "https://exemple.invalid/d/monimage.qcow2" + su, _a = DQ.sums_url_for(url, distro) + self.assertIn("monimage.qcow2", su) + + def test_a_distribution_that_publishes_nothing_says_so(self): + """NixOS a sa somme ÉPINGLÉE dans le dépôt, vérifiée toujours ; son + image tierce ne publie rien à côté.""" + self.assertNotIn("nixos", DQ.SUMS_SOURCE) + self.assertEqual( + ("", ""), DQ.sums_url_for("https://x/i.qcow2", "nixos") + ) + + +class LaVerificationEstLeDefaut(unittest.TestCase): + SRC = (RACINE / "script/qemu/deploy_qemu.py").read_text(encoding="utf-8") + + def test_no_flag_is_needed_any_more(self): + self.assertIn("do_verify = not args.no_verify", self.SRC) + + def test_skipping_it_must_be_asked_for(self): + self.assertIn('"--no-verify"', self.SRC) + + def test_unreachable_sums_do_not_stop_a_deployment(self): + """Un fichier de sommes injoignable est une panne de DISPONIBILITÉ — + miroir en travaux, réseau coupé. Refuser de déployer pour cela + rendrait la vérification plus coûteuse que le risque qu'elle couvre. + """ + i = self.SRC.index("def verify_sha256(") + corps = self.SRC[i : self.SRC.index("def hash_password", i)] + self.assertIn("image NON vérifiée", corps) + self.assertNotIn('sys.exit(f"Impossible', corps) + + def test_a_mismatch_still_stops_everything(self): + """Elle, c'est une panne d'INTÉGRITÉ.""" + i = self.SRC.index("def verify_sha256(") + corps = self.SRC[i : self.SRC.index("def hash_password", i)] + self.assertIn("NON conforme", corps) + self.assertIn("image.unlink", corps) + + +if __name__ == "__main__": + unittest.main()