diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c08f130..8a290a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ pip install -e ".[dev]" `ffmpeg` and `ffprobe` on `PATH` are optional — the suite runs without them. ```sh -pytest # ~400 tests, about 33s +pytest # ~416 tests, about 33s pytest --fuzz # property tests at 3000 examples each, about 2 min ruff check src tests pytest --cov=offloader --cov-report=term-missing diff --git a/README.md b/README.md index 277cb75..98a312d 100644 --- a/README.md +++ b/README.md @@ -356,13 +356,13 @@ what makes the report layer testable without moving bytes. ```sh pip install -e ".[dev]" -pytest # 409 tests, ~33s +pytest # 416 tests, ~33s pytest --fuzz # same suite, 3000 examples per property (~2 min) ruff check src tests pytest --cov=offloader --cov-report=term-missing ``` -409 tests at 82% line coverage. They cover formatting against the reference's +416 tests at 82% line coverage. They cover formatting against the reference's exact strings, checksum vectors and streaming equivalence, copy/verify behaviour including simulated destination corruption, pause/resume/cancel concurrency, retry discrimination, BRAW container parsing, ffprobe parsing, diff --git a/src/offloader/volumes.py b/src/offloader/volumes.py index 324b677..d525af2 100644 --- a/src/offloader/volumes.py +++ b/src/offloader/volumes.py @@ -8,6 +8,7 @@ import os import platform +import re import shutil import string from dataclasses import dataclass @@ -205,15 +206,134 @@ def _posix_volumes() -> list[Volume]: return found +#: Linux filesystems that are not storage: kernel interfaces, containers, snap +#: loop images, and per-user FUSE plumbing (gvfs, portals). +_LINUX_PSEUDO_FS = { + "proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "ramfs", "cgroup", "cgroup2", + "overlay", "squashfs", "autofs", "securityfs", "debugfs", "tracefs", "fusectl", + "configfs", "pstore", "bpf", "mqueue", "hugetlbfs", "binfmt_misc", "rpc_pipefs", + "efivarfs", "nsfs", "fuse.gvfsd-fuse", "fuse.portal", "fuse.snapfuse", + "fuse.lxcfs", "fuse.gvfs-fuse-daemon", +} +_LINUX_NETWORK_FS = { + "nfs", "nfs4", "cifs", "smb3", "smbfs", "9p", "ceph", "glusterfs", "afs", + "davfs", "fuse.sshfs", "fuse.rclone", +} +_LINUX_OPTICAL_FS = {"iso9660", "udf"} +_MOUNTINFO = "/proc/self/mountinfo" + +#: Mounts under these belong to the OS, not to a person plugging something in. +_LINUX_SYSTEM_PREFIXES = ( + "/boot", "/snap", "/var/snap", "/var/lib", "/usr", "/etc", "/proc", "/sys", + "/dev", "/run", +) +#: Where udisks and friends put removable media. Exempt from the list above, +#: because /run/media sits under /run. +_LINUX_MEDIA_PREFIXES = ("/media", "/run/media", "/mnt") + + +def _unescape_mountinfo(field: str) -> str: + """mountinfo writes space, tab, newline and backslash as octal escapes.""" + return re.sub(r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), field) + + +def _under(path: str, prefixes: tuple[str, ...]) -> bool: + return any(path == p or path.startswith(p + "/") for p in prefixes) + + +def _linux_device_is_removable(major_minor: str) -> bool: + """Whether the block device behind a mount reports itself as removable. + + A partition's `removable` flag lives on its parent disk. Device-mapper + devices (LUKS) have none, and read as not removable. + """ + try: + device = (Path("/sys/dev/block") / major_minor).resolve() + except OSError: + return False + for candidate in (device, device.parent): + try: + return (candidate / "removable").read_text().strip() == "1" + except OSError: + continue + return False + + +def _linux_volumes() -> list[Volume]: + """Volumes from the kernel's mount table. + + Cards mount at /media//