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
262 lines
10 KiB
Python
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()
|