[FIX] qemu images : vérifier les sommes publiées, partout et par défaut

La vérification existait sous un drapeau, et pour Ubuntu SEULEMENT : les
autres images arrivaient sans que rien ne les regarde, alors que leurs
éditeurs publient tous une somme. Six y entrent, relevées sur les dépôts
plutôt que devinées — Debian est en sha512, les familles RHEL nomment
« CHECKSUM », Rocky l'écrit en forme BSD, Arch et openSUSE posent une
somme par image. Fedora en reste dehors, son fichier portant un numéro de
construction que l'URL de l'image ne donne pas.

Injoignable n'arrête plus rien, un écart si. Le menu du cache sait aussi
oublier une URL, « --detient » faisant l'aperçu.

--- EN ---

Verification existed behind a flag, and for Ubuntu ONLY: the other images
arrived with nothing looking at them, though every publisher ships a sum.
Six now enter, read off the repositories rather than guessed — Debian is
sha512, the RHEL families name it "CHECKSUM", Rocky writes the BSD form,
Arch and openSUSE ship a sum per image. Fedora stays out, its file
carrying a build number the image URL does not give.

Unreachable no longer stops anything, a mismatch does. The cache menu can
also forget one URL, "--detient" serving as the preview.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-16 00:44:00 -04:00
parent dc878e30d0
commit 3d0ed8915f
8 changed files with 385 additions and 29 deletions

View file

@ -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
<!-- [fr] -->
@ -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
<!-- [en] -->
## 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
<!-- [fr] -->
@ -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à
<!-- [en] -->
## Removed

View file

@ -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é

View file

@ -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

View file

@ -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 —
# « <empreinte> <nom> », 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 « <empreinte> <nom> » — 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
(<nom>) = <empreinte> », 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)

View file

@ -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.')}")

View file

@ -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:",

View file

@ -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()

164
test/test_qemu_sommes.py Normal file
View file

@ -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()