erplibre/test/test_qemu_recover.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

269 lines
10 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Récupérer des fichiers dans le disque d'une VM, sans la démarrer.
Une VM qui ne démarre plus garde ses fichiers, et libguestfs sait monter son
qcow2 sans elle. Le risque n'est pas la lecture : c'est d'ouvrir en ÉCRITURE
le disque d'une machine allumée, ce qui corrompt son système de fichiers.
Ce que ces tests gardent :
- « --ro » sur CHAQUE commande, sans exception : c'est lui qui rend l'opération
sûre sur une VM en marche ;
- le nom du paquet suit la distribution — celui de Debian n'existe nulle part
ailleurs ;
- les chemins saisis par l'utilisateur sont échappés avant d'atteindre le
shell.
"""
import re
import shlex
import sys
import unittest
from unittest import mock
sys.argv = ["todo.py"]
from script.todo import qemu_recover as qr # noqa: E402
from script.todo.todo import TODO # noqa: E402
DISQUE = "/var/lib/libvirt/images/vm-a.qcow2"
class LesOutils(unittest.TestCase):
def test_each_package_manager_gets_its_own_name(self):
"""« libguestfs-tools » est le nom Debian : il n'existe ni sur Arch,
ni sur Fedora, ni sur openSUSE."""
attendu = {
"apt-get": "libguestfs-tools",
"dnf": "guestfs-tools",
"pacman": "libguestfs",
"zypper": "guestfs-tools",
}
for binaire, paquet in attendu.items():
with self.subTest(binaire=binaire):
with mock.patch.object(
qr.shutil,
"which",
side_effect=lambda b, cible=binaire: (
"/usr/bin/x" if b == cible else None
),
):
cmd, nom = qr.guestfs_install_cmd()
self.assertEqual(paquet, nom)
self.assertIn(paquet, cmd)
def test_an_unknown_host_proposes_nothing(self):
"""Proposer une commande qui échouera vaut moins que de dire qu'on ne
sait pas — sur macOS, libguestfs ne tourne pas."""
with mock.patch.object(qr.shutil, "which", return_value=None):
self.assertEqual((None, None), qr.guestfs_install_cmd())
class LaLectureEstToujoursSeule(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
def test_every_command_carries_read_only(self):
"""Sans « --ro », ouvrir le disque d'une VM allumée corrompt son
système de fichiers. Aucune commande n'a de raison de s'en passer."""
cmds = [
self.todo._qemu_guestfish_cmd(DISQUE),
self.todo._qemu_guestfish_cmd(DISQUE, "run", "list-filesystems"),
self.todo._qemu_guestfish_cmd(
DISQUE, "run", "mount /dev/sda3 /", "ls /home"
),
]
for cmd in cmds:
with self.subTest(cmd=cmd):
self.assertIn("--ro", cmd)
self.assertIn(shlex.quote(DISQUE), cmd)
def test_the_commands_are_chained_the_guestfish_way(self):
cmd = self.todo._qemu_guestfish_cmd(
DISQUE, "run", "mount /dev/sda3 /", "ls /home"
)
self.assertIn("run : mount /dev/sda3 / : ls /home", cmd)
def test_a_hostile_path_cannot_escape_the_shell(self):
"""Le chemin vient de l'utilisateur : sans échappement, « ; » ouvre
une seconde commande."""
cmd = self.todo._qemu_guestfish_cmd("/tmp/x.qcow2; rm -rf /", "run")
# Le chemin entier tient dans UNE apostrophe : le « ; » y est du
# texte, pas un séparateur de commande.
self.assertIn("'/tmp/x.qcow2; rm -rf /'", cmd)
self.assertNotIn(
"qcow2; rm", cmd.replace("'/tmp/x.qcow2; rm -rf /'", "")
)
class LaVmAllumee(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
def test_a_stopped_vm_goes_straight_through(self):
self.todo._qemu_domstate = lambda n: "shut off"
self.assertTrue(self.todo._qemu_recover_ready("vm-a"))
def test_a_running_vm_offers_three_ways(self):
self.todo._qemu_domstate = lambda n: "running"
vus = {}
def faux_pick(titre, valeurs, defaut, labels=None):
vus["valeurs"] = valeurs
vus["defaut"] = defaut
return "read"
self.todo._qemu_pick = faux_pick
with mock.patch("builtins.print"):
self.assertTrue(self.todo._qemu_recover_ready("vm-a"))
self.assertEqual(["read", "shutdown", "cancel"], vus["valeurs"])
# L'arrêt propre est le défaut : c'est la seule voie qui donne une
# copie fidèle.
self.assertEqual("shutdown", vus["defaut"])
def test_cancelling_stops_everything(self):
self.todo._qemu_domstate = lambda n: "running"
self.todo._qemu_pick = lambda *a, **k: "cancel"
with mock.patch("builtins.print"):
self.assertFalse(self.todo._qemu_recover_ready("vm-a"))
def test_choosing_shutdown_waits_for_it(self):
self.todo._qemu_domstate = lambda n: "running"
self.todo._qemu_pick = lambda *a, **k: "shutdown"
appels = []
self.todo._qemu_shutdown_wait = lambda n: appels.append(n) or True
with mock.patch("builtins.print"):
self.assertTrue(self.todo._qemu_recover_ready("vm-a"))
self.assertEqual(["vm-a"], appels)
class LExtraction(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
self.lances = []
self.todo.execute = mock.MagicMock()
self.todo.execute.exec_command_live.side_effect = lambda cmd, **k: (
self.lances.append(cmd) or 0
)
def test_copy_out_names_both_ends(self):
with (
mock.patch.object(qr.os, "makedirs"),
mock.patch("builtins.print"),
):
ok = self.todo._qemu_recover_copy_out(
DISQUE, "/dev/sda3", "/home/erplibre", "/tmp/vm-a-backup"
)
self.assertTrue(ok)
cmd = self.lances[0]
self.assertIn("copy-out /home/erplibre /tmp/vm-a-backup", cmd)
self.assertIn("mount /dev/sda3 /", cmd)
self.assertIn("--ro", cmd)
def test_an_uncreatable_destination_stops_before_running(self):
"""guestfish s'arrêterait sur une erreur qui ne dit pas laquelle des
deux extrémités manque."""
with (
mock.patch.object(
qr.os, "makedirs", side_effect=OSError("lecture seule")
),
mock.patch("builtins.print"),
):
ok = self.todo._qemu_recover_copy_out(
DISQUE, "/dev/sda3", "/home", "/interdit"
)
self.assertFalse(ok)
self.assertEqual([], self.lances)
class LesDiagnostics(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
self.lances = []
self.todo.execute = mock.MagicMock()
self.todo.execute.exec_command_live.side_effect = lambda cmd, **k: (
self.lances.append(cmd) or 0
)
def test_the_four_probes_answer_four_questions(self):
with mock.patch("builtins.print"):
self.todo._qemu_recover_diagnostics(DISQUE)
joint = " ".join(self.lances)
for attendu in (
"virt-filesystems",
"virt-df",
"inspect-os",
"libguestfs-test-tool",
):
self.assertIn(attendu, joint)
def test_no_diagnostic_writes_to_the_disk(self):
with mock.patch("builtins.print"):
self.todo._qemu_recover_diagnostics(DISQUE)
for cmd in self.lances:
if "guestfish" in cmd:
self.assertIn("--ro", cmd)
class LeMenu(unittest.TestCase):
def test_the_entry_is_wired_in_the_manage_section(self):
from pathlib import Path
source = Path("script/todo/qemu_menu.py").read_text(encoding="utf-8")
self.assertIn("Recover files from a VM disk (libguestfs)", source)
self.assertIn("self._qemu_recover_files()", source)
def test_the_config_entry_still_reaches_its_command(self):
"""L'entrée du catalogue vient de todo.json : elle n'a pas de branche
« elif » et dépend du repli par indice. Insérer une entrée codée en
dur la décale, et un décalage manqué lancerait la mauvaise. Le numéro
se lit sur le menu rendu plutôt qu'écrit ici en dur."""
from unittest import mock as m
from script.todo.todo_i18n import set_lang
set_lang("fr")
todo = TODO()
todo._menu_header = lambda: "x"
lancees = []
todo.execute_from_configuration = lambda e: lancees.append(e)
# Le numéro se DÉDUIT du menu : l'entrée de config est la dernière
# des entrées numérotées. L'écrire en dur ferait passer le test au
# premier réarrangement de sections, sans rien prouver.
with (
m.patch("script.todo.qemu_menu.click") as click,
m.patch.object(todo, "_qemu_ensure_tools", return_value=True),
m.patch("builtins.print"),
):
click.prompt.side_effect = ["0"]
todo.prompt_execute_qemu()
aide = click.prompt.call_args[0][0]
numeros = re.findall(r"^\[(\d+)\]", aide, re.M)
dernier = max(int(n) for n in numeros)
with (
m.patch("script.todo.qemu_menu.click") as click,
m.patch.object(todo, "_qemu_ensure_tools", return_value=True),
m.patch("builtins.print"),
):
click.prompt.side_effect = [str(dernier), "0"]
todo.prompt_execute_qemu()
self.assertEqual(1, len(lancees), lancees)
self.assertIn("dry-run", lancees[0].get("bash_command", ""))
def test_the_branches_stay_in_order_and_unique(self):
"""Insérer une entrée décale tout ce qui suit : un numéro en double
rendrait une commande inatteignable."""
import re
from pathlib import Path
source = Path("script/todo/qemu_menu.py").read_text(encoding="utf-8")
debut = source.index("def prompt_execute_qemu")
corps = source[debut : source.index("\n def ", debut + 10)]
nums = [int(n) for n in re.findall(r'status == "(\d+)"', corps)]
self.assertEqual(sorted(nums), nums, nums)
self.assertEqual(len(set(nums)), len(nums), nums)
if __name__ == "__main__":
unittest.main()