erplibre/test/test_qemu_sommes.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

262 lines
10 KiB
Python

#!/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)
class UneImageGardeeQuiAVieilli(unittest.TestCase):
"""« latest » avance à chaque version mineure : l'image gardée cesse de
correspondre à la somme publiée sans que rien ne soit corrompu.
La supprimer puis abandonner coûtait la campagne entière — trente minutes
et trois VM — là où un seul téléchargement suffit. Un second écart, lui,
porte sur des octets neufs : c'est une panne d'intégrité, et elle arrête.
"""
NOM = "ubuntu-24.04-server-cloudimg-amd64.img"
URL = f"https://miroir.invalid/d/{NOM}"
def jouer(
self,
sur_disque,
retelecharge=None,
publie=b"publie",
urls=("https://m/i",),
):
"""Rend (sorti, nombre de téléchargements, contenu final).
« publie » est ce que le fichier de sommes DÉCLARE ; « retelecharge »
ce qu'un nouveau téléchargement pose vraiment. Les confondre rend le
second écart impossible à éprouver : les octets repris concordent
alors toujours, et le cas « faux deux fois » n'existe plus.
"""
import hashlib
import tempfile
from unittest import mock
pose = retelecharge if retelecharge is not None else publie
somme = hashlib.sha256(publie).hexdigest()
sums = f"{somme} {self.NOM}\n".encode()
class Reponse:
def __enter__(self_inner):
return self_inner
def __exit__(self_inner, *a):
return False
def read(self_inner):
return sums
with tempfile.TemporaryDirectory() as d:
image = Path(d) / self.NOM
image.write_bytes(sur_disque)
appels = []
def faux_telechargement(urls_, dest, dry_run, timeout=None):
appels.append(tuple(urls_))
Path(dest).write_bytes(pose)
sorti = False
with (
mock.patch.object(
DQ.urllib.request, "urlopen", lambda *a, **k: Reponse()
),
mock.patch.object(DQ, "download_image", faux_telechargement),
):
try:
DQ.verify_sha256(self.URL, image, False, "ubuntu", urls)
except SystemExit:
sorti = True
final = image.read_bytes() if image.exists() else b""
return sorti, len(appels), final
def test_une_image_perimee_est_reprise_une_fois(self):
sorti, n, final = self.jouer(b"vieille", b"publie")
self.assertFalse(sorti, "une péremption arrête encore le déploiement")
self.assertEqual(
1, n, "l'image n'a pas été reprise exactement une fois"
)
self.assertEqual(b"publie", final)
def test_un_second_ecart_arrete_tout(self):
"""Des octets fraîchement téléchargés qui ne concordent pas ne sont
plus une péremption : la vérification doit alors refuser."""
sorti, n, final = self.jouer(b"vieille", b"faux-aussi")
self.assertTrue(sorti, "une image fausse deux fois passe")
self.assertEqual(1, n, "la reprise boucle au lieu d'arrêter")
self.assertEqual(b"", final, "l'image fausse est restée sur le disque")
def test_sans_miroir_le_comportement_ne_change_pas(self):
"""Appelée sans liste de miroirs — le mode épinglé, un appelant tiers
— la vérification garde sa forme d'avant : supprimer et sortir."""
sorti, n, final = self.jouer(b"vieille", b"publie", urls=())
self.assertTrue(sorti)
self.assertEqual(0, n)
self.assertEqual(b"", final)
def test_une_image_conforme_ne_declenche_rien(self):
sorti, n, final = self.jouer(b"publie", b"publie")
self.assertFalse(sorti)
self.assertEqual(0, n, "une image conforme a été retéléchargée")
self.assertEqual(b"publie", final)
if __name__ == "__main__":
unittest.main()