[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:
Mathieu Benoit 2026-09-09 04:39:01 -04:00
parent 85ac83cc75
commit 2df3be2df8
4 changed files with 220 additions and 25 deletions

View file

@ -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
View 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("!")
]

View file

@ -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)

View 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()