[FIX] ssh config : une seule décision sur ce qu'est un nom de machine
Trois lecteurs de ~/.ssh/config 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é `!nom`. Un alias déclaré « host » en minuscules était donc vu par l'un et invisible aux deux autres, ce qui se lit comme une panne intermittente de la découverte. La décision passe dans un module qui ne lit aucun fichier. Il rend None sur ce qui n'est pas une déclaration et une liste — possiblement vide — sur ce qui en est une : « Host * » ne nomme aucune machine mais reste une déclaration, sinon ses directives se rattachent au bloc précédent. Vérifié : 19 tests, et le menu reconnaît toujours le serveur du réseau par sa source SSH. --- EN --- Three readers of ~/.ssh/config settled the same question differently: one compared the keyword case-sensitively, another required a space where a tab is legal, a third let the negated pattern `!name` through. An alias declared with a lowercase "host" was therefore seen by one and invisible to the other two, which reads as intermittent discovery failure. The decision moves into a module that reads no file. It returns None for what is not a declaration and a list — possibly empty — for what is: "Host *" names no machine yet remains a declaration, otherwise its directives attach to the previous block. Checked: 19 tests, and the menu still recognises the server on the network through its SSH source. Assisted-by: Claude Opus 5
This commit is contained in:
parent
85ac83cc75
commit
2df3be2df8
4 changed files with 220 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
51
script/todo/ssh_config.py
Normal file
51
script/todo/ssh_config.py
Normal file
|
|
@ -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("!")
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
144
test/test_ssh_config_names.py
Normal file
144
test/test_ssh_config_names.py
Normal file
|
|
@ -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()
|
||||
Loading…
Reference in a new issue