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
+108 -7
View File
@@ -1,12 +1,17 @@
from __future__ import annotations
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from pathlib import Path
import pytest
from sqlalchemy.orm import Session
from cryptography.fernet import Fernet
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import make_engine, make_session_factory, upgrade
from netmover.crypto import SecretBox
from netmover.db import make_engine, make_session_factory, session_scope, upgrade
from netmover.models import Credential, CredentialKind, Mount, MountType
from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import MountManager
@pytest.fixture
@@ -17,9 +22,105 @@ def db_url(tmp_path: Path) -> str:
@pytest.fixture
def session(db_url: str) -> Iterator[Session]:
def session_factory(db_url: str) -> Iterator[sessionmaker[Session]]:
engine = make_engine(db_url)
factory = make_session_factory(engine)
with factory() as s:
yield s
yield make_session_factory(engine)
engine.dispose()
@pytest.fixture
def session(session_factory: sessionmaker[Session]) -> Iterator[Session]:
with session_factory() as s:
yield s
@pytest.fixture
def secretbox() -> SecretBox:
return SecretBox(Fernet.generate_key())
@pytest.fixture
def fake_backend(tmp_path: Path) -> Iterator[FakeMountBackend]:
backend = FakeMountBackend(tmp_path / "fake")
yield backend
backend.release_hangs()
@pytest.fixture
def mount_base(tmp_path: Path) -> Path:
return tmp_path / "mnt"
@pytest.fixture
def sleeps() -> list[float]:
return []
@pytest.fixture
def notifications() -> list[tuple[str, str, str]]:
return []
@pytest.fixture
def manager(
session_factory: sessionmaker[Session],
fake_backend: FakeMountBackend,
secretbox: SecretBox,
mount_base: Path,
sleeps: list[float],
notifications: list[tuple[str, str, str]],
) -> MountManager:
return MountManager(
session_factory,
fake_backend,
secretbox,
mount_base,
probe_timeout=0.5,
recover_backoff=(0, 1, 2),
sleep=sleeps.append,
notifier=lambda *a: notifications.append(a),
)
MountFactory = Callable[..., int]
@pytest.fixture
def make_mount(
session_factory: sessionmaker[Session], secretbox: SecretBox, mount_base: Path
) -> MountFactory:
def factory(
name: str = "scans",
type: MountType = MountType.CIFS,
server: str = "nas",
share: str = "scans",
password: str | None = "geheim123",
**kw: object,
) -> int:
with session_scope(session_factory) as s:
cred_id = None
if password is not None:
c = Credential(
name=f"cred-{name}",
kind=CredentialKind.PASSWORD,
username="scanner",
secret_enc=secretbox.encrypt(password),
)
s.add(c)
s.flush()
cred_id = c.id
m = Mount(
name=name,
slug=name,
type=type,
server=server,
share=share,
mountpoint=None if type is MountType.SFTP else str(mount_base.resolve() / name),
cred_id=cred_id,
**kw,
)
s.add(m)
s.flush()
return m.id
return factory
+202
View File
@@ -0,0 +1,202 @@
"""Minimaler SFTP-Server für Tests (paramiko-Serverseite, auf ein Verzeichnis beschränkt).
Unterstützt ``posix-rename@openssh.com`` (paramiko leitet es an ``posix_rename``
weiter), aber bewusst nicht ``statvfs@openssh.com`` – damit ist auch der
Rückfallweg ohne Platzprüfung abgedeckt.
"""
from __future__ import annotations
import contextlib
import io
import os
import socket
import threading
from pathlib import Path
from typing import Any
import paramiko
from paramiko import SFTPAttributes, SFTPHandle, SFTPServer, SFTPServerInterface
from paramiko.sftp import SFTP_FAILURE, SFTP_OK
from netmover.crypto import generate_ssh_keypair
class _Server(paramiko.ServerInterface):
def __init__(self, username: str, password: str | None, pubkey: paramiko.PKey | None) -> None:
self.username = username
self.password = password
self.pubkey = pubkey
def check_auth_password(self, username: str, password: str) -> int:
if username == self.username and self.password is not None and password == self.password:
return paramiko.AUTH_SUCCESSFUL
return paramiko.AUTH_FAILED
def check_auth_publickey(self, username: str, key: paramiko.PKey) -> int:
if username == self.username and self.pubkey is not None and key == self.pubkey:
return paramiko.AUTH_SUCCESSFUL
return paramiko.AUTH_FAILED
def get_allowed_auths(self, username: str) -> str:
return "password,publickey"
def check_channel_request(self, kind: str, chanid: int) -> int:
if kind == "session":
return paramiko.OPEN_SUCCEEDED
return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
class _Handle(SFTPHandle):
pass
class _RootedSFTP(SFTPServerInterface):
def __init__(self, server: Any, *args: Any, root: Path, **kwargs: Any) -> None:
super().__init__(server, *args, **kwargs)
self.root = root
def _real(self, path: str) -> str:
return str(self.root / self.canonicalize(path).lstrip("/"))
def list_folder(self, path: str) -> Any:
try:
real = self._real(path)
out = []
for name in os.listdir(real):
attr = SFTPAttributes.from_stat(os.stat(os.path.join(real, name)))
attr.filename = name
out.append(attr)
return out
except OSError as e:
return SFTPServer.convert_errno(e.errno)
def stat(self, path: str) -> Any:
try:
return SFTPAttributes.from_stat(os.stat(self._real(path)))
except OSError as e:
return SFTPServer.convert_errno(e.errno)
lstat = stat
def open(self, path: str, flags: int, attr: SFTPAttributes) -> Any:
real = self._real(path)
try:
fd = os.open(real, flags | getattr(os, "O_BINARY", 0), 0o644)
except OSError as e:
return SFTPServer.convert_errno(e.errno)
if flags & os.O_WRONLY:
mode = "ab" if flags & os.O_APPEND else "wb"
elif flags & os.O_RDWR:
mode = "a+b" if flags & os.O_APPEND else "r+b"
else:
mode = "rb"
f = os.fdopen(fd, mode)
h = _Handle(flags)
h.filename = real
h.readfile = f
h.writefile = f
return h
def remove(self, path: str) -> int:
try:
os.remove(self._real(path))
except OSError as e:
return SFTPServer.convert_errno(e.errno)
return SFTP_OK
def rename(self, oldpath: str, newpath: str) -> int:
new = self._real(newpath)
if os.path.exists(new):
return SFTP_FAILURE # SFTPv3-rename überschreibt nicht
try:
os.rename(self._real(oldpath), new)
except OSError as e:
return SFTPServer.convert_errno(e.errno)
return SFTP_OK
def posix_rename(self, oldpath: str, newpath: str) -> int:
try:
os.replace(self._real(oldpath), self._real(newpath))
except OSError as e:
return SFTPServer.convert_errno(e.errno)
return SFTP_OK
def mkdir(self, path: str, attr: SFTPAttributes) -> int:
try:
os.mkdir(self._real(path))
except OSError as e:
return SFTPServer.convert_errno(e.errno)
return SFTP_OK
def rmdir(self, path: str) -> int:
try:
os.rmdir(self._real(path))
except OSError as e:
return SFTPServer.convert_errno(e.errno)
return SFTP_OK
def chattr(self, path: str, attr: SFTPAttributes) -> int:
try:
if attr.st_mode is not None:
os.chmod(self._real(path), attr.st_mode & 0o7777)
if attr.st_atime is not None and attr.st_mtime is not None:
os.utime(self._real(path), (attr.st_atime, attr.st_mtime))
except OSError as e:
return SFTPServer.convert_errno(e.errno)
return SFTP_OK
def _load_key(pem: str) -> paramiko.PKey:
return paramiko.Ed25519Key.from_private_key(io.StringIO(pem))
class StubSFTPServer:
def __init__(
self, root: Path, username: str = "paperless", password: str | None = None
) -> None:
self.root = root
self.username = username
self.password = password
host_pem, _ = generate_ssh_keypair("host")
self.host_key = _load_key(host_pem)
self.client_private_key, self.client_public_key = generate_ssh_keypair("netmover")
self.authorized = _load_key(self.client_private_key)
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.bind(("127.0.0.1", 0))
self._sock.listen(8)
self.port: int = self._sock.getsockname()[1]
self._stop = threading.Event()
self._transports: list[paramiko.Transport] = []
self._thread = threading.Thread(target=self._serve, daemon=True)
@property
def host_key_string(self) -> str:
return f"{self.host_key.get_name()} {self.host_key.get_base64()}"
def start(self) -> StubSFTPServer:
self._thread.start()
return self
def _serve(self) -> None:
self._sock.settimeout(0.2)
while not self._stop.is_set():
try:
conn, _ = self._sock.accept()
except (TimeoutError, OSError):
continue
t = paramiko.Transport(conn)
t.add_server_key(self.host_key)
t.set_subsystem_handler("sftp", SFTPServer, _RootedSFTP, root=self.root)
self._transports.append(t)
with contextlib.suppress(Exception):
t.start_server(server=_Server(self.username, self.password, self.authorized))
def stop(self) -> None:
self._stop.set()
for t in self._transports:
with contextlib.suppress(Exception):
t.close()
self._sock.close()
self._thread.join(timeout=2)
+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)
+369
View File
@@ -0,0 +1,369 @@
"""Mount-Verwaltung mit FakeMountBackend – Fallstricke 2, 6, 7 und 13."""
from __future__ import annotations
import errno
from pathlib import Path
import pytest
from sqlalchemy.orm import Session, sessionmaker
from netmover.models import CredentialKind, Mount, MountState, MountType
from netmover.mounting.backend import MountError
from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import (
CredentialData,
MountConfig,
MountManager,
MountUnavailable,
build_spec,
)
from netmover.validation import ValidationError
from tests.conftest import MountFactory
def _state(sf: sessionmaker[Session], mount_id: int) -> Mount:
with sf() as s:
m = s.get(Mount, mount_id)
assert m is not None
s.expunge(m)
return m
# -- build_spec -------------------------------------------------------------------
def test_build_spec_cifs(tmp_path: Path) -> None:
cfg = MountConfig(
"scans",
MountType.CIFS,
"nas",
"scans",
uid=1000,
gid=100,
file_mode="0644",
extra_options="vers=3.0",
)
cred = CredentialData(CredentialKind.PASSWORD, "scanner", "pw", "WG")
spec = build_spec(cfg, tmp_path, cred)
assert spec.fstype == "cifs"
assert spec.source == "//nas/scans"
assert spec.options == ("vers=3.0", "uid=1000", "gid=100", "file_mode=0644")
assert spec.credentials is not None and spec.credentials.password == "pw"
assert "pw" not in repr(spec)
def test_build_spec_nfs() -> None:
nfs3 = build_spec(MountConfig("a", MountType.NFS3, "nas", "/volume1/a"), Path("/x"), None)
assert (nfs3.fstype, nfs3.source, nfs3.options) == ("nfs", "nas:/volume1/a", ("vers=3",))
nfs4 = build_spec(MountConfig("a", MountType.NFS4, "nas", "/volume1/a"), Path("/x"), None)
assert (nfs4.fstype, nfs4.options) == ("nfs4", ())
def test_build_spec_guest_and_revalidation() -> None:
spec = build_spec(MountConfig("a", MountType.CIFS, "nas", "pub"), Path("/x"), None)
assert spec.options == ("guest",)
# Auch Werte „aus der Datenbank“ laufen erneut durch die Validierung
with pytest.raises(ValidationError):
build_spec(MountConfig("a", MountType.CIFS, "nas;id", "pub"), Path("/x"), None)
with pytest.raises(ValidationError):
build_spec(
MountConfig("a", MountType.CIFS, "nas", "pub", extra_options="password=x"),
Path("/x"),
None,
)
# -- Einhängen / Aushängen (Fallstrick 6) --------------------------------------------
def test_mount_creates_mountpoint_under_base_and_unmount_removes_it(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
mount_base: Path,
session_factory: sessionmaker[Session],
) -> None:
remote = fake_backend.export("nas", "scans")
(remote / "a.pdf").write_text("x")
mid = make_mount()
manager.mount(mid)
mp = mount_base.resolve() / "scans"
assert fake_backend.is_mounted(mp)
assert (mp / "a.pdf").read_text() == "x"
m = _state(session_factory, mid)
assert m.state is MountState.MOUNTED and m.last_mounted_at is not None
# Zugangsdaten wurden entschlüsselt übergeben
spec = fake_backend.calls[0].spec
assert spec is not None and spec.credentials is not None
assert spec.credentials.password == "geheim123"
manager.unmount(mid)
assert not mp.exists()
assert _state(session_factory, mid).state is MountState.UNMOUNTED
def test_mount_refuses_non_empty_mountpoint(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
mount_base: Path,
session_factory: sessionmaker[Session],
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount()
mp = mount_base.resolve() / "scans"
mp.mkdir(parents=True)
(mp / "verirrt.pdf").write_text("lokal geschrieben")
with pytest.raises(MountError, match="nicht leer"):
manager.mount(mid)
assert not fake_backend.calls
assert (mp / "verirrt.pdf").exists()
assert _state(session_factory, mid).state is MountState.ERROR
def test_failed_mount_cleans_up_and_records_error(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
mount_base: Path,
session_factory: sessionmaker[Session],
) -> None:
mid = make_mount() # nicht exportiert → „No such file or directory“
with pytest.raises(MountError):
manager.mount(mid)
assert not (mount_base.resolve() / "scans").exists()
m = _state(session_factory, mid)
assert m.state is MountState.ERROR
assert m.last_error and "No such file" in m.last_error
def test_mountpoint_for_rejects_escape(manager: MountManager) -> None:
for bad in ["../etc", "a/b", "", "Scans"]:
with pytest.raises(ValidationError):
manager.mountpoint_for(bad)
def test_sftp_is_never_mounted(manager: MountManager, make_mount: MountFactory) -> None:
mid = make_mount("paperless", type=MountType.SFTP, share="/consume")
with pytest.raises(MountError, match="SFTP"):
manager.mount(mid)
def test_restore_all_only_auto_mount(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
notifications: list[tuple[str, str, str]],
) -> None:
fake_backend.export("nas", "a")
fake_backend.export("nas", "b")
a = make_mount("a", share="a")
make_mount("b", share="b", auto_mount=False)
make_mount("c", share="fehlt")
make_mount("d", share="a", enabled=False)
results = {r.name: r.ok for r in manager.restore_all()}
assert results == {"a": True, "c": False}
assert fake_backend.is_mounted(manager.mountpoint_for("a"))
assert notifications and notifications[0][:2] == ("mount.restore_failed", "c")
# zweiter Aufruf ist idempotent
manager.mount(a)
assert (
sum(1 for c in fake_backend.calls if c.action == "mount" and c.mountpoint.name == "a") == 1
)
# -- Test (drei Schritte) -----------------------------------------------------------
def test_check_unsaved_config_mounts_temporarily(
manager: MountManager, fake_backend: FakeMountBackend, mount_base: Path
) -> None:
fake_backend.export("nas", "scans")
cfg = MountConfig("neu", MountType.CIFS, "nas", "scans")
cred = CredentialData(CredentialKind.PASSWORD, "u", "pw")
report = manager.check(cfg, cred)
assert [(s.name, s.ok) for s in report.steps] == [
("mount", True),
("read", True),
("write", True),
]
assert report.can_write and report.can_delete
assert report.free_bytes is not None
assert [r.value for r in report.roles] == ["Quelle mit Löschen", "Ziel"]
# temporärer Mountpoint wieder weg, Testdatei gelöscht
assert list(mount_base.iterdir()) == []
assert list(fake_backend.remote_root.joinpath("nas", "scans").iterdir()) == []
assert not fake_backend.mounted
def test_check_mount_failure_reports_steps(manager: MountManager, mount_base: Path) -> None:
report = manager.check(MountConfig("x", MountType.CIFS, "nas", "fehlt"), None)
assert [(s.name, s.ok) for s in report.steps] == [
("mount", False),
("read", None),
("write", None),
]
assert not report.ok
assert list(mount_base.iterdir()) == []
def test_check_read_only_share_is_source_copy_only(
manager: MountManager, fake_backend: FakeMountBackend
) -> None:
remote = fake_backend.export("nas", "ro")
remote.chmod(0o555)
try:
report = manager.check(MountConfig("ro", MountType.CIFS, "nas", "ro"), None)
finally:
remote.chmod(0o755)
assert report.can_read and not report.can_write
assert [r.value for r in report.roles] == ["nur Quelle, kopieren"]
assert report.steps[2].ok is False
def test_check_stores_result_on_saved_mount(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount()
manager.mount(mid)
m = _state(session_factory, mid)
report = manager.check(MountConfig.from_model(m), None, mount_id=mid)
assert report.steps[0].message == "bereits eingehängt"
m = _state(session_factory, mid)
assert m.write_ok is True and m.last_test_result and '"ok": true' in m.last_test_result
assert fake_backend.is_mounted(manager.mountpoint_for("scans")) # bleibt eingehängt
# -- Fallstrick 2: nicht eingehängt ---------------------------------------------------
def test_check_alive_refuses_unmounted_mountpoint(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount()
manager.mount(mid)
mp = manager.check_alive(mid)
assert mp is not None
fake_backend.vanish(mp)
assert mp.is_dir() and list(mp.iterdir()) == [] # sieht aus wie eine leere Freigabe
with pytest.raises(MountUnavailable, match="nicht eingehängt"):
manager.check_alive(mid)
m = _state(session_factory, mid)
assert m.state is MountState.ERROR
# kein Wiederherstellungsversuch, kein Schreiben
assert [c.action for c in fake_backend.calls] == ["mount"]
assert list(mp.iterdir()) == []
# -- Fallstrick 13: gestörte und hängende Mounts ---------------------------------------
@pytest.mark.parametrize("code", [errno.ESTALE, errno.EIO, errno.ENOTCONN])
def test_stale_mount_is_lazily_unmounted_and_remounted(
code: int,
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount()
manager.mount(mid)
mp = manager.mountpoint_for("scans")
fake_backend.inject_fault(mp, code)
assert manager.check_alive(mid) == mp
actions = [(c.action, c.lazy) for c in fake_backend.calls]
assert actions == [("mount", False), ("unmount", True), ("mount", False)]
m = _state(session_factory, mid)
assert m.state is MountState.MOUNTED and m.last_error is None
def test_recovery_gives_up_after_three_attempts_with_growing_delay(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
sleeps: list[float],
notifications: list[tuple[str, str, str]],
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount()
manager.mount(mid)
mp = manager.mountpoint_for("scans")
fake_backend.inject_fault(mp, errno.ESTALE)
fake_backend.fail_next_mount(times=3)
with pytest.raises(MountUnavailable, match="3 Versuchen"):
manager.check_alive(mid)
assert sleeps == [1, 2] # 1. Versuch sofort, dann wachsender Abstand
assert sum(1 for c in fake_backend.calls if c.action == "mount") == 4
m = _state(session_factory, mid)
assert m.state is MountState.ERROR
assert notifications[-1][0] == "mount.error"
def test_hanging_mount_times_out_and_is_recovered(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount()
manager.mount(mid)
mp = manager.mountpoint_for("scans")
fake_backend.inject_fault(mp, "hang")
assert manager.check_alive(mid) == mp # blockiert nicht, Zeitlimit 0.5 s
assert ("unmount", True) in [(c.action, c.lazy) for c in fake_backend.calls]
assert _state(session_factory, mid).state is MountState.MOUNTED
def test_hanging_mount_without_recovery_shows_since(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount()
manager.mount(mid)
mp = manager.mountpoint_for("scans")
fake_backend.heal_on_remount = False
fake_backend.inject_fault(mp, "hang")
with pytest.raises(MountUnavailable):
manager.check_alive(mid)
m = _state(session_factory, mid)
assert m.state is MountState.ERROR
assert m.state_since is not None
assert m.last_error and "keine Antwort" in m.last_error
def test_non_recoverable_error_does_not_remount(
manager: MountManager, fake_backend: FakeMountBackend, make_mount: MountFactory
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount()
manager.mount(mid)
fake_backend.inject_fault(manager.mountpoint_for("scans"), errno.EACCES)
with pytest.raises(MountUnavailable):
manager.check_alive(mid)
assert [c.action for c in fake_backend.calls] == ["mount"]
def test_usage(
manager: MountManager, fake_backend: FakeMountBackend, make_mount: MountFactory
) -> None:
fake_backend.export("nas", "scans")
mid = make_mount()
assert manager.usage(mid) is None
manager.mount(mid)
usage = manager.usage(mid)
assert usage is not None and usage[0] >= usage[1] > 0
+40
View File
@@ -0,0 +1,40 @@
"""Fallstrick 2: „eingehängt?“ entscheidet /proc/self/mountinfo."""
from __future__ import annotations
from pathlib import Path
from netmover.mounting.mountinfo import find_mount, parse_mountinfo
SAMPLE = """\
22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw
98 22 0:52 / /mnt/netmover/scans rw,relatime shared:50 - cifs //nas/scans rw,vers=3.1.1
99 22 0:53 / /mnt/netmover/mit\\040leer rw,relatime - nfs4 nas:/volume1/a\\040b rw
100 22 0:54 / /mnt/netmover/scans rw,relatime - cifs //nas2/scans rw
"""
def test_parse_with_octal_escapes() -> None:
entries = parse_mountinfo(SAMPLE)
assert len(entries) == 4
assert entries[2].mountpoint == "/mnt/netmover/mit leer"
assert entries[2].source == "nas:/volume1/a b"
assert entries[2].fstype == "nfs4"
def test_find_mount_uses_topmost_entry() -> None:
entries = parse_mountinfo(SAMPLE)
found = find_mount(Path("/mnt/netmover/scans/"), entries)
assert found is not None
assert found.source == "//nas2/scans"
def test_unmounted_directory_is_not_mounted() -> None:
entries = parse_mountinfo(SAMPLE)
assert find_mount(Path("/mnt/netmover/archiv"), entries) is None
# Unterverzeichnis eines Mounts ist selbst kein Mountpoint
assert find_mount(Path("/mnt/netmover/scans/eingang"), entries) is None
def test_garbage_lines_are_ignored() -> None:
assert parse_mountinfo("kaputt\n\n1 2 3\n") == []
+61
View File
@@ -0,0 +1,61 @@
"""Fallstrick 12: Schreibtest erkennt Root-Squash und leitet Rollen ab."""
from __future__ import annotations
import errno
from netmover.models import MountType
from netmover.mounting.report import CheckReport, Role, assess_write
def _err(code: int) -> OSError:
return OSError(code, "x")
def test_nfs_root_write_denied_is_root_squash_with_synology_hint() -> None:
r = assess_write(MountType.NFS4, 0, _err(errno.EACCES), None, None)
assert not r.write_ok
assert r.root_squash is True
assert any("Synology" in h and "Squash" in h for h in r.hints)
def test_nfs_file_owned_by_nobody_is_root_squash() -> None:
r = assess_write(MountType.NFS3, 0, None, 65534, None)
assert r.write_ok and r.delete_ok
assert r.root_squash is True
assert "65534" in r.message
assert any("chown" in h for h in r.hints)
def test_nfs_no_root_squash() -> None:
r = assess_write(MountType.NFS4, 0, None, 0, None)
assert r.root_squash is False
assert r.hints == []
def test_cifs_owner_never_counts_as_squash() -> None:
r = assess_write(MountType.CIFS, 0, None, 1000, None)
assert r.root_squash is None
def test_not_root_cannot_judge_squash() -> None:
assert assess_write(MountType.NFS4, 1000, None, 1000, None).root_squash is None
def test_read_only() -> None:
r = assess_write(MountType.CIFS, 0, _err(errno.EROFS), None, None)
assert not r.write_ok
assert "schreibgeschützt" in r.message
def test_delete_failed() -> None:
r = assess_write(MountType.CIFS, 0, None, 0, _err(errno.EACCES))
assert r.write_ok and not r.delete_ok
def test_roles() -> None:
rep = CheckReport(can_read=True)
assert rep.roles == [Role.SOURCE_COPY]
rep.can_write = rep.can_delete = True
assert rep.roles == [Role.SOURCE_MOVE, Role.TARGET]
assert '"Quelle mit Löschen"' in rep.to_json()
+154
View File
@@ -0,0 +1,154 @@
"""SFTP-Ziel gegen einen echten (im Prozess laufenden) SFTP-Server."""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from sqlalchemy.orm import Session, sessionmaker
from netmover.crypto import SecretBox
from netmover.db import session_scope
from netmover.models import Credential, CredentialKind, Mount, MountState, MountType
from netmover.mounting.manager import MountManager, MountUnavailable
from netmover.mounting.sftp import (
HostKeyMismatch,
HostKeyUnknown,
SftpConfig,
SftpError,
check_connection,
connect,
)
from tests.sftp_stub import StubSFTPServer
@pytest.fixture
def server(tmp_path: Path) -> Iterator[StubSFTPServer]:
root = tmp_path / "sftp-root"
(root / "consume").mkdir(parents=True)
srv = StubSFTPServer(root, password="pw").start()
yield srv
srv.stop()
def _cfg(server: StubSFTPServer, **kw: object) -> SftpConfig:
data: dict[str, object] = {
"host": "127.0.0.1",
"port": server.port,
"username": server.username,
"base_path": "/consume",
"private_key": server.client_private_key,
}
data.update(kw)
return SftpConfig(**data) # type: ignore[arg-type]
def test_check_connection_with_key_tofu(server: StubSFTPServer) -> None:
report = check_connection(_cfg(server))
assert [(s.name, s.ok) for s in report.steps] == [
("connect", True),
("read", True),
("write", True),
]
assert report.host_key == server.host_key_string
assert report.host_key_fingerprint and report.host_key_fingerprint.startswith("SHA256:")
assert "neu" in report.steps[0].message
assert report.can_write and report.can_delete
# Stub kennt kein statvfs → Hinweis statt Fehler
assert report.free_bytes is None
assert any("statvfs" in h for h in report.hints)
assert list((server.root / "consume").iterdir()) == []
def test_password_auth(server: StubSFTPServer) -> None:
report = check_connection(
_cfg(server, private_key=None, password="pw", host_key=server.host_key_string)
)
assert report.ok
def test_wrong_credentials(server: StubSFTPServer) -> None:
report = check_connection(_cfg(server, private_key=None, password="falsch"))
assert report.steps[0].ok is False
assert "authorized_keys" in report.steps[0].message
assert "falsch" not in report.steps[0].message
def test_unknown_host_key_refused_outside_test(server: StubSFTPServer) -> None:
with pytest.raises(HostKeyUnknown):
connect(_cfg(server))
def test_host_key_mismatch(server: StubSFTPServer) -> None:
other = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOmvG9t0hJ7oC2Y3Fq6m0eKZ1x2y3z4A5B6C7D8E9F0G"
with pytest.raises(HostKeyMismatch, match="geändert"):
connect(_cfg(server, host_key=other))
report = check_connection(_cfg(server, host_key=other))
assert report.steps[0].ok is False
def test_missing_base_path(server: StubSFTPServer) -> None:
report = check_connection(_cfg(server, base_path="/fehlt"))
assert [(s.name, s.ok) for s in report.steps] == [
("connect", True),
("read", False),
("write", None),
]
def test_unreachable() -> None:
with pytest.raises(SftpError, match="fehlgeschlagen"):
connect(SftpConfig("127.0.0.1", 1, "x", "/", password="x"), timeout=2)
def test_manager_pins_host_key_and_checks_alive(
server: StubSFTPServer,
manager: MountManager,
session_factory: sessionmaker[Session],
secretbox: SecretBox,
) -> None:
with session_scope(session_factory) as s:
c = Credential(
name="paperless-key",
kind=CredentialKind.SSH_KEY,
username=server.username,
secret_enc=secretbox.encrypt(server.client_private_key),
public_key=server.client_public_key,
)
s.add(c)
s.flush()
m = Mount(
name="paperless",
slug="paperless",
type=MountType.SFTP,
server="127.0.0.1",
share="/consume",
port=server.port,
cred_id=c.id,
)
s.add(m)
s.flush()
mid = m.id
# vor dem ersten Test: Hostschlüssel unbekannt → kein Lauf möglich
with pytest.raises(MountUnavailable, match="Hostschlüssel"):
manager.check_alive(mid)
from netmover.mounting.manager import CredentialData, MountConfig
with session_factory() as s:
mm = s.get(Mount, mid)
assert mm is not None
cfg = MountConfig.from_model(mm)
cred = CredentialData(CredentialKind.SSH_KEY, server.username, server.client_private_key)
assert manager.check(cfg, cred, mount_id=mid).ok
with session_factory() as s:
mm = s.get(Mount, mid)
assert mm is not None and mm.host_key == server.host_key_string
assert manager.check_alive(mid) is None
with session_factory() as s:
mm = s.get(Mount, mid)
assert mm is not None and mm.state is MountState.MOUNTED