From 01c5290e30c6e95220f33ed253a5ef71b010ef09 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 14 Sep 2026 14:41:20 -0400 Subject: [PATCH] [FIX] qemu install : poser mise, pyenv et starship, verdict par outil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without pipefail, « curl … | sh » returns the status of sh, 0 on empty input: a failed download passed for an install, its fallback never ran, and the failure surfaced later as « command not found ». mise and pyenv are downloaded into a mktemp file, then run, and a check refuses any fallback behind a pipe without pipefail. starship's installer runs as root under a root timeout, never reaching the « sudo -v » that sudo-rs refuses, and its shell hook is guarded. Each tool then reports the version found or « not installed », without tripping « set -e ». --- FR --- Sans pipefail, « curl … | sh » rend le statut de sh, 0 sur une entrée vide : un téléchargement raté passait pour une pose, son repli ne tournait jamais, et l'échec se lisait plus loin sous « command not found ». mise et pyenv sont téléchargés dans un fichier tiré par mktemp, puis exécutés, et un contrôle refuse tout repli derrière un tube sans pipefail. L'installateur de starship tourne en root sous un délai root, sans atteindre le « sudo -v » que sudo-rs refuse, et son crochet de shell est gardé. Chaque outil dit ensuite la version trouvée ou « non installé », sans faire tomber « set -e ». Assisted-by: Claude Opus 5 --- script/install/lib_python_provider.sh | 34 +- script/todo/dev_tools.py | 50 ++- script/todo/qemu_install.py | 115 +++++- script/todo/todo_i18n.py | 16 + test/test_install_pipe_to_shell.py | 568 +++++++++++++++++++++++++- test/test_qemu_ai_tools.py | 270 +++++++++++- test/test_qemu_desktop_tools.py | 134 +++++- test/test_todo_shell_tools.py | 22 +- 8 files changed, 1164 insertions(+), 45 deletions(-) diff --git a/script/install/lib_python_provider.sh b/script/install/lib_python_provider.sh index 6d06740..5aa83c1 100755 --- a/script/install/lib_python_provider.sh +++ b/script/install/lib_python_provider.sh @@ -107,18 +107,36 @@ el_mise_install() { # Installe la version via pyenv, en posant pyenv lui-même au besoin. el_pyenv_install() { - local version="$1" root exe + local version="$1" root exe installer root="$(el_pyenv_root)" exe="$(el_pyenv_exec_path "${version}")" if [[ ! -d "${root}" ]]; then echo "---- Installation de pyenv dans ${root} ----" >&2 - # « -f » : sans lui, curl livre le CORPS d'une erreur HTTP à bash, qui - # l'exécute. Une page d'erreur de miroir, un portail captif ou le 504 - # d'un cache hors ligne devient alors une suite de commandes, et le - # lecteur reçoit « command not found » à la place de la cause. Avec - # « -f », curl rend non nul et le repli ci-dessous s'énonce. - curl -fsSL https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer \ - | bash >&2 || return 1 + # Téléchargé dans un fichier, PUIS exécuté. Quand curl alimente bash par + # un tube, le statut du tube est celui de bash, qui rend 0 sur une entrée + # vide : sans pipefail — que rien ne pose dans cette chaîne —, un + # « || return 1 » placé après le tube ne se déclencherait jamais. Un + # téléchargement raté mènerait alors à « pyenv: command not found », puis + # à un échec de compilation, deux messages qui accusent la mauvaise + # étape. Lu seul, le statut de curl nomme la vraie cause. + # + # « -f » : sans lui, curl écrit le CORPS d'une erreur HTTP — page d'erreur + # de miroir, portail captif, 504 d'un cache hors ligne —, que bash + # exécuterait ensuite comme un script. + installer="$(mktemp)" || return 1 + if ! curl -fsSL -o "${installer}" \ + https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer; then + echo "Telechargement de l'installateur pyenv impossible" \ + "(reseau ou cache) : pyenv n'est pas pose." >&2 + rm -f "${installer}" + return 1 + fi + if ! bash "${installer}" >&2; then + echo "L'installateur de pyenv a echoue (voir ci-dessus)." >&2 + rm -f "${installer}" + return 1 + fi + rm -f "${installer}" fi export PATH="${root}/bin:$PATH" eval "$(pyenv init - 2> /dev/null)" || true diff --git a/script/todo/dev_tools.py b/script/todo/dev_tools.py index 7402899..13b0b68 100644 --- a/script/todo/dev_tools.py +++ b/script/todo/dev_tools.py @@ -34,14 +34,58 @@ STARSHIP_UPSTREAM = f"curl -fsSL {STARSHIP_URL} | sh" # Sans terminal — une pose par SSH dans une VM — l'installateur demande une # confirmation que personne ne donnera. « -y » la donne d'avance. -STARSHIP_UPSTREAM_YES = f"curl -fsSL {STARSHIP_URL} | sh -s -- -y" +# +# Et il tourne en root. Sans droit d'écriture sur /usr/local/bin, son +# répertoire par défaut, l'installateur appelle « sudo -v » AVANT de +# télécharger quoi que ce soit. Or « sudo -v » exige un mot de passe dès +# qu'une seule règle sudoers qui vise le compte n'est pas NOPASSWD — celle +# du groupe d'administration ne l'est pas —, et sudo-rs ignore « verifypw », +# le réglage qui l'en dispenserait. En root le répertoire est inscriptible : +# aucun « sudo -v » n'est atteint, et le binaire atterrit dans un répertoire +# que porte le PATH de tout compte, SSH non interactif compris. +# +# La borne de temps passe DERRIÈRE sudo. Un « timeout » lancé sans privilège +# ne peut pas tuer un installateur root : son signal au groupe de processus +# échoue en EPERM sur les processus root, et sudo ne relaie pas un signal +# venu de son propre groupe. Un « timeout » root, lui, tue l'installateur et +# ses enfants. Il reste en deçà de la borne extérieure de la pose, qui ne +# couvre plus que la moitié non privilégiée du tube : curl. +# +# « -k » : SIGTERM se laisse ignorer, et « timeout » seul attendrait alors +# sans fin. Passé ce délai, il envoie SIGKILL, qui ne s'ignore pas. Borne et +# délai additionnés restent sous la borne extérieure. +STARSHIP_ROOT_TIMEOUT = 280 +STARSHIP_ROOT_KILL_AFTER = 10 +STARSHIP_UPSTREAM_VM = ( + f"curl -fsSL {STARSHIP_URL}" + f" | sudo timeout -k {STARSHIP_ROOT_KILL_AFTER}" + f" {STARSHIP_ROOT_TIMEOUT} sh -s -- -y" +) # Ce que chaque shell écrit pour lancer starship. La ligne va en FIN de # fichier : starship compose le prompt et doit passer après tout ce qui y # touche. +# +# bash et zsh la gardent par « command -v » : sans binaire — une pose qui a +# échoué, un binaire retiré depuis —, « eval "$(starship init …)" » écrirait +# « command not found » à l'ouverture de chaque shell. La garde est DANS la +# ligne, et non autour de son écriture : elle vaut aussi pour le binaire qui +# disparaît après coup. « starship init » y reste en clair, parce que c'est +# le motif qui dédoublonne, dans le menu de l'hôte comme dans la VM. +# +# Un « if » et non un « && » : la ligne est la DERNIÈRE du fichier, et le +# fichier rend son statut. « a && b » rend 1 quand starship manque, si bien +# qu'un script sous « set -e » qui lit ce fichier s'arrêterait là ; un « if » +# sans branche prise rend 0. STARSHIP_LINE = { - "bash": 'eval "$(starship init bash)"', - "zsh": 'eval "$(starship init zsh)"', + "bash": ( + "if command -v starship >/dev/null 2>&1; then" + ' eval "$(starship init bash)"; fi' + ), + "zsh": ( + "if command -v starship >/dev/null 2>&1; then" + ' eval "$(starship init zsh)"; fi' + ), "fish": "starship init fish | source", } diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index 7f569b4..97d9d2c 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -1592,6 +1592,13 @@ class QemuInstallMixin: Le tout dans un groupe gardé : ni une panne de réseau ni une extension retirée du site ne doivent faire échouer une installation d'une heure. + + Deux échecs, deux messages. Un téléchargement raté — réseau coupé, + cache qui n'a pas l'archive — n'apprend rien sur la version de GNOME : + le site sert une archive même à une version qu'il ne connaît pas. Seul + un refus de « gnome-extensions install » la met en cause. Et l'appel à + se reconnecter ne vient que si au moins une extension a été posée : + sans quoi il n'y a rien à charger. """ uuids = " ".join(self._QEMU_GNOME_EXT_UUIDS) site = self._QEMU_GNOME_EXT_SITE @@ -1617,19 +1624,31 @@ class QemuInstallMixin: 'gx() { if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && ' "command -v dbus-run-session >/dev/null 2>&1; then " 'dbus-run-session -- gnome-extensions "$@"; ' - 'else gnome-extensions "$@"; fi; }; ' + f"for u in {uuids}; do " - # « || echo » DANS la substitution : un mktemp qui échoue rendrait - # l'affectation non nulle, et « set -e » couperait toute la suite. - + "z=$(mktemp -p /var/tmp gext-XXXX.zip || echo /var/tmp/gext.zip); " - + 'if curl -fsSL --max-time 120 "' + 'else gnome-extensions "$@"; fi; }; ' + # `n` compte les extensions réellement posées. + + f"n=0; for u in {uuids}; do " + # L'archive ne va que dans un nom tiré par mktemp, créé par ce + # compte seul : un nom fixe dans /var/tmp, ouvert à tous, pourrait + # y être posé d'avance par un autre. L'affectation est DANS la + # condition, si bien qu'un mktemp qui échoue ne fait pas tomber + # « set -e » ; « z » reste alors vide, et l'extension est sautée + # comme un téléchargement raté, sans rien à effacer. + + "if ! z=$(mktemp -p /var/tmp gext-XXXX.zip 2>/dev/null); " + + 'then z=""; fi; ' + + 'if [ -z "$z" ] || ! curl -fsSL --max-time 120 "' + site - + '/$u.shell-extension.zip?shell_version=$sv" -o "$z" ' - + '&& gx install --force "$z" >/dev/null 2>&1; then ' - + 'gx enable "$u" >/dev/null 2>&1 || true; ' - + f'echo " {t("installed and enabled:")} $u"; else ' + + '/$u.shell-extension.zip?shell_version=$sv" -o "$z"; then ' + + f'echo " ⚠ {t("download impossible (network or cache):")} ' + + '$u"; ' + + 'elif ! gx install --force "$z" >/dev/null 2>&1; then ' + f'echo " {t("not available for this GNOME, skipped:")} ' - + '$u (GNOME $sv)"; fi; rm -f "$z"; done; ' - + f'echo " {t("log out and back in to load them")}"; ' + + '$u (GNOME $sv)"; ' + + "else " + + 'gx enable "$u" >/dev/null 2>&1 || true; n=$((n+1)); ' + + f'echo " {t("installed and enabled:")} $u"; ' + + 'fi; if [ -n "$z" ]; then rm -f "$z"; fi; done; ' + + 'if [ "$n" -gt 0 ]; then ' + + f'echo " {t("log out and back in to load them")}"; fi; ' + "fi; } || true; " ) @@ -2210,6 +2229,13 @@ class QemuInstallMixin: « > {fichier}; " ) + def verdict(var, nom, repli=""): + # `var` reçoit le chemin du binaire : « command -v » d'abord, puis + # `repli`, le chemin où l'installateur le pose hors du PATH de ce + # shell. Aucune ligne ne peut faire tomber « set -e » : l'« if » + # absorbe le test, et la version se lit sous « timeout », sans + # entrée, comme une pose. + trouve = f'echo "{repli}"' if repli else "true" + return ( + f'{var}="$(command -v {nom} || {trouve})"; ' + f'if [ -x "${var}" ]; then echo " {nom}: ' + f'$(timeout 10 "${var}" --version &1' + ' | head -n 1 || true)"; ' + f'else echo " ⚠ {nom} {t("not installed (see above)")}"; ' + "fi; " + ) + return ( f'echo "== {t("AI coding tools")} =="; ' + self._qemu_pkg_install_cmd(self._QEMU_AIDEV_PKGS) @@ -2256,12 +2300,18 @@ class QemuInstallMixin: # commande distante a été figé au démarrage du shell SSH, avant que # l'installateur ne pose le binaire. Le code 127 qu'on obtiendrait # sinon ne dirait pas que le hook n'a pas été écrit. - + f'RTK="$(command -v rtk || echo "{local_bin}/rtk")"; ' + + verdict("RTK", "rtk", f"{local_bin}/rtk") + '[ -x "$RTK" ] && timeout 60 "$RTK" init --global' " /dev/null 2>&1 || true; " - + pose(dev_tools.STARSHIP_UPSTREAM_YES, 300) + # En root, starship atterrit dans /usr/local/bin, que le PATH de + # ce shell porte déjà : « command -v » le trouve sans repli. Ces + # 300 s ne bornent que curl : l'installateur root porte sa propre + # borne, plus courte, derrière sudo — voir STARSHIP_UPSTREAM_VM. + + pose(dev_tools.STARSHIP_UPSTREAM_VM, 300) + + verdict("STARSHIP", "starship") + une_fois(prompt, "starship init bash") + pose(commande, 600) + + verdict("AGENT", nom_agent, f"{repertoire}/{nom_agent}") + une_fois(local_line, local_bin) # Claude Code s'installe DANS ~/.local/bin : la ligne serait la # même, écrite deux fois dans le journal pour un seul effet. @@ -2449,7 +2499,20 @@ class QemuInstallMixin: défaut — même raison que pour cargo et rustc. Sans mise utilisable, rien n'est écrit : lib_python_provider.sh - retombe alors sur pyenv toute seule.""" + retombe alors sur pyenv toute seule. + + L'installateur est téléchargé dans un fichier, PUIS exécuté. Dans + « curl … | sh || repli », le statut du tube est celui de sh, qui rend + 0 sur une entrée vide : sans pipefail, le repli ne se déclencherait + jamais, et un téléchargement raté passerait pour une pose réussie. Le + statut de curl, lu seul, distingue les deux échecs — rien obtenu, + ou un installateur qui a échoué — et chacun a son message. Aucun ne + fait tomber « set -e » : ils sont testés dans un « if ». + + Le fichier ne s'obtient que de mktemp : un nom aléatoire, créé par ce + compte seul. Sans mktemp, rien n'est téléchargé. Un nom fixe dans + /tmp, qu'un autre compte peut créer d'avance, serait exécuté par + root.""" if python_provider == "pyenv": # Explicite : même si mise se trouvait déjà dans l'image, on ne # l'utilise pas. Sans cela le mode « auto » du dépôt le prendrait. @@ -2461,11 +2524,23 @@ class QemuInstallMixin: "if command -v mise >/dev/null 2>&1; then " 'echo " mise: $(mise --version)"; ' "else " + # L'affectation est DANS la condition : un mktemp qui échoue prend + # la branche du téléchargement impossible au lieu de faire tomber + # « set -e ». `f` y reste vide, et « rm -f "" » rend 0. + "if ! f=$(mktemp 2>/dev/null) " + '|| ! curl -fsSL https://mise.run -o "$f"; then ' + 'echo " ⚠ ' + + t( + "mise download impossible (network or cache): " + "pyenv will take over" + ) + + '"; ' # La variable est passée À sudo, pas exportée avant : « sudo -E » # dépend de env_reset dans sudoers et n'est pas garanti. - "curl -fsSL https://mise.run " - "| sudo MISE_INSTALL_PATH=/usr/local/bin/mise sh " - '|| echo " mise indisponible ici : pyenv prendra le relais"; ' + + 'elif ! sudo MISE_INSTALL_PATH=/usr/local/bin/mise sh "$f"' + " &2 »). Un +# « && » est traversé : dans « tube && a || repli », le repli suit aussi un +# tube qui a « réussi » sur une entrée vide, c'est-à-dire jamais. +REPLI = re.compile(r"(?:[^\n;&|]|\\\n|\|(?!\|)|&&|&(?!&))*\|\|\s*([^\n;]*)") + +# Un « set » et ses arguments, jusqu'à la fin de la commande. +SET = re.compile(r"\bset((?:[ \t]+[^\s;&|()]+)+)") + +# Le tube est-il la condition d'un « if » ou d'un « elif » ? Rien ne sépare +# le mot-clé du tube, sinon un « && » ou un « || ». Groupe 1 : un « ! » qui +# nie la condition. +SI = re.compile(r"\b(?:if|elif)\s+(!\s+)?([^;\n]*)$") + +# Jusqu'au « then » de ce « if » : la fin de la condition. +ALORS = re.compile(r"(?:[^\n;]|\\\n)*[;\n]\s*then\b") + +MOTS_SI = re.compile(r"\b(if|elif|else|fi)\b") + + +def bascules_pipefail(texte: str): + """(position, allumé) de chaque réglage de pipefail, hors commentaire. + + « set -o pipefail » et ses formes groupées (« -eo », « -euo ») allument : + le « o » est la dernière lettre d'une option à MOINS, et le mot suivant + est « pipefail ». « set +o pipefail » éteint. Dans un même « set », le + dernier réglage l'emporte, comme pour le shell. + """ + for m in SET.finditer(texte): + debut_ligne = texte.rfind("\n", 0, m.start()) + 1 + if re.search(r"(?:^|\s)#", texte[debut_ligne : m.start()]): + continue + mots = m.group(1).split() + for mot, suivant in zip(mots, mots[1:]): + if suivant == "pipefail" and re.fullmatch(r"[-+]\w*o", mot): + yield m.start(), mot[0] == "-" + + +def debut_de_ligne_logique(src: str, pos: int) -> int: + """Le début de la ligne qui porte `pos`, continuations « \\ » comprises.""" + i = src.rfind("\n", 0, pos) + while i > 0 and src[i - 1] == "\\": + i = src.rfind("\n", 0, i - 1) + return i + 1 + + +def prologue(src: str) -> int: + """La fin de l'en-tête du fichier : lignes vides, commentaires et « set » + qui précèdent la première autre commande.""" + fin = 0 + for ligne in src.splitlines(keepends=True): + nue = ligne.strip() + if nue and not nue.startswith("#") and not re.match(r"set\b", nue): + break + fin += len(ligne) + return fin + + +def pipefail_actif(src: str, pos: int) -> bool: + """pipefail vaut-il pour le tube qui commence à `pos` ? + + Deux portées, et deux seulement. D'abord la ligne logique du tube, avant + lui : le dernier réglage l'emporte. Les sous-shells refermés « ( … ) », + « $( … ) » et les chaînes refermées « '…' » en sont retirés, parce qu'un + « set » posé là ne vaut que pour eux. Sans réglage sur la ligne, l'en-tête + du fichier : il doit allumer pipefail, et aucun « set +o pipefail » ne + doit l'éteindre entre lui et le tube. + + Un pipefail posé ailleurs — dans une fonction, plus bas dans le fichier — + ne compte pas. L'erreur penche du côté du signalement : un repli exempté + à tort serait un repli sans effet que plus rien ne signale. + """ + ligne = src[debut_de_ligne_logique(src, pos) : pos] + retire = 1 + while retire: + ligne, retire = re.subn(r"\([^()]*\)", "", ligne) + ligne = re.sub(r"'[^']*'", "", ligne) + sur_la_ligne = list(bascules_pipefail(ligne)) + if sur_la_ligne: + return sur_la_ligne[-1][1] + fin = prologue(src) + en_tete = list(bascules_pipefail(src[:fin])) + if not en_tete or not en_tete[-1][1]: + return False + return all(allume for _p, allume in bascules_pipefail(src[fin:pos])) + + +def branche_de_repli(src: str, debut: int, niee: bool): + """La branche qu'un « if » réserve à l'échec de sa condition. + + `debut` suit le « then ». Condition niée (« if ! … »), c'est la branche + « then » elle-même ; sinon, le « else » ou l'« elif » de ce même « if », + les « if » imbriqués étant sautés. None s'il n'y en a pas. + """ + profondeur = 0 + for m in MOTS_SI.finditer(src, debut): + mot = m.group(1) + if mot == "if": + profondeur += 1 + elif mot == "fi" and profondeur: + profondeur -= 1 + elif profondeur == 0 and niee: + return src[debut : m.start()].strip(" \t\n;") + elif mot == "fi": + return None + elif profondeur == 0: + fin = src.find("\n", m.end()) + corps = src[m.end() : fin if fin >= 0 else len(src)] + corps = corps.strip(" \t;") + return corps if mot == "else" else f"elif {corps}" + return None + + +def repli_du_tube(src: str, m): + """Le texte du repli qui lit le statut du tube `m`, ou None. + + Deux formes : un « || » derrière le tube, et un « if » dont le tube est + la condition.""" + suite = REPLI.match(src, m.end()) + if suite: + return suite.group(1) + debut = debut_de_ligne_logique(src, m.start()) + si = SI.search(src[debut : m.start()].replace("\\\n", " ")) + if not si: + return None + alors = ALORS.match(src, m.end()) + if not alors: + return None + niee = bool(si.group(1)) and not re.search(r"&&|\|\|", si.group(2)) + return branche_de_repli(src, alors.end(), niee) def demande_lechec(options: str) -> bool: @@ -42,20 +206,71 @@ def demande_lechec(options: str) -> bool: return False +def replis_sans_effet(src: str): + """Les replis qui lisent le statut d'un tube vers un shell, sans pipefail. + + Rend le texte de chaque repli. « || true » et « || : » n'en sont pas : + ils absorbent un échec, ils n'y réagissent pas, et ne promettent donc + rien que le tube ne tienne. + """ + trouves = [] + for m in TUBE.finditer(src): + if pipefail_actif(src, m.start()): + continue + repli = repli_du_tube(src, m) + if repli is None: + continue + mots = repli.split() + premier = mots[0].strip("'\")") if mots else "" + if premier not in ("true", ":"): + trouves.append(repli.strip()) + return trouves + + def scripts(): for chemin in sorted(RACINE.glob("script/**/*.sh")): yield chemin, chemin.read_text(encoding="utf-8", errors="replace") +def commandes_de_lhote(): + """Les commandes que l'hôte compose et envoie à une VM, par leur nom. + + Elles ne vivent dans aucun .sh : un tube y est coupé entre plusieurs + littéraux Python, qu'une lecture ligne à ligne du source ne recolle pas. + On les construit donc, comme le fait le déploiement. + """ + todo = TODO.__new__(TODO) + yield "dev_tools.RTK_UPSTREAM", dev_tools.RTK_UPSTREAM + yield "dev_tools.STARSHIP_UPSTREAM", dev_tools.STARSHIP_UPSTREAM + yield "dev_tools.STARSHIP_UPSTREAM_VM", dev_tools.STARSHIP_UPSTREAM_VM + for agent, (commande, _repertoire) in sorted(dev_tools.AGENTS.items()): + yield f"dev_tools.AGENTS[{agent}]", commande + yield ( + f"_qemu_aidev_remote_cmd({agent})", + todo._qemu_aidev_remote_cmd(agent), + ) + yield "_qemu_mise_remote_cmd(mise)", todo._qemu_mise_remote_cmd("mise") + yield "_qemu_gnome_ext_remote_cmd", todo._qemu_gnome_ext_remote_cmd() + yield "_qemu_pycharm_remote_cmd", todo._qemu_pycharm_remote_cmd() + yield ( + "_qemu_android_studio_remote_cmd", + todo._qemu_android_studio_remote_cmd(), + ) + + +def sources(): + for chemin, src in scripts(): + yield str(chemin.relative_to(RACINE)), src + yield from commandes_de_lhote() + + class TestUnTelechargementTubeDansUnShell(unittest.TestCase): def test_curl_doit_echouer_sur_une_erreur_http(self): fautifs = [] - for chemin, src in scripts(): + for nom, src in sources(): for options in TUBE.findall(src): if not demande_lechec(options): - fautifs.append( - f"{chemin.relative_to(RACINE)} : curl {options.strip()}" - ) + fautifs.append(f"{nom} : curl {options.strip()}") self.assertEqual( fautifs, [], @@ -63,6 +278,21 @@ class TestUnTelechargementTubeDansUnShell(unittest.TestCase): + "\n ".join(fautifs), ) + def test_aucun_repli_derriere_un_tube_sans_pipefail(self): + """Le repli d'un tube sans pipefail ne se déclenche jamais : c'est + le statut de l'interpréteur qu'il lit, 0 sur une entrée vide.""" + fautifs = [ + f"{nom} : || {repli}" + for nom, src in sources() + for repli in replis_sans_effet(src) + ] + self.assertEqual( + fautifs, + [], + "repli sans effet, télécharger dans un fichier :\n " + + "\n ".join(fautifs), + ) + def test_les_options_groupees_sont_reconnues(self): """« -sSf » vaut « -s -S -f » : les traiter comme un seul jeton signalerait à tort la moitié des appels du dépôt.""" @@ -71,6 +301,136 @@ class TestUnTelechargementTubeDansUnShell(unittest.TestCase): for mauvais in ("-L", "-sSL", "", "--silent"): self.assertFalse(demande_lechec(f"{mauvais} https://x"), mauvais) + def test_le_motif_voit_un_sudo_devant_linterpreteur(self): + """Un installateur qui écrit hors du HOME passe par sudo, avec ses + options et ses variables : le motif doit le voir quand même.""" + for tube in ( + "curl -sL https://example.invalid/i | sh", + "curl -sL https://example.invalid/i | bash -s -- -y", + "curl -sL https://example.invalid/i | sudo sh", + "curl -sL https://example.invalid/i | sudo -E bash", + "curl -sL https://example.invalid/i | sudo -n -E bash", + "curl -sL https://example.invalid/i | sudo A=/opt/a sh", + "curl -sL https://example.invalid/i | sudo -E A=1 B=2 sh -s", + "curl -sL https://example.invalid/i | sudo timeout 280 sh -s -- -y", + "curl -sL https://example.invalid/i" + " | sudo timeout -k 10 280 sh -s -- -y", + "curl -sL https://example.invalid/i | timeout 60 bash", + "curl -sL https://example.invalid/i | sudo -n timeout -k 5 9m sh", + "curl -sL https://example.invalid/i | sudo timeout --kill-after=5" + " 280 sh", + ): + self.assertEqual( + ["-sL https://example.invalid/i "], TUBE.findall(tube), tube + ) + for pas_un_shell in ( + "curl -sL https://example.invalid/i | shasum", + "curl -sL https://example.invalid/i | sudo tee /x", + "curl -sL https://example.invalid/i | sudo timeout 280 tee /x", + "curl -sL https://example.invalid/i -o /tmp/i; sh /tmp/i", + ): + self.assertEqual([], TUBE.findall(pas_un_shell), pas_un_shell) + + def test_un_repli_derriere_le_tube_est_reconnu(self): + """Les formes qui promettent un repli qu'elles ne tiennent pas.""" + for tube in ( + "curl -fsSL https://example.invalid/i | sh || echo repli", + "curl -fsSL https://example.invalid/i | bash >&2 || return 1", + "curl -fsSL https://example.invalid/i | sudo A=1 sh || exit 1", + "curl -fsSL https://example.invalid/i \\\n | bash \\\n || return 1", + "timeout 9 sh -c 'curl -fsSL https://example.invalid/i | sh'" + " > "{self.trace}"\nexit 1\n') + + def _faux(self, nom, corps): + chemin = self.faux / nom + chemin.write_text("#!/bin/bash\n" + corps, encoding="utf-8") + chemin.chmod(0o755) + + def _installer(self): + env = { + "PATH": str(self.faux), + "HOME": str(self.dir), + "TMPDIR": str(self.dir), + "PYENV_ROOT": str(self.dir / "pyenv"), + } + return subprocess.run( + [ + str(self.faux / "bash"), + "-c", + f'source "{self.LIB}"; el_pyenv_install 3.12.10', + ], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + + def test_un_telechargement_rate_sarrete_et_le_dit(self): + self._faux( + "curl", + 'echo "curl: (22) The requested URL returned error: 504" >&2\n' + "exit 22\n", + ) + fini = self._installer() + self.assertNotEqual(0, fini.returncode) + self.assertIn("installateur pyenv impossible", fini.stderr) + self.assertFalse( + self.trace.exists(), + "la suite a tourné sans pyenv : " + + (self.trace.read_text() if self.trace.exists() else ""), + ) + + def test_un_installateur_qui_echoue_sarrete_et_le_dit(self): + """Le fichier téléchargé est bien celui qui s'exécute, et son échec + arrête la fonction avant toute compilation.""" + marque = self.dir / "installateur.a.tourne" + self._faux( + "curl", + # curl … -o FICHIER URL : on écrit l'installateur dans FICHIER. + 'while [ "$#" -gt 0 ]; do [ "$1" = -o ] && dest="$2"; shift;' + " done\n" + f"printf 'touch \"%s\"\\nexit 3\\n' '{marque}' > \"$dest\"\n", + ) + fini = self._installer() + self.assertNotEqual(0, fini.returncode) + self.assertTrue(marque.exists(), fini.stderr) + self.assertIn("installateur de pyenv a echoue", fini.stderr) + self.assertFalse(self.trace.exists()) + + +class TestLeReplisDeMiseSeDeclenche(unittest.TestCase): + """La pose de mise dans une VM, composée par l'hôte. + + Deux échecs, deux messages, et aucun ne fait tomber « set -e » : pyenv + prend le relais. La commande tourne pour de vrai dans un PATH où curl et + sudo sont faux — et où mise n'est PAS, pour que la pose soit tentée même + sur un hôte qui l'a. + """ + + def setUp(self): + from unittest import mock + + with mock.patch("script.todo.qemu_install.t", lambda k: k): + self.cmd = TODO.__new__(TODO)._qemu_mise_remote_cmd("mise") + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + self.faux = self.dir / "bin" + self.faux.mkdir() + for outil in ("sh", "bash", "env", "mktemp", "rm", "cat", "touch"): + os.symlink(shutil.which(outil), self.faux / outil) + self.marque = self.dir / "installateur.a.tourne" + self.trace = self.dir / "sudo.trace" + self._faux( + "curl", + 'if [ -n "$ECHEC" ]; then exit 22; fi\n' + 'while [ "$#" -gt 0 ]; do [ "$1" = -o ] && dest="$2"; shift;' + " done\n" + f"printf 'touch \"%s\"\\nexit ${{RC_INSTALL:-0}}\\n' " + f"'{self.marque}' > \"$dest\"\n", + ) + # Trace, puis exécute SANS privilège ; « env » lit les + # « VAR=valeur » de tête comme le fait sudo. + self._faux("sudo", f'echo "$*" >> "{self.trace}"\nexec env "$@"\n') + + def _faux(self, nom, corps): + chemin = self.faux / nom + chemin.write_text("#!/bin/sh\n" + corps, encoding="utf-8") + chemin.chmod(0o755) + + def _lancer(self, **env_en_plus): + env = { + "PATH": str(self.faux), + "HOME": str(self.dir), + "TMPDIR": str(self.dir), + **env_en_plus, + } + script = ( + "set -e\n" + self.cmd + '\necho "PROVIDER=$EL_PYTHON_PROVIDER"' + ) + return subprocess.run( + [str(self.faux / "bash"), "-c", script], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + + def test_un_telechargement_rate_le_dit_sans_rien_lancer(self): + fini = self._lancer(ECHEC="1") + self.assertEqual(0, fini.returncode, fini.stderr) + self.assertIn("⚠ mise download impossible", fini.stdout) + self.assertIn("PROVIDER=auto", fini.stdout) + self.assertFalse(self.trace.exists(), "sudo lancé sans installateur") + + def test_un_installateur_qui_echoue_le_dit(self): + fini = self._lancer(RC_INSTALL="5") + self.assertEqual(0, fini.returncode, fini.stderr) + self.assertTrue(self.marque.exists()) + self.assertIn("⚠ mise installer failed", fini.stdout) + self.assertIn("PROVIDER=auto", fini.stdout) + self.assertIn( + "MISE_INSTALL_PATH=/usr/local/bin/mise sh", self.trace.read_text() + ) + + def test_une_pose_reussie_se_tait(self): + fini = self._lancer() + self.assertEqual(0, fini.returncode, fini.stderr) + self.assertTrue(self.marque.exists()) + self.assertNotIn("⚠", fini.stdout) + self.assertIn("PROVIDER=auto", fini.stdout) + + def test_sans_mktemp_rien_nest_telecharge_ni_lance(self): + """Aucun nom de repli ne remplace mktemp : root exécuterait ce qu'un + autre compte aurait pu déposer d'avance sous ce nom.""" + # Le lien vers le vrai mktemp est retiré AVANT d'écrire le faux : + # écrire à travers le lien viserait le binaire de l'hôte. + (self.faux / "mktemp").unlink() + self._faux("mktemp", "exit 1\n") + fini = self._lancer() + self.assertEqual(0, fini.returncode, fini.stderr) + self.assertIn("⚠ mise download impossible", fini.stdout) + self.assertIn("PROVIDER=auto", fini.stdout) + self.assertFalse(self.marque.exists(), "installateur lancé") + self.assertFalse(self.trace.exists(), "sudo lancé sans installateur") + + def test_aucun_chemin_fixe_dans_tmp(self): + """Le fichier que root exécute ne vient que de mktemp.""" + self.assertNotIn("/tmp/", self.cmd) + self.assertIn("f=$(mktemp 2>/dev/null)", self.cmd) + if __name__ == "__main__": unittest.main() diff --git a/test/test_qemu_ai_tools.py b/test/test_qemu_ai_tools.py index c0506b0..d7a34d3 100644 --- a/test/test_qemu_ai_tools.py +++ b/test/test_qemu_ai_tools.py @@ -27,6 +27,7 @@ Ce que ces tests gardent : import importlib.util import shlex +import shutil import subprocess import sys import unittest @@ -133,14 +134,21 @@ class LaCommandeDistante(unittest.TestCase): rallonge son ~/.bashrc — ou son historique — d'une ligne identique. Aucun ajout n'échappe à la règle : on compte les « >> » et non les - greps, pour qu'une ligne ajoutée sans garde fasse tomber le test.""" + greps, pour qu'une ligne ajoutée sans garde fasse tomber le test. + Chaque « >> » doit clore l'idiome ENTIER — grep, puis « || echo » de + la ligne citée —, lu d'un seul motif : la ligne citée peut porter + elle-même des « ; », et un découpage sur « ; » la couperait.""" + import re + + cite = r"(?:'(?:[^']|'\"'\"')*'|[^\s']+)" + garde = re.compile( + rf"grep -qF {cite} \S+ 2>/dev/null \|\| echo {cite} >> \S+" + ) for agent in ("claude", "opencode"): cmd = self._cmd(agent) with self.subTest(agent=agent): self.assertEqual(cmd.count(">> "), cmd.count("grep -qF")) - for morceau in cmd.split("; "): - if ">> " in morceau: - self.assertIn("grep -qF", morceau) + self.assertEqual(cmd.count(">> "), len(garde.findall(cmd))) def test_the_local_bin_is_on_the_path_for_every_agent(self): """rtk se pose dans ~/.local/bin. La ligne de l'agent ne couvre ce @@ -441,5 +449,259 @@ class LaSpec(unittest.TestCase): self.assertEqual("qui@exemple.invalid", spec["git_email"]) +# Outils réels prêtés à la commande distante dans un bac à sable : aucun ne +# touche au réseau, aux paquets ni aux droits. +_OUTILS_INOFFENSIFS = ( + "sh", + "bash", + "env", + "timeout", + "grep", + "chmod", + "head", + "mktemp", + "rm", + "cat", + "mkdir", + "touch", +) + + +def _bac_a_sable(test, faux, outils=_OUTILS_INOFFENSIFS): + """Un PATH qui ne porte QUE des faux et des outils inoffensifs. + + `faux` : nom -> corps d'un script sh posé dans ce PATH. curl, sudo et + les gestionnaires de paquets n'y sont que FAUX, ou pas du tout : la + commande distante tourne pour de vrai sans jamais atteindre le réseau + ni l'hôte. Le HOME est jetable. Rend (env, répertoire du PATH, HOME). + """ + import os + import shutil + import tempfile + + tmp = tempfile.TemporaryDirectory() + test.addCleanup(tmp.cleanup) + racine = Path(tmp.name) + faux_bin = racine / "bin" + home = racine / "home" + faux_bin.mkdir() + home.mkdir() + for outil in outils: + os.symlink(shutil.which(outil), faux_bin / outil) + for nom, corps in faux.items(): + chemin = faux_bin / nom + chemin.write_text("#!/bin/sh\n" + corps, encoding="utf-8") + chemin.chmod(0o755) + env = { + "PATH": str(faux_bin), + "HOME": str(home), + "TMPDIR": str(racine), + "FAUXBIN": str(faux_bin), + } + return env, faux_bin, home + + +# Faux curl : à chaque installateur amont, un script qui pose un binaire +# factice là où le vrai le poserait — starship dans le PATH, comme en root. +# Avec ECHEC, il rend 22 sans rien écrire, comme sur un 504. +_FAUX_CURL = ( + 'if [ -n "$ECHEC" ]; then echo "curl: (22) 504" >&2; exit 22; fi\n' + 'case "$*" in\n' + " *rtk*) echo 'poser rtk \"$HOME/.local/bin\"' ;;\n" + " *starship*) echo 'poser starship \"$FAUXBIN\"' ;;\n" + " *claude.ai*) echo 'poser claude \"$HOME/.local/bin\"' ;;\n" + " *opencode*) echo 'poser opencode \"$HOME/.opencode/bin\"' ;;\n" + " *) exit 22 ;;\n" + "esac\n" +) +_FAUX_POSER = ( + 'mkdir -p "$2"\n' + 'printf \'#!/bin/sh\\necho "%s 0.0.1"\\n\' "$1" > "$2/$1"\n' + 'chmod +x "$2/$1"\n' +) +# Trace, puis exécute SANS privilège ; « env » lit les « VAR=valeur » de +# tête comme le fait sudo. +_FAUX_SUDO = 'echo "$*" >> "$HOME/sudo.trace"\nexec env "$@"\n' + + +class LeVerdictDesOutilsAmont(unittest.TestCase): + """Après chaque installateur amont, une ligne qui dit s'il a abouti. + + Le code de sortie de la pose ne le peut pas : sans pipefail, un tube + rend le statut de l'interpréteur, 0 sur une entrée vide. La commande + distante tourne ici pour de vrai, sous « set -e », dans un bac à sable + où curl et sudo sont faux. + """ + + def _lancer(self, agent, **env_en_plus): + from unittest import mock + + with mock.patch("script.todo.qemu_install.t", lambda k: k): + cmd = TODO.__new__(TODO)._qemu_aidev_remote_cmd(agent) + env, faux_bin, home = _bac_a_sable( + self, + {"curl": _FAUX_CURL, "poser": _FAUX_POSER, "sudo": _FAUX_SUDO}, + ) + env.update(env_en_plus) + fini = subprocess.run( + [str(faux_bin / "bash"), "-c", "set -e\n" + cmd + "\necho FIN"], + env=env, + capture_output=True, + text=True, + timeout=120, + ) + return fini, home + + def test_without_a_download_each_tool_is_named_missing(self): + """Et l'installation continue : aucun outil optionnel ne la fait + tomber, pas même sa ligne de verdict.""" + for agent in ("claude", "opencode"): + with self.subTest(agent=agent): + fini, _home = self._lancer(agent, ECHEC="1") + self.assertEqual(0, fini.returncode, fini.stderr[-400:]) + self.assertIn("FIN", fini.stdout) + for nom in ("rtk", "starship", agent): + self.assertIn( + f"⚠ {nom} not installed (see above)", fini.stdout + ) + + def test_a_posed_tool_gives_its_version(self): + """opencode s'installe hors du PATH de ce shell : c'est le chemin + de repli qui le trouve.""" + for agent in ("claude", "opencode"): + with self.subTest(agent=agent): + fini, _home = self._lancer(agent) + self.assertEqual(0, fini.returncode, fini.stderr[-400:]) + for nom in ("rtk", "starship", agent): + self.assertIn(f"{nom}: {nom} 0.0.1", fini.stdout) + self.assertNotIn("⚠", fini.stdout) + + def test_starship_is_installed_as_root(self): + """En root, /usr/local/bin est inscriptible : l'installateur n'atteint + jamais « sudo -v », que sudo-rs refuse sans mot de passe même à un + compte NOPASSWD. L'hôte, lui, garde son installateur sans sudo.""" + self.assertIn( + shlex.quote(dev_tools.STARSHIP_UPSTREAM_VM), + TODO.__new__(TODO)._qemu_aidev_remote_cmd("claude"), + ) + self.assertIn("| sudo timeout -k ", dev_tools.STARSHIP_UPSTREAM_VM) + self.assertNotIn("sudo", dev_tools.STARSHIP_UPSTREAM) + _fini, home = self._lancer("claude") + self.assertIn( + f"timeout -k {dev_tools.STARSHIP_ROOT_KILL_AFTER}" + f" {dev_tools.STARSHIP_ROOT_TIMEOUT} sh -s -- -y", + (home / "sudo.trace").read_text(), + ) + + def test_the_root_installer_is_bounded_behind_sudo(self): + """Un « timeout » sans privilège ne tue pas un processus root : la + borne de l'installateur doit passer DERRIÈRE sudo. « -k » envoie + SIGKILL à un installateur qui ignore SIGTERM ; borne et délai + additionnés tombent avant la borne de la pose, qui ne tient plus que + curl.""" + import re + + vm = dev_tools.STARSHIP_UPSTREAM_VM + dedans = re.search(r"\| sudo timeout -k (\d+) (\d+) sh -s -- -y$", vm) + self.assertIsNotNone(dedans, vm) + cmd = TODO.__new__(TODO)._qemu_aidev_remote_cmd("claude") + dehors = re.search( + r"timeout (\d+) sh -c " + re.escape(shlex.quote(vm)), cmd + ) + self.assertIsNotNone(dehors, "la pose de starship n'est plus bornée") + self.assertLess( + int(dedans.group(1)) + int(dedans.group(2)), int(dehors.group(1)) + ) + + +class LeHookDuPrompt(unittest.TestCase): + """La ligne que le fichier du shell reçoit pour starship, lue par un vrai + shell. + + Le fichier est lu SEUL, sous « set -e », et rien ne suit la lecture : la + ligne est la dernière du fichier, dont le statut est donc le sien. Une + commande placée après masquerait ce statut. + """ + + def _sourcer(self, faux, shell="bash"): + binaire = shutil.which(shell) + if binaire is None: + self.skipTest(f"{shell} absent de cet hôte") + env, faux_bin, home = _bac_a_sable(self, faux, outils=()) + rc = home / f".{shell}rc" + rc.write_text(dev_tools.STARSHIP_LINE[shell] + "\n", encoding="utf-8") + # Ni ~/.bashrc ni ~/.zshrc lus d'office : seul `rc` est lu. + options = ["--norc"] if shell == "bash" else ["-f"] + return subprocess.run( + [binaire, *options, "-c", f'set -e; . "{rc}"'], + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + def test_without_the_binary_it_stays_silent_and_returns_zero(self): + """Une pose ratée, un binaire retiré : sans garde, chaque shell + ouvert écrirait « command not found ». Et la lecture rend 0 : un + script sous « set -e » qui lit ce fichier ne s'y arrête pas.""" + for shell in ("bash", "zsh"): + with self.subTest(shell=shell): + fini = self._sourcer({}, shell) + self.assertEqual("", fini.stderr) + self.assertEqual(0, fini.returncode) + + def test_with_the_binary_it_starts_starship(self): + for shell in ("bash", "zsh"): + with self.subTest(shell=shell): + fini = self._sourcer( + {"starship": 'echo "echo INIT-$2"\n'}, shell + ) + self.assertEqual(0, fini.returncode, fini.stderr) + self.assertIn(f"INIT-{shell}", fini.stdout) + + def test_the_guard_is_an_if_in_every_posix_shell(self): + """Le même « if … fi » pour bash et zsh : un hôte sans zsh ne lit + la ligne de zsh par aucun shell, sa forme reste donc vérifiée.""" + for shell in ("bash", "zsh"): + with self.subTest(shell=shell): + ligne = dev_tools.STARSHIP_LINE[shell] + self.assertTrue(ligne.startswith("if command -v starship ")) + self.assertTrue(ligne.endswith("; fi"), ligne) + + def test_the_dedup_pattern_survives_the_guard(self): + """L'hôte comme la VM reconnaissent une ligne déjà écrite à + « starship init » : la garde ne doit pas le masquer.""" + for shell, ligne in dev_tools.STARSHIP_LINE.items(): + with self.subTest(shell=shell): + self.assertIn(f"starship init {shell}", ligne) + + +class UneApostropheTraduite(unittest.TestCase): + def test_the_tool_blocks_stay_valid_shell(self): + """Les messages sont traduits, et le français est plein + d'apostrophes : une seule mal placée casse la commande distante + ENTIÈRE. On remplace la traduction elle-même — « set_lang » la + persisterait dans env_var.sh.""" + from unittest import mock + + # Un nombre IMPAIR d'apostrophes : entre apostrophes, un nombre pair + # se referme de lui-même, et « bash -n » ne verrait rien. + piege = "l'outil n'a pas « fini » aujourd'hui" + with mock.patch("script.todo.qemu_install.t", lambda k: piege): + todo = TODO.__new__(TODO) + blocs = { + f"aidev({agent})": todo._qemu_aidev_remote_cmd(agent) + for agent in dev_tools.AGENTS + } + blocs["mise"] = todo._qemu_mise_remote_cmd("mise") + for nom, cmd in blocs.items(): + with self.subTest(bloc=nom): + self.assertIn(piege, cmd) + fini = subprocess.run( + ["bash", "-n"], input=cmd, text=True, capture_output=True + ) + self.assertEqual(0, fini.returncode, fini.stderr[:400]) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index ee5bdd9..d9b96ba 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -1329,6 +1329,134 @@ class TestGnomeSiteExtensions(unittest.TestCase): l'activation ne peut rien ecrire dans dconf.""" self.assertIn("dbus-run-session", self.block) + def _lancer(self, **env_en_plus): + """Le bloc, pour de vrai, sous « set -e », dans un PATH où + gnome-shell, curl, mktemp et gnome-extensions sont faux : ni + réseau, ni session, ni fichier hors du répertoire du test.""" + import os + import shutil + import tempfile + + with mock.patch("script.todo.qemu_install.t", lambda k: k): + bloc = TODO.__new__(TODO)._qemu_gnome_ext_remote_cmd() + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + racine = pathlib.Path(tmp.name) + self.racine = racine + faux = racine / "bin" + faux.mkdir() + for outil in ("sh", "bash", "awk", "cut", "rm"): + os.symlink(shutil.which(outil), faux / outil) + corps = { + "gnome-shell": 'echo "GNOME Shell 50.1"\n', + "mktemp": 'if [ -n "$MKTEMP_ECHEC" ]; then exit 1; fi\n' + 'f="$TMPDIR/gext.$$"; : > "$f"; echo "$f"\n', + "curl": 'echo "$*" >> "$HOME/curl.trace"\n' + 'if [ -n "$ECHEC" ]; then exit 22; fi\n' + 'while [ "$#" -gt 0 ]; do [ "$1" = -o ] && dest="$2"; shift;' + ' done\n: > "$dest"\n', + "gnome-extensions": '[ "$1" = install ] && exit "${GX_RC:-0}"\n' + "exit 0\n", + } + for nom, texte in corps.items(): + chemin = faux / nom + chemin.write_text("#!/bin/sh\n" + texte, encoding="utf-8") + chemin.chmod(0o755) + env = { + "PATH": str(faux), + "HOME": str(racine), + "TMPDIR": str(racine), + **env_en_plus, + } + fini = subprocess.run( + [str(faux / "bash"), "-c", "set -e\n" + bloc + "\necho FIN"], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(0, fini.returncode, fini.stderr[-400:]) + self.assertIn("FIN", fini.stdout) + return fini.stdout + + def test_a_failed_download_does_not_blame_gnome(self): + """Le site sert une archive même à un GNOME qu'il ne connaît pas : + un téléchargement raté ne dit rien de la version.""" + sortie = self._lancer(ECHEC="1") + self.assertEqual( + len(TODO._QEMU_GNOME_EXT_UUIDS), + sortie.count("⚠ download impossible (network or cache):"), + ) + self.assertNotIn("not available for this GNOME", sortie) + + def test_a_failed_mktemp_skips_like_a_failed_download(self): + """Sans nom tiré par mktemp, rien n'est téléchargé : un nom fixe dans + /var/tmp, ouvert à tous, pourrait y être posé d'avance.""" + sortie = self._lancer(MKTEMP_ECHEC="1") + self.assertEqual( + len(TODO._QEMU_GNOME_EXT_UUIDS), + sortie.count("⚠ download impossible (network or cache):"), + ) + self.assertNotIn("installed and enabled:", sortie) + self.assertFalse( + (self.racine / "curl.trace").exists(), "curl lancé sans fichier" + ) + + def test_no_fixed_path_in_the_shared_tmp(self): + """/var/tmp n'apparaît que comme répertoire donné à mktemp, et le + bloc reste valide sous une traduction à apostrophe impaire.""" + import re + + piege = "l'extension n'a pas « fini » aujourd'hui" + with mock.patch("script.todo.qemu_install.t", lambda k: piege): + bloc = TODO.__new__(TODO)._qemu_gnome_ext_remote_cmd() + self.assertEqual( + {"/var/tmp"}, set(re.findall(r"/var/tmp[^\s;\"')|]*", bloc)) + ) + self.assertIn("mktemp -p /var/tmp gext-XXXX.zip", bloc) + res = subprocess.run( + ["bash", "-n"], input=bloc, text=True, capture_output=True + ) + self.assertEqual(0, res.returncode, res.stderr[:400]) + + def test_a_refused_install_names_the_gnome_version(self): + sortie = self._lancer(GX_RC="1") + self.assertEqual( + len(TODO._QEMU_GNOME_EXT_UUIDS), + sortie.count("not available for this GNOME, skipped:"), + ) + self.assertIn("(GNOME 50)", sortie) + self.assertNotIn("download impossible", sortie) + + def test_log_out_is_asked_only_when_something_was_installed(self): + """Se reconnecter pour charger des extensions jamais posées est + une consigne qui ment.""" + for echec in ({"ECHEC": "1"}, {"GX_RC": "1"}): + with self.subTest(echec=echec): + sortie = self._lancer(**echec) + self.assertNotIn("log out and back in", sortie) + sortie = self._lancer() + self.assertEqual( + len(TODO._QEMU_GNOME_EXT_UUIDS), + sortie.count("installed and enabled:"), + ) + self.assertEqual(1, sortie.count("log out and back in to load them")) + + def test_a_translated_apostrophe_keeps_the_block_valid(self): + """Une apostrophe mal placée casse la commande distante ENTIÈRE. On + remplace la traduction — « set_lang » la persisterait. + + Un nombre IMPAIR d'apostrophes : entre apostrophes, un nombre pair + se referme de lui-même, et « bash -n » ne verrait rien.""" + piege = "l'extension n'a pas « fini » aujourd'hui" + with mock.patch("script.todo.qemu_install.t", lambda k: piege): + bloc = TODO.__new__(TODO)._qemu_gnome_ext_remote_cmd() + self.assertIn(piege, bloc) + res = subprocess.run( + ["bash", "-n"], input=bloc, text=True, capture_output=True + ) + self.assertEqual(0, res.returncode, res.stderr[:400]) + class TestLeServiceDeLAgentInvite(unittest.TestCase): """Le service détaché qui pose qemu-guest-agent, lancé par cloud-init. @@ -1486,10 +1614,14 @@ class TestLeVerrouAptNeCoutePasDesMinutes(unittest.TestCase): PERSISTE dans env_var.sh, et un test qui la déplace fait échouer tout ce qui suit. On remplace la traduction elle-même, le temps du contrôle, par une chaîne qui porte le caractère dangereux. + + Le nombre d'apostrophes est IMPAIR : avec deux, un message emballé + entre guillemets simples se referme sur lui-même et le shell reste + valide, si bien que le contrôle ne verrait rien. """ import tempfile - piege = "l'agent n'a pas fini « attendre »" + piege = "l'agent n'a pas fini aujourd'hui « attendre »" with mock.patch("script.todo.qemu_install.t", lambda k: piege): todo = TODO.__new__(TODO) cmd = ( diff --git a/test/test_todo_shell_tools.py b/test/test_todo_shell_tools.py index ae647f0..5c856de 100644 --- a/test/test_todo_shell_tools.py +++ b/test/test_todo_shell_tools.py @@ -25,9 +25,17 @@ from unittest.mock import MagicMock, patch from script.todo.todo import TODO -LIGNE_BASH = 'eval "$(starship init bash)"' -LIGNE_ZSH = 'eval "$(starship init zsh)"' +LIGNE_BASH = ( + "if command -v starship >/dev/null 2>&1; then" + ' eval "$(starship init bash)"; fi' +) +LIGNE_ZSH = ( + "if command -v starship >/dev/null 2>&1; then" + ' eval "$(starship init zsh)"; fi' +) LIGNE_FISH = "starship init fish | source" +# La ligne telle qu'elle s'écrivait avant sa garde : des fichiers la portent. +LIGNE_BASH_SANS_GARDE = 'eval "$(starship init bash)"' def refuse_input(*args, **kwargs): @@ -214,6 +222,16 @@ class TestHookStarship(ShellFixture): ) self.assertIn("✅", sortie) + def test_a_line_written_before_the_guard_is_still_recognised(self): + """Un fichier écrit avant la garde porte la ligne nue : « starship + init » la reconnaît, et une seconde ligne n'est pas ajoutée.""" + self.cree("bash", contenu=f"{LIGNE_BASH_SANS_GARDE}\n") + self.hook() + self.assertEqual( + self.fichiers["bash"].read_text(encoding="utf-8"), + f"{LIGNE_BASH_SANS_GARDE}\n", + ) + def test_no_file_at_all_creates_the_bash_one(self): self.hook() self.assertEqual(