#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Le balayage : ce qu'on planifie, ce qu'on frappe, et rien d'autre. Trois décisions sont défendues ici, et chacune casse en silence. **Le préfixe se lit, il ne se devine pas.** Une machine porte volontiers deux /24 — un sur son interface physique, un sur un pont de virtualisation où elle est la passerelle — et « le /24 local » n'a alors pas de sens. Une régression qui choisirait pour l'utilisateur balaierait le mauvais réseau sans rien lever, et un réseau plus large qu'un /24 doit être REFUSÉ plutôt que rétréci : rétrécir présenterait une hypothèse comme une lecture. **Le balayage ne touche que ce qu'on lui a désigné.** Les adresses de la machine elle-même sont écartées du plan, sans quoi le balayage se reconnaît lui-même et offre la passerelle d'un pont comme un serveur découvert. Et la table de voisinage ne rend que ce qui a RÉPONDU : une entrée sans adresse matérielle dit qu'on a demandé sans obtenir. **Le battement de cœur est testable, et c'est le point.** L'idiome de progression du dépôt code son intervalle en dur, et c'est exactement pourquoi cette branche-là n'a aucun test. Ici l'intervalle et l'horloge sont injectés. Aucun paquet n'est émis : le connecteur est injecté partout, `ip` est remplacé par du texte, et le cas du /24 complet remplace en plus `socket.create_connection` par une levée, pour qu'un chemin oublié tombe en rouge au lieu de parler au réseau. Les adresses sont celles des blocs réservés à la documentation, et les noms d'interface sont inventés — `.claude/rules/04-code-conventions.md` exige que la valeur illustrant un interdit s'invente, et « lien0 » comme « pont0 » ne paraissent nulle part ailleurs dans le dépôt. """ import os import re import subprocess import sys import time import unittest from unittest.mock import patch sys.path.append( os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) ) from script.todo.assistant import discover # noqa: E402 from script.todo.assistant import fingerprint # noqa: E402 # Deux réseaux des blocs réservés à la documentation : ils ne peuvent # désigner aucune machine réelle. LAN = "192.0.2.0/24" PONT = "198.51.100.0/24" # Ce que « ip -o -4 addr show » annonce : la boucle locale, une interface # ordinaire, un pont. La forme « -o » met l'interface et son préfixe sur la # même ligne, ce qui est ce qui les rend rattachables. ADDR = ( "1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever\n" "2: lien0 inet 192.0.2.10/24 metric 1024 brd 192.0.2.255 scope" " global dynamic lien0\\ valid_lft 2968sec\n" "3: pont0 inet 198.51.100.1/24 brd 198.51.100.255 scope global" " pont0\\ valid_lft forever\n" ) # Les mêmes interfaces, annoncées dans l'ordre inverse : l'ordre des rangs ne # doit pas décider laquelle ouvre la liste. ADDR_INVERSE = "\n".join( [ADDR.splitlines()[0], ADDR.splitlines()[2], ADDR.splitlines()[1]] ) # Ce que « ip -d -o link show » annonce. « bridge » nu est le TYPE, et lui # seul nomme un pont ; « bridge_slave » est l'attribut que porte un PORT de # pont, et « bridge_id » un attribut du pont lui-même. Les trois paraissent # ici pour que la frontière de mot du motif soit ce qui les sépare. LINK = ( "1: lo: mtu 65536 state UNKNOWN" "\\ link/loopback 00:00:00:00:00:00 addrgenmode eui64\n" "2: lien0: mtu 1500 state UP" "\\ link/ether aa:bb:cc:dd:ee:01 promiscuity 0" " bridge_slave state forwarding\n" "3: pont0: mtu 1500 state DOWN" "\\ link/ether aa:bb:cc:dd:ee:02 promiscuity 0" "\\ bridge forward_delay 200 bridge_id 8000.aa:bb:cc:dd:ee:02\n" ) # La route par défaut désigne l'interface qui sort de la machine. Le noyau a # déjà tranché, et c'est cette réponse-là qui ouvre la liste. ROUTE = ( "default via 192.0.2.1 dev lien0 proto dhcp src 192.0.2.10 metric 1024\n" ) # Une table de voisinage. Les deux premières ont répondu — elles portent une # adresse matérielle ; les deux suivantes ont été sollicitées sans répondre ; # la dernière répète la première sur une autre interface. NEIGH = ( "192.0.2.11 dev lien0 lladdr aa:bb:cc:dd:ee:11 REACHABLE\n" "192.0.2.12 dev lien0 lladdr aa:bb:cc:dd:ee:12 STALE\n" "192.0.2.13 dev lien0 FAILED\n" "192.0.2.14 dev lien0 INCOMPLETE\n" "192.0.2.11 dev pont0 lladdr aa:bb:cc:dd:ee:11 STALE\n" ) def faux_ip(addr=ADDR, link=LINK, route=ROUTE): """Un exécuteur de « ip » qui rend du texte choisi, sans sous-processus. Se branche sur `local_networks(run=…)` et distingue les trois questions par leur verbe, comme la commande elle-même. """ def run(argv): if "addr" in argv: return addr if "link" in argv: return link if "route" in argv: return route return "" return run class Horloge: """Une horloge injectée qui avance d'une seconde par lecture. Une durée rendue par elle vaut au moins une seconde, là où le balayage d'un test dure quelques centièmes : une durée supérieure à la seconde PROUVE donc que c'est bien elle qui a été lue, et non l'horloge du système. """ def __init__(self): self.lectures = 0 def __call__(self): self.lectures += 1 return 1000.0 + self.lectures class Compteur: """Un connecteur injecté : rend vrai sur les couples ouverts, et compte ses appels.""" def __init__(self, ouverts): self.ouverts = set(ouverts) self.appels = 0 def __call__(self, host, port, timeout): self.appels += 1 return (host, port) in self.ouverts class LesReseauxLocaux(unittest.TestCase): def test_les_deux_slash24_locaux_sont_offerts(self): reseaux = discover.local_networks(run=faux_ip()) self.assertTrue(reseaux, "aucun réseau lu : le motif ne colle plus") self.assertEqual([LAN, PONT], [item.cidr for item in reseaux]) self.assertEqual(["lien0", "pont0"], [i.name for i in reseaux]) def test_la_boucle_locale_n_est_pas_un_reseau_a_balayer(self): """Elle se sonde en quelques millisecondes, sans balayage : l'offrir ferait payer 254 adresses pour une seule.""" reseaux = discover.local_networks(run=faux_ip()) self.assertTrue(reseaux, "aucun réseau lu") for item in reseaux: self.assertNotIn("127.", item.cidr) self.assertNotEqual("lo", item.name) def test_le_pont_libvirt_est_signale_comme_tel(self): """Le type « bridge » nomme un pont ; les attributs « bridge_slave » et « bridge_id » n'en nomment pas un, et la ligne de l'interface ordinaire porte le premier pour le prouver.""" par_nom = {i.name: i for i in discover.local_networks(run=faux_ip())} self.assertTrue(par_nom, "aucune interface lue") self.assertTrue(par_nom["pont0"].is_bridge) self.assertFalse(par_nom["lien0"].is_bridge) def test_l_interface_de_la_route_par_defaut_ouvre_la_liste(self): reseaux = discover.local_networks( run=faux_ip(addr=ADDR_INVERSE, route=ROUTE) ) self.assertTrue(reseaux, "aucun réseau lu") self.assertEqual("lien0", reseaux[0].name) self.assertEqual({"lien0", "pont0"}, {i.name for i in reseaux}) def test_un_prefixe_plus_large_est_rendu_tel_quel_pas_retreci(self): """Rétrécir en silence présenterait une hypothèse comme une lecture : le refus appartient au planificateur, qui peut le DIRE.""" large = "2: lien0 inet 192.0.2.10/16 scope global lien0\n" reseaux = discover.local_networks(run=faux_ip(addr=large)) self.assertEqual(["192.0.0.0/16"], [i.cidr for i in reseaux]) self.assertEqual([], discover.plan_sweep(reseaux[0].cidr)) def test_un_ip_absent_est_une_liste_vide_pas_une_panne(self): def leve(argv): raise FileNotFoundError(argv[0]) self.assertEqual([], discover.local_networks(run=leve)) self.assertEqual([], discover.local_networks(run=lambda argv: "")) class LePlanDeBalayage(unittest.TestCase): def test_plus_large_qu_un_slash24_est_refuse(self): for cidr in ("192.0.2.0/23", "192.0.2.0/16", "10.0.0.0/8", "::/64"): self.assertEqual([], discover.plan_sweep(cidr), cidr) self.assertEqual(254, discover.MAX_HOSTS) def test_un_slash24_donne_tous_ses_hotes_fois_tous_les_ports(self): jobs = discover.plan_sweep(LAN) attendu = discover.MAX_HOSTS * len(fingerprint.PORTS) self.assertEqual(attendu, len(jobs)) self.assertEqual(("192.0.2.1", fingerprint.PORTS[0]), jobs[0]) def test_les_ports_par_defaut_sont_ceux_de_l_echelle(self): jobs = discover.plan_sweep("192.0.2.7/32") self.assertEqual( [(str("192.0.2.7"), port) for port in fingerprint.PORTS], jobs ) def test_les_adresses_de_l_hote_sont_ecartees(self): """Sans ce retrait, un balayage se reconnaît lui-même et offre la passerelle d'un pont comme un serveur découvert.""" siennes = ("192.0.2.10", "192.0.2.1") jobs = discover.plan_sweep(LAN, (11434,), skip=siennes) self.assertTrue(jobs, "plan vide : le retrait a tout emporté") self.assertEqual(discover.MAX_HOSTS - 2, len(jobs)) for adresse in siennes: self.assertNotIn((adresse, 11434), jobs) def test_un_cidr_illisible_est_une_liste_vide(self): for cidre in ("", " ", "pas-un-cidr", "192.0.2.0/33", None, 42): self.assertEqual([], discover.plan_sweep(cidre), repr(cidre)) def test_un_ensemble_de_ports_vide_ne_planifie_rien(self): self.assertEqual([], discover.plan_sweep(LAN, ())) class LaTableDeVoisinage(unittest.TestCase): def test_la_table_de_voisinage_ne_rend_que_ce_qui_a_parle(self): adresses = discover.neigh_hosts(NEIGH) self.assertEqual(["192.0.2.11", "192.0.2.12"], adresses) def test_une_table_vide_ou_abimee_ne_leve_pas(self): for texte in ("", None, "n'importe quoi\nlladdr sans adresse\n"): self.assertEqual([], discover.neigh_hosts(texte), repr(texte)) class LaFrappe(unittest.TestCase): def test_un_slash24_complet_n_emet_aucun_paquet(self): """Le connecteur injecté est le seul chemin : une socket ouverte par un chemin oublié tombe en rouge au lieu de parler au réseau.""" jobs = discover.plan_sweep(LAN) ouverts = {("192.0.2.7", 11434), ("192.0.2.42", 8080)} connect = Compteur(ouverts) with patch( "script.todo.assistant.discover.socket.create_connection", side_effect=AssertionError("aucun paquet ne doit sortir"), ): trouves = discover.sweep(jobs, connect=connect) self.assertEqual(len(jobs), connect.appels) self.assertEqual(ouverts, set(trouves)) def test_le_battement_part_sur_l_horloge_injectee(self): horloge = Horloge() evenements = [] def lent(host, port, timeout): time.sleep(0.06) return False discover.sweep( [("192.0.2.7", 11434)], connect=lent, heartbeat_sec=0.01, now=horloge, on_event=evenements.append, ) battements = [e for e in evenements if e[0] == "heartbeat"] self.assertTrue(battements, "aucun battement : la branche est muette") for _nom, faits, total, secondes in battements: self.assertLessEqual(faits, total) self.assertGreaterEqual(secondes, 1.0) def test_un_hote_bloque_n_arrete_pas_les_autres(self): bloque = ("192.0.2.99", 11434) ouverts = {("192.0.2.7", 11434), ("192.0.2.8", 1234)} def connect(host, port, timeout): if (host, port) == bloque: time.sleep(0.05) return False return (host, port) in ouverts jobs = sorted(ouverts | {bloque, ("192.0.2.9", 5001)}) trouves = discover.sweep( jobs, connect=connect, heartbeat_sec=0.01, timeout=0.01 ) self.assertEqual(ouverts, set(trouves)) def test_un_connecteur_qui_leve_compte_pour_un_port_ferme(self): def connect(host, port, timeout): if host == "192.0.2.99": raise OSError("pile réseau à bout") return port == 11434 jobs = [("192.0.2.99", 11434), ("192.0.2.7", 11434)] self.assertEqual( [("192.0.2.7", 11434)], discover.sweep(jobs, connect=connect) ) def test_les_evenements_rendent_les_memes_couples_que_le_retour(self): """L'appelant peut affirmer sur des DONNÉES et non sur du texte capté : c'est ce qui rend l'affichage remplaçable.""" jobs = discover.plan_sweep("192.0.2.0/24", (11434, 1234)) ouverts = {("192.0.2.7", 11434), ("192.0.2.9", 1234)} evenements = [] trouves = discover.sweep( jobs, connect=Compteur(ouverts), on_event=evenements.append, ) dits = [(e[1], e[2]) for e in evenements if e[0] == "hit"] self.assertTrue(dits, "aucune trouvaille annoncée") self.assertEqual(sorted(trouves), sorted(dits)) self.assertEqual( [("done", len(trouves), len(jobs))], [e[:3] for e in evenements if e[0] == "done"], ) def test_un_plan_vide_se_termine_sans_piscine(self): evenements = [] self.assertEqual([], discover.sweep([], on_event=evenements.append)) self.assertEqual(["done"], [e[0] for e in evenements]) class LesSourcesInjectees(unittest.TestCase): def test_la_piscine_est_dimensionnee_par_vagues_pas_par_coeurs(self): """Ces fils attendent le réseau : un compte de cœurs à deux chiffres multiplierait par quinze la durée d'un /24.""" source = _source() self.assertIn("min(len(jobs), workers)", source) self.assertNotIn("cpu_count", source) def test_la_sonde_est_une_connexion_jamais_un_sous_processus_ping(self): """Un `ping` par adresse coûterait un millier de fork+exec sur un /24, là où une socket n'en coûte aucun.""" source = _source() self.assertIn("socket.create_connection", source) self.assertNotIn('"ping"', source) class LaFrontiere(unittest.TestCase): def test_le_balayage_ne_tire_pas_todo(self): """Importer `script.todo.todo` coûte près d'une seconde et imprime sur la sortie : le paquet doit rester importable seul.""" # Dans un interpréteur NEUF : la suite complète importe todo par # ailleurs, et le sys.modules de ce processus en garderait la trace # quel que soit le module éprouvé ici. sortie = subprocess.run( [ sys.executable, "-c", "import sys, script.todo.assistant.discover, script.todo.assistant.fingerprint;" " print('script.todo.todo' in sys.modules)", ], cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), capture_output=True, text=True, timeout=60, ) self.assertEqual(sortie.returncode, 0, sortie.stderr) self.assertEqual(sortie.stdout.strip(), "False") def test_aucun_asyncio_du_depot_n_est_utilise(self): """`AsyncioPool` enveloppe des sous-processus et non des sockets, et passe à `asyncio.wait` un argument retiré de Python 3.10.""" self.assertNotIn("lib_asyncio", _source()) def _source(): """Le texte du module de découverte, non vide.""" with open(discover.__file__, encoding="utf-8") as fh: texte = fh.read() if not re.search(r"def sweep\(", texte): raise AssertionError("source illisible : le module a changé de forme") return texte if __name__ == "__main__": unittest.main()