diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 9deafa5..84428c1 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -12,7 +12,7 @@ import shutil import subprocess import time -from script.todo import todo_install +from script.todo import ssh_config, todo_install from script.todo.qemu_privilege import ( LIBVIRT_URI as URI, sudo_prefix, @@ -98,15 +98,15 @@ def parse_ssh_blocks(content) -> dict: """{nom: {"hostname": …, "proxyjump": …}} pour CHAQUE nom déclaré. Une ligne « Host » peut en porter plusieurs : ils partagent alors le même - corps, donc la même entrée. Les motifs (« * », « ? ») sont écartés — ce - sont des règles, pas des machines.""" + corps, donc la même entrée. Ce qui compte comme un nom de machine est + tranché par `ssh_config.declared_names`, partagé avec les deux lecteurs + de todo.py.""" blocs, courant = {}, [] for ligne in (content or "").splitlines(): - if re.match(r"^[ \t]*Host[ \t]+", ligne): + declares = ssh_config.declared_names(ligne) + if declares is not None: corps = {} - courant = [ - n for n in ligne.split()[1:] if "*" not in n and "?" not in n - ] + courant = declares for nom in courant: blocs[nom] = corps continue diff --git a/script/todo/ssh_config.py b/script/todo/ssh_config.py new file mode 100644 index 0000000..f5ecc52 --- /dev/null +++ b/script/todo/ssh_config.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce qu'est un nom de machine dans ~/.ssh/config, décidé en un seul endroit. + +Trois lecteurs de ce fichier vivent dans ce dépôt — l'énumération des alias, +le menu de montage qui veut aussi l'adresse, et l'inventaire des entrées de +VM — et ils divergeaient sur la MÊME question. L'un comparait le mot-clé avec +la casse, un autre exigeait un espace là où une tabulation est légale, un +troisième laissait passer le motif nié. Un alias déclaré `host exo` était donc +vu par l'un et invisible aux autres, ce qui se lit comme une panne +intermittente de la découverte. + +Le module ne lit aucun fichier et ne suit pas `Include` : il tranche une +ligne, rien de plus. Un alias déclaré dans un fichier inclus reste donc +invisible aux trois lecteurs, alors même que `ssh -G` le résoudrait — la +source est incomplète sans être fausse. +""" +from __future__ import annotations + +import re + +# Le mot-clé, suivi d'au moins un blanc. ssh_config ne distingue pas la casse +# de ses mots-clés, et sépare par espace OU tabulation. Le blanc est exigé +# sans quoi `HostName` serait lu comme une déclaration d'hôte. +DECLARATION = re.compile(r"^[ \t]*host[ \t]+", re.IGNORECASE) + + +def declared_names(line) -> list | None: + """Les noms de machine d'une ligne « Host », ou None si ce n'en est pas une. + + Rend None pour toute autre ligne, et une liste — possiblement VIDE — + quand la ligne est une déclaration. Distinguer les deux compte : `Host *` + EST une déclaration qui ne nomme aucune machine, et un lecteur qui + accumule un bloc doit clore le précédent malgré tout. Rendre `[]` dans + les deux cas rattachait les directives d'un bloc générique au bloc + précédent. + + Trois formes ne désignent aucune machine et sortent : les jokers `*` et + `?`, qui décrivent une règle appliquée à plusieurs hôtes, et le motif NIÉ + `!nom`, qui RETIRE un nom de l'ensemble que la ligne vient de décrire. + Retenir un motif nié rend une cible dont le nom commence par `!`, que ssh + ne résoudra jamais. + """ + if not DECLARATION.match(line or ""): + return None + return [ + nom + for nom in line.split()[1:] + if "*" not in nom and "?" not in nom and not nom.startswith("!") + ] diff --git a/script/todo/todo.py b/script/todo/todo.py index efdefbf..1cd7042 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -26,7 +26,7 @@ sys.path.append(new_path) from script.config import config_file from script.execute import execute -from script.todo import dev_tools, todo_install, todo_prefs +from script.todo import dev_tools, ssh_config, todo_install, todo_prefs from script.todo.assistant_menu import AssistantMenuMixin from script.todo.database_manager import DatabaseManager from script.todo.kdbx_manager import KdbxManager @@ -1741,20 +1741,23 @@ class TODO( def _ssh_config_hosts(): """Noms d'hôtes déclarés dans ~/.ssh/config, dans l'ordre du fichier. - Une ligne « Host » peut porter plusieurs noms : on les rend tous. Les - motifs (`*`, `?`) sont écartés — ce sont des règles, pas des machines - auxquelles se connecter.""" + Une ligne « Host » peut porter plusieurs noms : on les rend tous. + Ce qui compte comme un nom de machine est tranché par + `ssh_config.declared_names`, en un seul endroit pour les trois + lecteurs de ce fichier — la casse du mot-clé, la tabulation qui + sépare et le motif nié s'y décidaient autrement dans chacun. + + La lecture ne suit PAS `Include` : un alias déclaré dans un fichier + inclus reste invisible ici, alors même que `ssh -G` le résoudrait. La + source est donc incomplète sans être fausse.""" path = os.path.expanduser("~/.ssh/config") names = [] try: with open(path, encoding="utf-8") as fh: for line in fh: - if not re.match(r"^[ \t]*Host[ \t]+", line): - continue - for name in line.split()[1:]: - if "*" in name or "?" in name or name in names: - continue - names.append(name) + for name in ssh_config.declared_names(line) or (): + if name not in names: + names.append(name) except OSError: pass return names @@ -2004,8 +2007,8 @@ class TODO( « Host a b » déclare DEUX alias pour la même machine — c'est ce que todo.py écrit lui-même quand une VM porte plusieurs noms. Les prendre pour un seul nom donnait un alias « a b », que sshfs ne peut pas - monter. Les motifs génériques (« * », « web-? ») sont écartés : ils ne - désignent aucune machine. + monter. Ce qui compte comme un nom de machine est tranché par + `ssh_config.declared_names`, partagé avec les deux autres lecteurs. """ hosts = [] noms = [] @@ -2022,13 +2025,10 @@ class TODO( return [] for ligne in lignes: ligne = ligne.strip() - if ligne.lower().startswith("host "): + declares = ssh_config.declared_names(ligne) + if declares is not None: clore() - noms = [ - m - for m in ligne.split()[1:] - if "*" not in m and "?" not in m and not m.startswith("!") - ] + noms = declares info = {} elif noms: paire = ligne.split(None, 1) diff --git a/test/test_ssh_config_names.py b/test/test_ssh_config_names.py new file mode 100644 index 0000000..0ebfeb1 --- /dev/null +++ b/test/test_ssh_config_names.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce qu'est un nom de machine dans ~/.ssh/config, et pourquoi c'est partagé. + +Trois lecteurs de ce fichier vivent dans ce dépôt, et ils tranchaient la même +question autrement : l'un comparait le mot-clé avec la casse, un autre +exigeait un espace là où une tabulation est légale, un troisième laissait +passer le motif nié. Une découverte voyait donc un alias que le menu de +montage ne voyait pas, ce qui se lit comme une panne intermittente et non +comme trois filtres différents. + +Ces tests portent sur la décision elle-même, puis sur le fait que les trois +lecteurs la partagent — cette dernière assertion est ce qui empêche qu'un +quatrième filtre se réinstalle en douce. + +Les alias sont inventés : `ssh_config` ne résout rien et ne touche aucun +fichier, donc aucun nom réel n'est nécessaire ici. +""" + +import unittest + +from script.todo import ssh_config as sc + + +class TestCeQuiEstUneDeclaration(unittest.TestCase): + def test_a_plain_host_line(self): + self.assertEqual(sc.declared_names("Host alpha"), ["alpha"]) + + def test_lowercase_keyword(self): + """ssh_config ne distingue pas la casse de ses mots-clés.""" + self.assertEqual(sc.declared_names("host alpha"), ["alpha"]) + + def test_uppercase_keyword(self): + self.assertEqual(sc.declared_names("HOST alpha"), ["alpha"]) + + def test_tab_separator(self): + """Une tabulation sépare aussi légalement qu'un espace.""" + self.assertEqual(sc.declared_names("Host\talpha"), ["alpha"]) + + def test_indented_declaration(self): + self.assertEqual(sc.declared_names(" Host alpha"), ["alpha"]) + + def test_several_names_on_one_line(self): + self.assertEqual( + sc.declared_names("Host alpha beta gamma"), + ["alpha", "beta", "gamma"], + ) + + def test_hostname_is_not_a_declaration(self): + """Le blanc exigé derrière le mot-clé est ce qui sépare les deux.""" + self.assertIsNone(sc.declared_names(" HostName 10.83.4.19")) + + def test_another_directive_is_not_a_declaration(self): + self.assertIsNone(sc.declared_names(" User quelquun")) + + def test_an_empty_line_is_not_a_declaration(self): + self.assertIsNone(sc.declared_names("")) + self.assertIsNone(sc.declared_names(None)) + + +class TestCeQuiNeDesigneAucuneMachine(unittest.TestCase): + def test_a_wildcard_is_a_rule(self): + self.assertEqual(sc.declared_names("Host *"), []) + + def test_a_partial_wildcard_is_a_rule(self): + self.assertEqual(sc.declared_names("Host web-*"), []) + + def test_a_single_char_wildcard_is_a_rule(self): + self.assertEqual(sc.declared_names("Host web-?"), []) + + def test_a_negated_pattern_is_removed(self): + """`!nom` RETIRE un nom : le retenir donne une cible en « ! ».""" + self.assertEqual(sc.declared_names("Host alpha !beta"), ["alpha"]) + + def test_only_a_negation_declares_nothing(self): + self.assertEqual(sc.declared_names("Host !beta"), []) + + def test_a_declaration_naming_nothing_is_still_a_declaration(self): + """None et [] ne veulent pas dire la même chose. + + `Host *` EST une déclaration : le lecteur qui accumule un bloc doit + clore le précédent, sinon les directives du bloc générique se + rattachent au bloc d'avant.""" + self.assertIsNotNone(sc.declared_names("Host *")) + self.assertIsNone(sc.declared_names("Compression yes")) + + +class TestLesTroisLecteursPartagentLaDecision(unittest.TestCase): + """Les assertions qui empêchent un quatrième filtre de se réinstaller. + + Elles comptent les APPELS à la décision partagée, lus dans l'arbre + syntaxique : un lecteur qui recompile son propre motif cesse d'appeler, + et le compte tombe. Compter dans le texte accuserait le bon code, une + docstring qui nomme la fonction n'étant pas un appel.""" + + def _source(self, chemin): + from pathlib import Path + + racine = Path(__file__).resolve().parents[1] + return (racine / chemin).read_text(encoding="utf-8") + + def _appels(self, chemin): + """Le nombre d'APPELS à la décision partagée, lus dans l'arbre. + + Compté sur l'arbre et non dans le texte : une docstring qui NOMME la + fonction n'est pas un appel, et une assertion sur le texte compterait + les deux.""" + import ast + + arbre = ast.parse(self._source(chemin)) + return sum( + 1 + for n in ast.walk(arbre) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and n.func.attr == "declared_names" + ) + + def test_qemu_manage_delegates(self): + self.assertEqual(self._appels("script/todo/qemu_manage.py"), 1) + + def test_todo_delegates_twice(self): + """Les DEUX lecteurs de todo.py, pas seulement celui qu'on a corrigé.""" + self.assertEqual(self._appels("script/todo/todo.py"), 2) + + def test_parse_ssh_blocks_sees_a_lowercase_alias(self): + """Le bout de bout : la forme qui échappait aux trois.""" + from script.todo.qemu_manage import parse_ssh_blocks + + blocs = parse_ssh_blocks("host alpha\n\tHostName 10.83.4.19\n") + self.assertIn("alpha", blocs) + self.assertEqual(blocs["alpha"]["hostname"], "10.83.4.19") + + def test_parse_ssh_blocks_drops_a_negation(self): + from script.todo.qemu_manage import parse_ssh_blocks + + blocs = parse_ssh_blocks("Host alpha !beta\n\tUser quelquun\n") + self.assertIn("alpha", blocs) + self.assertNotIn("!beta", blocs) + + +if __name__ == "__main__": + unittest.main()