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:
co-authored by
Claude Opus 5.5
parent
689ddf0d94
commit
e5ed8c4d88
+108
-7
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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") == []
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user