From 21556282ed0fabbc173b9227a5d1d23ce230fb66 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Mon, 12 Oct 2026 17:14:50 +0200 Subject: [PATCH] mount: list the current borg mounts when no MOUNTPOINT is given, fixes #2579 "borg mount" without a MOUNTPOINT now prints the mountpoints of all file systems mounted by borg mount, one per line (--json: a JSON list with mountpoint, source and fstype). This needs neither a repository nor FUSE. borgfs still requires the mountpoint. New platform API platform.list_mounts() returning the mount table as MountEntry (source, fstype, mountpoint) tuples, implemented in the per-OS platform modules: - Linux: /proc/self/mountinfo (fields after the "-" separator, octal escapes); raises a clear OSError if the file can not be read (/proc not mounted). - illumos: /etc/mnttab - macOS, FreeBSD: getmntinfo() as a Cython extern (the header maps it to getmntinfo$INODE64 on x86_64 macOS; sys/mount.h is self-contained on all supported FreeBSD releases, the sys/param.h / sys/ucred.h includes listed by getmntinfo(3) are not needed) - NetBSD: getvfsstat() as a Cython extern (the header maps it to the libc symbol of the current struct layout, __getvfsstat90) - Windows: GetLogicalDriveStringsW + GetVolumeInformationW, i.e. drive letter mounts only - directory mountpoints (junctions) can not be enumerated. - other platforms (e.g. OpenBSD, whose kernel names every FUSE mount "fusefs" and ignores fsname): empty list, with a warning. An OSError from the enumerator is reported as a runtime error by the listing, not as a traceback. vfs.is_borgfs_mount() has the recognition rule in one place: source "borgfs" (Linux, macOS, FreeBSD, illumos) or an fstype ending in "borgfs" (NetBSD shows "puffs|borgfs", Linux "fuse.borgfs"); on Windows the fs name "FUSE" with a volume label containing "borgfs". On Linux, parse_mount_options now also gives libfuse "subtype=borgfs", so the file system type is "fuse.borgfs" (as in the fstab examples) and the mount is recognized even if the user overrides fsname. Co-Authored-By: Claude Fable 5.1 --- docs/changes.rst | 5 ++ docs/usage/mount.rst | 5 ++ src/borg/archiver/mount_cmds.py | 51 +++++++++++++-- src/borg/platform/__init__.py | 7 ++ src/borg/platform/base.py | 35 ++++++++++ src/borg/platform/darwin.pyx | 27 ++++++++ src/borg/platform/freebsd.pyx | 26 ++++++++ src/borg/platform/linux.pyx | 48 +++++++++++++- src/borg/platform/netbsd.pyx | 40 ++++++++++++ src/borg/platform/solaris.py | 23 ++++++- src/borg/platform/windows.pyx | 23 +++++++ .../testsuite/archiver/mount_cmds_test.py | 64 +++++++++++++++++++ src/borg/testsuite/platform/linux_test.py | 62 +++++++++++++++++- src/borg/testsuite/platform/platform_test.py | 21 +++++- src/borg/testsuite/platform/solaris_test.py | 27 ++++++++ src/borg/testsuite/vfs_test.py | 48 ++++++++++++++ src/borg/vfs.py | 18 ++++++ 17 files changed, 520 insertions(+), 10 deletions(-) create mode 100644 src/borg/testsuite/platform/solaris_test.py diff --git a/docs/changes.rst b/docs/changes.rst index d3139fa75a..126d5935a7 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -177,6 +177,11 @@ New features: - archive_dir_format - numeric_ids +- mount: without a MOUNTPOINT, list the mountpoints of the current borg mounts + (``--json`` for a JSON list), #2579. Linux, macOS, FreeBSD, NetBSD, illumos + and Windows (drive letter mounts only) are supported. + On Linux, borg mounts now have the file system type ``fuse.borgfs``. + Fixes: - legacy remote: use deques for the RPC call queues (performance fix for diff --git a/docs/usage/mount.rst b/docs/usage/mount.rst index b0aea14f46..57ea9a8f1c 100644 --- a/docs/usage/mount.rst +++ b/docs/usage/mount.rst @@ -45,6 +45,11 @@ Examples $ borg mount -r /path/to/repo /tmp/mymountpoint only/that/path $ borg mount --exclude '...' /tmp/mymountpoint + # Without a mountpoint, "borg mount" lists the current borg mounts. + $ borg mount + /tmp/mymountpoint + $ borg umount /tmp/mymountpoint + borgfs ++++++ diff --git a/src/borg/archiver/mount_cmds.py b/src/borg/archiver/mount_cmds.py index cdd0f025e7..a6c8193cc4 100644 --- a/src/borg/archiver/mount_cmds.py +++ b/src/borg/archiver/mount_cmds.py @@ -5,9 +5,10 @@ from ..helpers import RTError from ..helpers import PathSpec, FilesystemDirSpec from ..helpers import location_validator -from ..helpers import umount +from ..helpers import umount, json_print from ..helpers.argparsing import ArgumentParser from ..platformflags import is_win32 +from .. import platform from ..logger import create_logger @@ -38,9 +39,29 @@ def use_passcommand_mount_option(args): os.environ["BORG_PASSCOMMAND"] = passcommand +def list_borgfs_mounts(): + """Return the mount table entries of the file systems mounted by borg mount, see platform.list_mounts().""" + from ..vfs import is_borgfs_mount + + return [entry for entry in platform.list_mounts() if is_borgfs_mount(entry.source, entry.fstype)] + + class MountMixIn: def do_mount(self, args): - """Mounts an archive or an entire repository as a FUSE filesystem.""" + """Mounts an archive or an entire repository as a FUSE filesystem, or lists the borg mounts.""" + if args.mountpoint is None: + # Just list the mounted borg file systems. This needs neither a repository nor FUSE. + try: + mounts = list_borgfs_mounts() + except OSError as e: + raise RTError(f"listing the borg mounts failed: {e}") + if args.json: + json_print([entry._asdict() for entry in mounts]) + else: + for entry in mounts: + print(entry.mountpoint) + return + # Perform these checks before opening the repository and asking for a passphrase. from ..fuse_impl import llfuse, has_mfusepy, BORG_FUSE_IMPL, fuse_import_errors @@ -103,6 +124,13 @@ def build_parser_mount_umount(self, subparsers, common_parser, mid_common_parser This command mounts a repository or an archive as a FUSE filesystem. This can be useful for browsing or restoring individual files. + Without a MOUNTPOINT, it lists the mountpoints of all file systems currently + mounted by ``borg mount`` (on Windows: only the mounts on a drive letter, there is + no way to find the directory mountpoints), one per line. ``--json`` gives a JSON + list instead, with the ``mountpoint``, the ``source`` (what the OS reports as + mounted, usually ``borgfs``) and the ``fstype`` (the file system type the OS + reports) of each. This does not need a repository or FUSE. + When restoring, take into account that the current FUSE implementation does not support special fs flags. On Linux, POSIX ACLs of the archived fs objects are exposed (read-only) via the ``system.posix_acl_access`` and @@ -288,9 +316,22 @@ def _define_borg_mount(self, parser, borgfs=False): default=None, help="repository to mount (default: as given by -r/--repo or BORG_REPO)", ) - parser.add_argument( - "mountpoint", metavar="MOUNTPOINT", type=FilesystemDirSpec, help="where to mount the filesystem" - ) + if borgfs: + parser.add_argument( + "mountpoint", metavar="MOUNTPOINT", type=FilesystemDirSpec, help="where to mount the filesystem" + ) + else: + parser.add_argument( + "mountpoint", + metavar="MOUNTPOINT", + nargs="?", + type=FilesystemDirSpec, + default=None, + help="where to mount the filesystem (default: list the mountpoints of the current borg mounts)", + ) + parser.add_argument( + "--json", action="store_true", help="format the list of the current borg mounts as JSON" + ) parser.add_argument( "-f", "--foreground", dest="foreground", action="store_true", help="stay in foreground, do not daemonize" ) diff --git a/src/borg/platform/__init__.py b/src/borg/platform/__init__.py index cfa5b56a9a..93d8d69e9d 100644 --- a/src/borg/platform/__init__.py +++ b/src/borg/platform/__init__.py @@ -13,6 +13,7 @@ from .base import get_process_id, get_hostname, get_fqdn, get_hostid, swidth from .base import acl_text_to_xattr # overridden below for platforms supporting it from .base import set_times # overridden below for win32 +from .base import list_mounts, MountEntry # list_mounts is overridden below for the platforms supporting it # work around pyinstaller "forgetting" to include the xattr module @@ -31,6 +32,7 @@ from .posix import get_errno from .posix import getosusername from . import posix_ug as platform_ug + from .linux import list_mounts # type: ignore[no-redef] elif is_freebsd: # pragma: freebsd only from .freebsd import listxattr, getxattr, setxattr from .freebsd import acl_get, acl_set @@ -42,6 +44,7 @@ from .posix import get_errno from .posix import getosusername from . import posix_ug as platform_ug + from .freebsd import list_mounts # type: ignore[no-redef] elif is_netbsd: # pragma: netbsd only from .netbsd import listxattr, getxattr, setxattr from .base import acl_get, acl_set @@ -52,6 +55,7 @@ from .posix import get_errno from .posix import getosusername from . import posix_ug as platform_ug + from .netbsd import list_mounts # type: ignore[no-redef] elif is_darwin: # pragma: darwin only from .darwin import listxattr, getxattr, setxattr from .darwin import acl_get, acl_set @@ -65,6 +69,7 @@ from .posix import get_errno from .posix import getosusername from . import posix_ug as platform_ug + from .darwin import list_mounts # type: ignore[no-redef] elif is_sunos: # pragma: sunos only from .solaris import listxattr, getxattr, setxattr from .base import acl_get, acl_set @@ -75,6 +80,7 @@ from .posix import get_errno from .posix import getosusername from . import posix_ug as platform_ug + from .solaris import list_mounts # type: ignore[no-redef] elif not is_win32: # pragma: posix only # Generic code for all other POSIX OSes from .base import listxattr, getxattr, setxattr @@ -96,6 +102,7 @@ from .windows import set_times # type: ignore[no-redef] from .windows import process_alive, local_pid_alive from .windows import getosusername + from .windows import list_mounts # type: ignore[no-redef] from . import windows_ug as platform_ug diff --git a/src/borg/platform/base.py b/src/borg/platform/base.py index 0cbcafd590..cbaadcbca9 100644 --- a/src/borg/platform/base.py +++ b/src/borg/platform/base.py @@ -7,10 +7,14 @@ import unicodedata import uuid from pathlib import Path +from typing import NamedTuple from ..helpers import safe_unlink +from ..logger import create_logger from ..platformflags import is_win32 +logger = create_logger() + """ platform base module ==================== @@ -162,6 +166,37 @@ def on_different_mounts(fd1, fd2): return os.fstat(fd1).st_dev != os.fstat(fd2).st_dev +class MountEntry(NamedTuple): + """An entry of the mount table, see list_mounts().""" + + source: str # what is mounted ("device"), e.g. /dev/sda1, borgfs, /dev/puffs; on Windows: the volume label + fstype: str # file system type, e.g. ext4, fuse.borgfs, macfuse, fusefs, puffs|borgfs; on Windows: FUSE, NTFS + mountpoint: str # where it is mounted; on Windows: the drive, e.g. X: + + +def list_mounts(): + """ + Return the mount table: a list of MountEntry (source, fstype, mountpoint) tuples, one per + mounted file system, as the OS reports them. The platform modules implement this via + /proc/self/mountinfo (Linux), /etc/mnttab (illumos), getmntinfo() (macOS, FreeBSD), + getvfsstat() (NetBSD) or the drive letters (Windows: only mounts on a drive letter are found, + a file system mounted on a directory is not). + + How a FUSE file system mounted with ``-o fsname=borgfs`` looks like per platform (measured): + + - Linux: source ``borgfs``, fstype ``fuse`` (``fuse.borgfs`` with the ``subtype=borgfs`` option) + - macOS (macFUSE): source ``borgfs``, fstype ``macfuse`` + - FreeBSD: source ``borgfs``, fstype ``fusefs`` + - NetBSD (librefuse / puffs): source ``/dev/puffs``, fstype ``puffs|borgfs`` + - illumos (libfuse): source ``borgfs``, fstype ``fuse`` + - Windows (WinFsp): the fs name is ``FUSE``, the volume label comes from the ``volname`` option + + Not supported on this platform: returns an empty list after warning about it. + """ + logger.warning("Listing the mounted file systems is not supported on this platform.") + return [] + + def set_times(path, *, atime_ns, mtime_ns, birthtime_ns=None, fd=None, follow_symlinks=True): """ Set the timestamps of *path* (or of the open file descriptor *fd*, if given). diff --git a/src/borg/platform/darwin.pyx b/src/borg/platform/darwin.pyx index 5aa298d24e..4653001e10 100644 --- a/src/borg/platform/darwin.pyx +++ b/src/borg/platform/darwin.pyx @@ -5,6 +5,7 @@ from libc cimport errno from posix.time cimport timespec from . import posix_ug +from .base import MountEntry from ..helpers import safe_decode, safe_encode from .xattr import _listxattr_inner, _getxattr_inner, _setxattr_inner, split_string0 @@ -22,6 +23,16 @@ cdef extern from *: is_darwin_feature_64_bit_inode = DARWIN_FEATURE_64_BIT_INODE_DEFINED != 0 +cdef extern from "sys/mount.h": + # only the members we use (the header defines the struct; with 64-bit inodes - the default on + # current SDKs - getmntinfo maps to getmntinfo$INODE64 on x86_64, the header takes care of that) + cdef struct statfs: + char f_fstypename[16] # MFSTYPENAMELEN + char f_mntonname[1024] # MAXPATHLEN + char f_mntfromname[1024] # MAXPATHLEN + int getmntinfo(statfs **mntbufp, int mode) + int MNT_NOWAIT + cdef extern from "sys/xattr.h": ssize_t c_listxattr "listxattr" (const char *path, char *list, size_t size, int flags) ssize_t c_flistxattr "flistxattr" (int filedes, char *list, size_t size, int flags) @@ -285,3 +296,19 @@ def sync_dir(path): raise finally: os.close(fd) + + +def list_mounts(): + """See platform.base.list_mounts, macOS implementation via getmntinfo(3).""" + cdef statfs *mntbuf + # getmntinfo points mntbuf to an array (allocated by libc, not to be freed) of count struct statfs. + # MNT_NOWAIT: do not ask the file systems for fresh statistics, we only need the names. + cdef int count = getmntinfo(&mntbuf, MNT_NOWAIT) + if count <= 0: + raise OSError(errno.errno, "getmntinfo failed") + return [ + MountEntry( + os.fsdecode(mntbuf[i].f_mntfromname), os.fsdecode(mntbuf[i].f_fstypename), os.fsdecode(mntbuf[i].f_mntonname) + ) + for i in range(count) + ] diff --git a/src/borg/platform/freebsd.pyx b/src/borg/platform/freebsd.pyx index 6501697d69..7b9688fd31 100644 --- a/src/borg/platform/freebsd.pyx +++ b/src/borg/platform/freebsd.pyx @@ -4,11 +4,21 @@ import stat from libc cimport errno from .posix import posix_acl_use_stored_uid_gid +from .base import MountEntry from ..helpers import safe_encode, safe_decode from .xattr import _listxattr_inner, _getxattr_inner, _setxattr_inner, split_lstring +cdef extern from "sys/mount.h": + # only the members we use, the header defines the struct + cdef struct statfs: + char f_fstypename[16] # MFSNAMELEN + char f_mntfromname[1024] # MNAMELEN + char f_mntonname[1024] # MNAMELEN + int getmntinfo(statfs **mntbufp, int mode) + int MNT_NOWAIT + cdef extern from "sys/extattr.h": ssize_t c_extattr_list_file "extattr_list_file" (const char *path, int attrnamespace, void *data, size_t nbytes) ssize_t c_extattr_list_link "extattr_list_link" (const char *path, int attrnamespace, void *data, size_t nbytes) @@ -283,3 +293,19 @@ def set_flags(path, bsd_flags, fd=None): mask = OWNER_SETTABLE_FLAGS_MASK continue raise OSError(err, os.strerror(err), path) + + +def list_mounts(): + """See platform.base.list_mounts, FreeBSD implementation via getmntinfo(3).""" + cdef statfs *mntbuf + # getmntinfo points mntbuf to an array (allocated by libc, not to be freed) of count struct statfs. + # MNT_NOWAIT: do not ask the file systems for fresh statistics, we only need the names. + cdef int count = getmntinfo(&mntbuf, MNT_NOWAIT) + if count <= 0: + raise OSError(errno.errno, "getmntinfo failed") + return [ + MountEntry( + os.fsdecode(mntbuf[i].f_mntfromname), os.fsdecode(mntbuf[i].f_fstypename), os.fsdecode(mntbuf[i].f_mntonname) + ) + for i in range(count) + ] diff --git a/src/borg/platform/linux.pyx b/src/borg/platform/linux.pyx index b2ef611245..6388c2a924 100644 --- a/src/borg/platform/linux.pyx +++ b/src/borg/platform/linux.pyx @@ -8,7 +8,7 @@ from . import posix_ug from ..helpers import workarounds from ..helpers import safe_decode, safe_encode from .base import SyncFile as BaseSyncFile -from .base import safe_fadvise +from .base import safe_fadvise, MountEntry from .xattr import _listxattr_inner, _getxattr_inner, _setxattr_inner, split_string0 try: from .syncfilerange import sync_file_range, SYNC_FILE_RANGE_WRITE, SYNC_FILE_RANGE_WAIT_BEFORE, SYNC_FILE_RANGE_WAIT_AFTER @@ -502,3 +502,49 @@ else: # tell the OS that it does not need to cache what we just wrote, # avoids spoiling the cache for the OS and other processes. safe_fadvise(self.fd, 0, 0, 'DONTNEED') + + +_MOUNTINFO_ESCAPE_RE = re.compile(rb"\\([0-7]{3})") + + +def _unescape_mountinfo(field): + # /proc/self/mountinfo escapes space, tab, newline and backslash as \040, \011, \012 and \134. + # It escapes bytes, so unescape bytes and decode the result, not the other way round. + return os.fsdecode(_MOUNTINFO_ESCAPE_RE.sub(lambda m: bytes([int(m.group(1), 8)]), field)) + + +def parse_mountinfo(lines): + """Parse the lines of /proc/self/mountinfo (bytes), see proc_pid_mountinfo(5). + + Each line is: mount ID, parent ID, major:minor, root, mountpoint, mount options, zero or + more optional fields, a lone "-", fstype, source, super options. As the number of optional + fields varies, the fields after the separator are found from the separator, not from the start. + """ + entries = [] + for line in lines: + fields = line.split() + try: + sep = fields.index(b"-") + except ValueError: + continue # not a mountinfo line + if sep < 5 or len(fields) < sep + 3: + continue + mountpoint, fstype, source = fields[4], fields[sep + 1], fields[sep + 2] + entries.append(MountEntry(_unescape_mountinfo(source), os.fsdecode(fstype), _unescape_mountinfo(mountpoint))) + return entries + + +MOUNTINFO = "/proc/self/mountinfo" + + +def list_mounts(): + """See platform.base.list_mounts, Linux implementation via /proc/self/mountinfo. + + Raises OSError if that file cannot be read (e.g. because /proc is not mounted). + """ + try: + with open(MOUNTINFO, "rb") as f: + lines = f.read().splitlines() + except OSError as e: + raise OSError(e.errno, f"cannot read {MOUNTINFO} (is /proc mounted?): {e.strerror}") from e + return parse_mountinfo(lines) diff --git a/src/borg/platform/netbsd.pyx b/src/borg/platform/netbsd.pyx index 12c2c0904f..e037aeecc4 100644 --- a/src/borg/platform/netbsd.pyx +++ b/src/borg/platform/netbsd.pyx @@ -1,7 +1,23 @@ +import os + +from libc cimport errno +from libc.stdlib cimport malloc, free + +from .base import MountEntry from .xattr import _listxattr_inner, _getxattr_inner, _setxattr_inner, split_lstring +cdef extern from "sys/statvfs.h": + # only the members we use, the header defines the struct (and it maps getvfsstat to the libc + # symbol of the current struct layout, __getvfsstat90 since NetBSD 9) + cdef struct statvfs: + char f_fstypename[32] # _VFS_NAMELEN + char f_mntonname[1024] # _VFS_MNAMELEN + char f_mntfromname[1024] # _VFS_MNAMELEN + int getvfsstat(statvfs *buf, size_t bufsize, int flags) + int ST_NOWAIT + cdef extern from "sys/extattr.h": ssize_t c_extattr_list_file "extattr_list_file" (const char *path, int attrnamespace, void *data, size_t nbytes) ssize_t c_extattr_list_link "extattr_list_link" (const char *path, int attrnamespace, void *data, size_t nbytes) @@ -87,3 +103,27 @@ def setxattr(path, name, value, *, follow_symlinks=False): pass else: _setxattr_inner(func, path, name, value) + + +def list_mounts(): + """See platform.base.list_mounts, NetBSD implementation via getvfsstat(2).""" + cdef statvfs *buf + cdef int count, i + # getvfsstat returns the number of mounted file systems: all of them if buf is NULL, else as many as + # fit into buf. ST_NOWAIT: do not ask the file systems for fresh statistics, we only need the names. + count = getvfsstat(NULL, 0, ST_NOWAIT) + if count < 0: + raise OSError(errno.errno, "getvfsstat failed") + buf = malloc(count * sizeof(statvfs)) + if buf == NULL: + raise MemoryError + try: + count = getvfsstat(buf, count * sizeof(statvfs), ST_NOWAIT) + if count < 0: + raise OSError(errno.errno, "getvfsstat failed") + return [ + MountEntry(os.fsdecode(buf[i].f_mntfromname), os.fsdecode(buf[i].f_fstypename), os.fsdecode(buf[i].f_mntonname)) + for i in range(count) + ] + finally: + free(buf) diff --git a/src/borg/platform/solaris.py b/src/borg/platform/solaris.py index 14194d826e..6f7177d6de 100644 --- a/src/borg/platform/solaris.py +++ b/src/borg/platform/solaris.py @@ -14,7 +14,7 @@ import os import stat -from .base import ENOATTR +from .base import ENOATTR, MountEntry # CPython exposes os.O_XATTR on Solaris-derived platforms; 0x4000 is its value on illumos # and Oracle Solaris (belt and braces in case the os module does not have it). @@ -108,3 +108,24 @@ def setxattr(path, name, value, *, follow_symlinks=False): os.close(fd) finally: os.close(dirfd) + + +def parse_mnttab(lines): + """Parse the lines of /etc/mnttab (str), see mnttab(5). + + Each line has 5 tab-separated fields: special (source), mount point, fstype, options, time. + """ + entries = [] + for line in lines: + fields = line.rstrip("\n").split("\t") + if len(fields) < 3: + continue # not a mnttab line + source, mountpoint, fstype = fields[:3] + entries.append(MountEntry(source, fstype, mountpoint)) + return entries + + +def list_mounts(): + """See platform.base.list_mounts, illumos / Solaris implementation via /etc/mnttab.""" + with open("/etc/mnttab", encoding="utf-8", errors="surrogateescape") as f: + return parse_mnttab(f) diff --git a/src/borg/platform/windows.pyx b/src/borg/platform/windows.pyx index 075b181346..b291af6d7e 100644 --- a/src/borg/platform/windows.pyx +++ b/src/borg/platform/windows.pyx @@ -6,6 +6,7 @@ import os import platform from .base import SyncFile as BaseSyncFile +from .base import MountEntry cdef extern from 'windows.h': @@ -196,3 +197,25 @@ def process_alive(host, pid, thread): def local_pid_alive(pid): """Return whether *pid* is alive.""" raise NotImplementedError + + +def list_mounts(): + """See platform.base.list_mounts, Windows implementation: the volumes mounted on drive letters. + + A file system mounted on a directory (a WinFsp directory mountpoint is a junction pointing to the + volume) is not found this way, there is no API to enumerate these mountpoints. + """ + buf = ctypes.create_unicode_buffer(1024) + length = _kernel32.GetLogicalDriveStringsW(len(buf) - 1, buf) + if not length: + raise ctypes.WinError(ctypes.get_last_error()) + entries = [] + for root in buf[:length].split("\0"): # e.g. "C:\\", "X:\\" - GetVolumeInformationW needs the backslash + if not root: + continue + label = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH + 1) + fsname = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH + 1) + if not _kernel32.GetVolumeInformationW(root, label, len(label), None, None, None, fsname, len(fsname)): + continue # e.g. a drive without media or a disconnected network drive + entries.append(MountEntry(label.value, fsname.value, root.rstrip("\\"))) + return entries diff --git a/src/borg/testsuite/archiver/mount_cmds_test.py b/src/borg/testsuite/archiver/mount_cmds_test.py index a79b968cd8..a238d103ac 100644 --- a/src/borg/testsuite/archiver/mount_cmds_test.py +++ b/src/borg/testsuite/archiver/mount_cmds_test.py @@ -5,6 +5,7 @@ # The tox configuration (pyproject.toml) runs these tests with different BORG_FUSE_IMPL settings. import errno +import json import os import shlex import stat @@ -29,6 +30,7 @@ from . import requires_hardlinks, _extract_hardlinks_setup, fuse_mount, create_test_files, generate_archiver_tests from . import Archiver from ...archiver.mount_cmds import use_passcommand_mount_option +from ...platform import MountEntry pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,binary") # NOQA @@ -676,6 +678,68 @@ def test_borg_rejects_unknown_config_keys(monkeypatch, tmp_path): assert args.log_level == "info" +# "borg mount" without a MOUNTPOINT lists the mountpoints of the file systems mounted by borg mount: +# the entries of the mount table (platform.list_mounts) that vfs.is_borgfs_mount recognizes. +# This needs neither a repository nor FUSE. + +FAKE_MOUNT_TABLE = [ + MountEntry("/dev/sda1", "ext4", "/"), + MountEntry("borgfs", "fuse.borgfs", "/mnt/linux"), + MountEntry("sshfs#user@host:", "fuse.sshfs", "/mnt/sshfs"), + MountEntry("/dev/puffs", "puffs|borgfs", "/mnt/netbsd"), +] + + +@pytest.fixture +def fake_mount_table(monkeypatch): + monkeypatch.setattr("borg.vfs.is_win32", False) + monkeypatch.setattr("borg.platform.list_mounts", lambda: FAKE_MOUNT_TABLE) + + +def test_mount_without_mountpoint_lists_borg_mounts(archiver, fake_mount_table): + # note: the repository the archiver fixture refers to does not exist (and is not needed). + output = cmd(archiver, "mount") + assert output.splitlines() == ["/mnt/linux", "/mnt/netbsd"] + + +def test_mount_without_mountpoint_lists_borg_mounts_json(archiver, fake_mount_table): + output = cmd(archiver, "mount", "--json") + assert json.loads(output) == [ + {"source": "borgfs", "fstype": "fuse.borgfs", "mountpoint": "/mnt/linux"}, + {"source": "/dev/puffs", "fstype": "puffs|borgfs", "mountpoint": "/mnt/netbsd"}, + ] + + +def test_mount_without_mountpoint_does_not_need_fuse(archiver, fake_mount_table, monkeypatch): + monkeypatch.setattr("borg.fuse_impl.llfuse", None) + monkeypatch.setattr("borg.fuse_impl.has_mfusepy", False) + output = cmd(archiver, "mount") + assert output.splitlines() == ["/mnt/linux", "/mnt/netbsd"] + # the pre-mount checks (and FUSE) are still needed when a MOUNTPOINT is given. + with pytest.raises(RTError, match="no FUSE support"): + cmd(archiver, "mount", "/mnt/point") + + +@pytest.mark.skipif(not has_any_fuse, reason="FUSE not available") +@pytest.mark.skipif(is_win32, reason="directory mountpoints can not be listed on Windows") +def test_fuse_mount_is_listed(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + create_src_archive(archiver, "archive") + mountpoint = os.path.join(archiver.tmpdir, "mount point") + + def listed_mountpoints(): + # the OS might show the mountpoint with symlinks resolved (e.g. /private/tmp on macOS) + return [os.path.realpath(mp) for mp in cmd(archiver, "mount").splitlines()] + + with fuse_mount(archiver, mountpoint): + assert os.path.realpath(mountpoint) in listed_mountpoints() + mounts = json.loads(cmd(archiver, "mount", "--json")) + entry = next(m for m in mounts if os.path.realpath(m["mountpoint"]) == os.path.realpath(mountpoint)) + assert entry["source"] == "borgfs" or entry["fstype"].endswith("borgfs") + assert os.path.realpath(mountpoint) not in listed_mountpoints() + + def test_borg_mount_has_no_repository_positional(): # borg mount is unchanged: MOUNTPOINT [PATH...], the repository only comes from -r/--repo. archiver = Archiver(prog="borg") diff --git a/src/borg/testsuite/platform/linux_test.py b/src/borg/testsuite/platform/linux_test.py index 6a4a3df3bd..b8389182a5 100644 --- a/src/borg/testsuite/platform/linux_test.py +++ b/src/borg/testsuite/platform/linux_test.py @@ -5,10 +5,11 @@ import pytest from ...platform import acl_get, acl_set +from ...platform.base import MountEntry from .platform_test import skipif_not_linux, skipif_fakeroot_detected, skipif_acls_not_working, skipif_no_ubel_user # set module-level skips -pytestmark = [skipif_not_linux, skipif_fakeroot_detected] +pytestmark = [skipif_not_linux] ACCESS_ACL = """\ @@ -50,6 +51,7 @@ def set_acl(path, access=None, default=None, numeric_ids=False): @skipif_acls_not_working +@skipif_fakeroot_detected def test_access_acl(): file = tempfile.NamedTemporaryFile() assert get_acl(file.name) == {} @@ -74,6 +76,7 @@ def test_access_acl(): @skipif_acls_not_working +@skipif_fakeroot_detected def test_default_acl(): with tempfile.TemporaryDirectory() as tmpdir: assert get_acl(tmpdir) == {} @@ -84,6 +87,7 @@ def test_default_acl(): @skipif_acls_not_working @skipif_no_ubel_user +@skipif_fakeroot_detected def test_non_ascii_acl(): # Testing non-ASCII ACL processing to see whether our code is robust. # I have no idea whether non-ASCII ACLs are allowed by the standard, @@ -118,6 +122,7 @@ def test_non_ascii_acl(): assert group_entry_numeric in acl_access_numeric +@skipif_fakeroot_detected def test_acl_text_to_xattr(): from ...platform.linux import acl_text_to_xattr @@ -145,6 +150,7 @@ def test_acl_text_to_xattr(): @skipif_acls_not_working +@skipif_fakeroot_detected def test_acl_text_to_xattr_matches_kernel(): from ...platform.linux import acl_text_to_xattr @@ -160,6 +166,7 @@ def test_acl_text_to_xattr_matches_kernel(): assert acl_text_to_xattr(item["acl_access"], numeric_ids=True) == kernel_binary +@skipif_fakeroot_detected def test_utils(): from ...platform.linux import acl_use_local_uid_gid @@ -169,6 +176,7 @@ def test_utils(): assert acl_use_local_uid_gid(b"group:root:rw-:0") == b"group:0:rw-" +@skipif_fakeroot_detected def test_numeric_to_named_with_id_simple(monkeypatch): # Import here to ensure skip marker is applied before any platform-specific import side effects. from ...platform.linux import _acl_from_numeric_to_named_with_id @@ -200,6 +208,7 @@ def _gid2group(gid, default=None): assert b"other::r--" in lines +@skipif_fakeroot_detected def test_numeric_to_named_with_id_nonexistent_ids(monkeypatch): from ...platform.linux import _acl_from_numeric_to_named_with_id @@ -222,6 +231,7 @@ def _gid2group(gid, default=None): assert lines[1] == b"group:8888:r--:8888" +@skipif_fakeroot_detected def test_numeric_to_numeric_with_id_simple(): from ...platform.linux import _acl_from_numeric_to_numeric_with_id @@ -234,3 +244,53 @@ def test_numeric_to_numeric_with_id_simple(): assert b"group:100:r--:100" in lines assert b"mask::r-x" in lines assert b"other::r--" in lines + + +# /proc/self/mountinfo lines captured on Debian 13 (the fuse lines while borg mount was running, +# with "subtype=borgfs" / without it / with "-o fsname=other"; the mountpoint has a space in it). +MOUNTINFO = b"""\ +24 29 0:22 / /proc rw,nosuid,nodev,noexec,relatime shared:11 - proc proc rw +25 29 0:6 / /dev rw,nosuid,relatime shared:2 - devtmpfs udev rw,size=4045056k,nr_inodes=1011264,mode=755,inode64 +29 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw,errors=remount-ro +31 25 0:26 / /dev/shm rw,nosuid,nodev shared:4 - tmpfs tmpfs rw,size=4068456k,nr_inodes=1017114,inode64 +67 42 0:48 / /tmp/m14/mnt\\040point ro,nosuid,nodev,relatime shared:150 - fuse.borgfs borgfs ro,user_id=0,group_id=0 +68 42 0:49 / /tmp/m14/mnt2 ro,nosuid,nodev,relatime shared:151 - fuse borgfs ro,user_id=0,group_id=0 +69 42 0:50 / /tmp/m14/mnt3 ro,nosuid,nodev,relatime shared:152 - fuse.borgfs other ro,user_id=0,group_id=0 +""" + + +def test_parse_mountinfo(): + from ...platform.linux import parse_mountinfo + + assert parse_mountinfo(MOUNTINFO.splitlines()) == [ + MountEntry("proc", "proc", "/proc"), + MountEntry("udev", "devtmpfs", "/dev"), + MountEntry("/dev/sda1", "ext4", "/"), + MountEntry("tmpfs", "tmpfs", "/dev/shm"), + MountEntry("borgfs", "fuse.borgfs", "/tmp/m14/mnt point"), + MountEntry("borgfs", "fuse", "/tmp/m14/mnt2"), + MountEntry("other", "fuse.borgfs", "/tmp/m14/mnt3"), + ] + + +def test_parse_mountinfo_escapes(): + from ...platform.linux import parse_mountinfo + + # space, tab, newline and backslash are escaped as octal byte values; non-ascii bytes are not escaped. + line = b"1 2 0:3 / /mnt/a\\040b\\011c\\012d\\134e\\303\\244f\xc3\xb6 rw - ext4 /dev/x\\040y rw" + assert parse_mountinfo([line]) == [MountEntry("/dev/x y", "ext4", "/mnt/a b\tc\nd\\eäfö")] + + +def test_parse_mountinfo_skips_garbage(): + from ...platform.linux import parse_mountinfo + + assert parse_mountinfo([b"", b"no separator here", b"1 2 - ext4 /dev/x rw"]) == [] + + +def test_list_mounts_without_proc(monkeypatch, tmp_path): + # /proc is not necessarily mounted (chroots, minimal containers): expect a clear OSError, not a traceback. + from ...platform import linux + + monkeypatch.setattr(linux, "MOUNTINFO", str(tmp_path / "mountinfo")) + with pytest.raises(OSError, match=r"cannot read .*mountinfo \(is /proc mounted\?\)"): + linux.list_mounts() diff --git a/src/borg/testsuite/platform/platform_test.py b/src/borg/testsuite/platform/platform_test.py index d44090203e..2c6c59f09e 100644 --- a/src/borg/testsuite/platform/platform_test.py +++ b/src/borg/testsuite/platform/platform_test.py @@ -5,10 +5,10 @@ import pytest -from ...platformflags import is_darwin, is_freebsd, is_linux, is_win32 +from ...platformflags import is_darwin, is_freebsd, is_linux, is_netbsd, is_sunos, is_win32 from ...platform import acl_get, acl_set from ...platform import get_process_id, process_alive -from ...platform import base +from ...platform import base, list_mounts, MountEntry from .. import unopened_tempfile from ..fslocking_test import free_pid # NOQA @@ -175,3 +175,20 @@ def fake_lstat(path, *args, **kwargs): monkeypatch.setattr(os, "lchflags", lambda p, f: calls.append(f), raising=False) base.set_flags(FLAGS_TESTFILE, stat.UF_NODUMP) assert calls == [] + + +@pytest.mark.skipif( + not (is_linux or is_darwin or is_freebsd or is_netbsd or is_sunos or is_win32), + reason="listing the mounted file systems is not supported on this platform", +) +def test_list_mounts(): + mounts = list_mounts() + assert mounts and all(isinstance(entry, MountEntry) for entry in mounts) + root = os.environ.get("SystemDrive", "C:") if is_win32 else "/" + entry = next(entry for entry in mounts if entry.mountpoint == root) + assert entry.fstype # e.g. ext4, apfs, ufs, ffs, zfs, NTFS + + +def test_list_mounts_unsupported(): + # the base implementation (used on the platforms without support) warns and returns an empty list. + assert base.list_mounts() == [] diff --git a/src/borg/testsuite/platform/solaris_test.py b/src/borg/testsuite/platform/solaris_test.py new file mode 100644 index 0000000000..b006acc155 --- /dev/null +++ b/src/borg/testsuite/platform/solaris_test.py @@ -0,0 +1,27 @@ +# Tests for the illumos / Solaris platform module (pure Python, so they run everywhere). + +from ...platform.base import MountEntry +from ...platform.solaris import parse_mnttab + +# /etc/mnttab lines captured on OmniOS r151058 (the fuse line while borg mount was running). +MNTTAB = """\ +rpool/ROOT/omnios-r151058u\t/\tzfs\tdev=4310002\t0 +/devices\t/devices\tdevfs\tdev=89c0000\t1791368839 +proc\t/proc\tproc\tdev=8a80000\t1791368839 +swap\t/tmp\ttmpfs\tdev=8b40002\t1791368839 +borgfs\t/root/mnt\tfuse\tdev=8c40001\t1791369000 +""" + + +def test_parse_mnttab(): + assert parse_mnttab(MNTTAB.splitlines(keepends=True)) == [ + MountEntry("rpool/ROOT/omnios-r151058u", "zfs", "/"), + MountEntry("/devices", "devfs", "/devices"), + MountEntry("proc", "proc", "/proc"), + MountEntry("swap", "tmpfs", "/tmp"), + MountEntry("borgfs", "fuse", "/root/mnt"), + ] + + +def test_parse_mnttab_skips_garbage(): + assert parse_mnttab(["", "just one field\n"]) == [] diff --git a/src/borg/testsuite/vfs_test.py b/src/borg/testsuite/vfs_test.py index 600a8d62af..864d01db2c 100644 --- a/src/borg/testsuite/vfs_test.py +++ b/src/borg/testsuite/vfs_test.py @@ -10,6 +10,7 @@ from ..platform import acl_text_to_xattr from ..platformflags import is_win32 from ..vfs import VFSNode, item_getxattr, item_listxattr, lookup_child, parse_mount_options, versioned_name +from ..vfs import is_borgfs_mount from .platform.platform_test import skipif_not_linux ACCESS_ACL = b"user::rw-\ngroup::r--\nmask::rw-\nother::---\nuser:root:rw-:0\ngroup:root:rw-:0\n" @@ -124,12 +125,59 @@ class MountArgs: def test_parse_mount_options_posix(monkeypatch): monkeypatch.setattr("borg.vfs.is_win32", False) monkeypatch.setattr("borg.vfs.is_darwin", False) + monkeypatch.setattr("borg.vfs.is_linux", False) options, vfs_options = parse_mount_options(MountArgs(), "/mnt/point", "uid=0,gid=0,allow_other") # uid and gid are implemented by borg, so they are not passed on to libfuse. assert options == ["fsname=borgfs", "ro", "default_permissions", "allow_other"] assert (vfs_options.uid_forced, vfs_options.gid_forced) == (0, 0) +@pytest.mark.skipif(is_win32, reason="needs os.getuid / os.getgid") +def test_parse_mount_options_linux_subtype(monkeypatch): + # on Linux, the subtype makes the file system type "fuse.borgfs", so the mount can be recognized + # (is_borgfs_mount) even if the user overrides fsname. + monkeypatch.setattr("borg.vfs.is_win32", False) + monkeypatch.setattr("borg.vfs.is_darwin", False) + monkeypatch.setattr("borg.vfs.is_linux", True) + options, vfs_options = parse_mount_options(MountArgs(), "/mnt/point", "fsname=other") + assert options == ["fsname=borgfs", "ro", "default_permissions", "subtype=borgfs", "fsname=other"] + + +@pytest.mark.parametrize( + "source, fstype, expected", + [ + ("borgfs", "fuse.borgfs", True), # Linux + ("other", "fuse.borgfs", True), # Linux, -o fsname=other + ("borgfs", "fuse", True), # illumos (and Linux before subtype=borgfs was given) + ("borgfs", "macfuse", True), # macOS + ("borgfs", "fusefs", True), # FreeBSD + ("/dev/puffs", "puffs|borgfs", True), # NetBSD: the fsname ends up in the fstype + ("/dev/puffs", "puffs|sshfs", False), + ("sshfs#user@host:", "fuse.sshfs", False), + ("/dev/sda1", "ext4", False), + ("borgfs", "nfs", True), # the rule only looks at the names, not at the plausibility + ], +) +def test_is_borgfs_mount(monkeypatch, source, fstype, expected): + monkeypatch.setattr("borg.vfs.is_win32", False) + assert is_borgfs_mount(source, fstype) is expected + + +@pytest.mark.parametrize( + "source, fstype, expected", + [ + ("point (borgfs)", "FUSE", True), # the default volname for a directory mountpoint + ("borgfs", "FUSE", True), # the default volname for a drive mountpoint + ("backup", "FUSE", False), # -o volname=backup + ("Windows", "NTFS", False), + ("borgfs", "NTFS", False), + ], +) +def test_is_borgfs_mount_win32(monkeypatch, source, fstype, expected): + monkeypatch.setattr("borg.vfs.is_win32", True) + assert is_borgfs_mount(source, fstype) is expected + + @pytest.mark.parametrize( "cli_strip_components, mount_options, expected", [(0, None, 0), (2, None, 2), (0, "strip_components=3", 3), (2, "strip_components=3", 3)], diff --git a/src/borg/vfs.py b/src/borg/vfs.py index 5967592520..d74f417492 100644 --- a/src/borg/vfs.py +++ b/src/borg/vfs.py @@ -693,6 +693,20 @@ def pop_option(options, key, present, not_present, wanted_type, int_base=0): return not_present +def is_borgfs_mount(source, fstype): + """Is this mount table entry (see platform.list_mounts) a file system mounted by borg mount? + + parse_mount_options gives libfuse "fsname=borgfs" (and "subtype=borgfs" on Linux), which the OSes + show as source "borgfs" (Linux, macOS, FreeBSD, illumos) or as fstype "puffs|borgfs" (NetBSD) resp. + "fuse.borgfs" (Linux, independent of the fsname). WinFsp ignores fsname, there the file system name + is always "FUSE" and the volume label comes from the volname option, which defaults to " + (borgfs)" or "borgfs". + """ + if is_win32: + return fstype == "FUSE" and "borgfs" in source + return source == "borgfs" or fstype.endswith("borgfs") + + def parse_mount_options(args, mountpoint, mount_options): """Process the "borg mount" options; returns (libfuse options, VFSOptions). @@ -705,6 +719,10 @@ def parse_mount_options(args, mountpoint, mount_options): # When not using allow_other or allow_root, access is limited to the # mounting user anyway. options = ["fsname=borgfs", "ro", "default_permissions"] + if is_linux: + # the file system type then is "fuse.borgfs" (that is also what fstab entries use), so a borg + # mount can be recognized in the mount table even if the user overrides fsname, see is_borgfs_mount. + options.append("subtype=borgfs") if is_win32: # WinFsp builds the Windows security descriptor of a file from its uid, gid and mode and checks # all access against it. The archived uids / gids do not refer to anybody on a Windows machine,