feat: Mount-Schicht mit Linux-/Fake-Backend, Test und Wiederherstellung

MountBackend-Interface mit LinuxMountBackend (Argumentliste, kurzlebige
Credentials-Datei) und FakeMountBackend; MountManager für Einhängen,
Aushängen, Start-Wiederherstellung, Drei-Schritt-Test mit Root-Squash-
Erkennung, Lebenszeichen und umount -l/Neu-Einhängen; SFTP-Anbindung mit
festgehaltenem Hostschlüssel. Tests inkl. echtem SFTP-Server im Prozess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-10 18:46:50 +02:00
co-authored by Claude Opus 5.5
parent 689ddf0d94
commit e5ed8c4d88
18 changed files with 2478 additions and 9 deletions
+117
View File
@@ -0,0 +1,117 @@
"""Fallstricke 5 und 7 am echten Backend – ``subprocess`` wird nur abgefangen."""
from __future__ import annotations
import stat
import subprocess
from pathlib import Path
from typing import Any
import pytest
from netmover.mounting.backend import CifsCredentials, MountError, MountSpec
from netmover.mounting.linux import LinuxMountBackend, explain_mount_error
class Recorder:
def __init__(self, returncode: int = 0, stderr: str = "") -> None:
self.returncode = returncode
self.stderr = stderr
self.calls: list[tuple[list[str], dict[str, Any]]] = []
self.cred_snapshot: dict[str, Any] = {}
def __call__(self, argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
self.calls.append((argv, kwargs))
for opt in ",".join(argv).split(","):
if opt.startswith("credentials="):
p = Path(opt.removeprefix("credentials="))
self.cred_snapshot = {
"path": p,
"mode": stat.S_IMODE(p.stat().st_mode),
"content": p.read_text(),
}
return subprocess.CompletedProcess(argv, self.returncode, "", self.stderr)
def _spec(tmp_path: Path, password: str = "S3cr3t,pw=x") -> MountSpec:
return MountSpec(
fstype="cifs",
source="//nas/scans",
mountpoint=tmp_path / "mnt" / "scans",
options=("vers=3.0", "uid=1000"),
credentials=CifsCredentials("scanner", password, "WORKGROUP"),
)
def test_mount_uses_argv_and_credentials_file(tmp_path: Path) -> None:
rec = Recorder()
backend = LinuxMountBackend(tmp_path / "run", runner=rec)
backend.mount(_spec(tmp_path))
argv, kwargs = rec.calls[0]
assert "shell" not in kwargs
assert argv[:4] == ["mount", "-t", "cifs", "-o"]
assert argv[-2:] == ["//nas/scans", str(tmp_path / "mnt" / "scans")]
assert argv[4].startswith("vers=3.0,uid=1000,credentials=")
# Passwort nie in der Prozessliste
assert not any("S3cr3t" in a for a in argv)
assert "password" not in argv[4]
# Credentials-Datei: 0600, Inhalt korrekt, danach gelöscht
snap = rec.cred_snapshot
assert snap["mode"] == 0o600
assert snap["content"] == "username=scanner\npassword=S3cr3t,pw=x\ndomain=WORKGROUP\n"
assert snap["path"].parent == tmp_path / "run"
assert not snap["path"].exists()
assert kwargs["env"]["LC_ALL"] == "C"
def test_credentials_file_removed_on_failure_and_output_redacted(tmp_path: Path) -> None:
rec = Recorder(returncode=32, stderr="mount error(13): Permission denied for S3cr3t,pw=x")
backend = LinuxMountBackend(tmp_path / "run", runner=rec)
with pytest.raises(MountError) as exc:
backend.mount(_spec(tmp_path))
assert "S3cr3t" not in str(exc.value)
assert "Benutzername/Passwort" in str(exc.value)
assert list((tmp_path / "run").iterdir()) == []
def test_mount_timeout(tmp_path: Path) -> None:
def runner(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
raise subprocess.TimeoutExpired(argv, kwargs["timeout"])
backend = LinuxMountBackend(tmp_path / "run", runner=runner)
with pytest.raises(MountError, match="keine Antwort nach 5 s"):
backend.mount(_spec(tmp_path), timeout=5)
assert list((tmp_path / "run").iterdir()) == []
def test_unmount_lazy_argv(tmp_path: Path) -> None:
rec = Recorder()
backend = LinuxMountBackend(tmp_path / "run", runner=rec)
backend.unmount(Path("/mnt/netmover/scans"), lazy=True)
assert rec.calls[0][0] == ["umount", "-l", "/mnt/netmover/scans"]
def test_is_mounted_reads_mountinfo(tmp_path: Path) -> None:
info = tmp_path / "mountinfo"
info.write_text("98 22 0:52 / /mnt/netmover/scans rw - cifs //nas/scans rw\n")
backend = LinuxMountBackend(tmp_path / "run", mountinfo_path=info, runner=Recorder())
assert backend.is_mounted(Path("/mnt/netmover/scans"))
assert not backend.is_mounted(Path("/mnt/netmover/other"))
@pytest.mark.parametrize(
("fstype", "msg", "expect"),
[
(
"cifs",
"mount: wrong fs type, bad option; you might need a /sbin/mount.<type> helper program",
"cifs-utils",
),
("nfs4", "mount.nfs4: access denied by server while mounting nas:/x", "NFS-Berechtigungen"),
("cifs", "mount error(112): Host is down", "nicht erreichbar"),
("cifs", "mount: only root can do that", "CAP_SYS_ADMIN"),
],
)
def test_explain(fstype: str, msg: str, expect: str) -> None:
assert expect in explain_mount_error(fstype, msg)