mirror of
https://github.com/borgbackup/borg.git
synced 2026-10-12 02:11:36 +00:00
mount: support Windows using WinFsp (via mfusepy), fixes #2316
mfusepy already knows how to talk to the FUSE 2 API of WinFsp, so this is about the borg side: - mountpoint: WinFsp mounts on an unused drive (X:) or creates (and later removes) the mountpoint directory, so it must not exist yet. - there is no fork: borg mount always stays in the foreground. Ctrl-C or Ctrl-Break unmount. borg umount is not supported there. - WinFsp ignores the "ro" mount option, a read-only volume has to be requested from the init callback (FSP_FUSE_CAP_READ_ONLY). - WinFsp makes a security descriptor from uid / gid / mode and checks all access against it. The archived uids / gids do not mean anything on Windows, so the uid and gid mount options are left to WinFsp and default to -1 (the user who mounts). - volname gives the volume label (default: borgfs). - st_birthtime is given to mfusepy, WinFsp uses it as the creation time. - readlink of something that is not a symlink fails with EINVAL (WinFsp tries that for "/" to find out whether symlinks are supported). Tests: on Windows, fuse_mount runs borg mount as a child process and stops it like Ctrl-Break does. WinFsp does not support hard link counts. With mfusepy 3.1.1, symlinks show up as regular files, because WinFsp has getdir and readlink in a different order in struct fuse_operations than libfuse has. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a6904402ef
commit
0e316b1b52
@@ -122,7 +122,7 @@ it.
|
||||
|
||||
.. note:: ``borg mount`` only works if the binary was built with FUSE support
|
||||
(third-party binaries might lack it) and your OS has FUSE installed
|
||||
(e.g. the ``fuse3`` package on Linux, macFUSE on macOS).
|
||||
(e.g. the ``fuse3`` package on Linux, macFUSE on macOS, WinFsp on Windows).
|
||||
If ``borg mount`` fails, the error message will tell for each FUSE
|
||||
implementation why it could not be loaded.
|
||||
|
||||
@@ -344,6 +344,10 @@ Install the dependencies with the provided script::
|
||||
|
||||
./scripts/msys2-install-deps
|
||||
|
||||
For FUSE support to mount the backup archives, you need `WinFsp <https://winfsp.dev/>`_
|
||||
(e.g. ``winget install WinFsp.WinFsp``) and, when installing Borg via ``pip``, the
|
||||
``mfusepy`` extra (``pip install borgbackup[mfusepy]``).
|
||||
|
||||
.. _msys2_path_translation:
|
||||
|
||||
MSYS2 Path Translation
|
||||
|
||||
@@ -7,6 +7,7 @@ from ..helpers import PathSpec, FilesystemDirSpec
|
||||
from ..helpers import location_validator
|
||||
from ..helpers import umount
|
||||
from ..helpers.argparsing import ArgumentParser
|
||||
from ..platformflags import is_win32
|
||||
|
||||
from ..logger import create_logger
|
||||
|
||||
@@ -25,11 +26,23 @@ class MountMixIn:
|
||||
msg += "".join(f"\nimport of {impl} failed: {err}" for impl, err in fuse_import_errors.items())
|
||||
raise RTError(msg)
|
||||
|
||||
if not os.path.isdir(args.mountpoint):
|
||||
raise RTError(f"{args.mountpoint}: Mountpoint must be an **existing directory**")
|
||||
if is_win32:
|
||||
# WinFsp either mounts on an unused drive or it creates (and later removes) the mountpoint directory.
|
||||
drive, tail = os.path.splitdrive(args.mountpoint)
|
||||
if drive and tail in ("", "/"):
|
||||
args.mountpoint = drive # WinFsp wants X: (not X:/).
|
||||
if os.path.exists(drive + "/"):
|
||||
raise RTError(f"{args.mountpoint}: Mountpoint must be an **unused** drive")
|
||||
elif os.path.lexists(args.mountpoint):
|
||||
raise RTError(f"{args.mountpoint}: Mountpoint must be an unused drive or a **not existing** directory")
|
||||
elif not os.path.isdir(os.path.dirname(os.path.abspath(args.mountpoint))):
|
||||
raise RTError(f"{args.mountpoint}: Mountpoint must be in an **existing directory**")
|
||||
else:
|
||||
if not os.path.isdir(args.mountpoint):
|
||||
raise RTError(f"{args.mountpoint}: Mountpoint must be an **existing directory**")
|
||||
|
||||
if not os.access(args.mountpoint, os.R_OK | os.W_OK | os.X_OK):
|
||||
raise RTError(f"{args.mountpoint}: Mountpoint must be a **writable** directory")
|
||||
if not os.access(args.mountpoint, os.R_OK | os.W_OK | os.X_OK):
|
||||
raise RTError(f"{args.mountpoint}: Mountpoint must be a **writable** directory")
|
||||
|
||||
self._do_mount(args)
|
||||
|
||||
@@ -52,6 +65,9 @@ class MountMixIn:
|
||||
|
||||
def do_umount(self, args):
|
||||
"""Unmounts the FUSE filesystem."""
|
||||
if is_win32:
|
||||
# there is no fusermount / umount for WinFsp.
|
||||
raise RTError("borg umount is not supported on Windows: to unmount, stop borg mount (e.g. using Ctrl-C).")
|
||||
umount(args.mountpoint)
|
||||
|
||||
def build_parser_mount_umount(self, subparsers, common_parser, mid_common_parser):
|
||||
@@ -152,6 +168,15 @@ class MountMixIn:
|
||||
option is internally enforced by Borg. ``ignore_permissions`` can be given to
|
||||
not enforce ``default_permissions``.
|
||||
|
||||
On Windows, ``borg mount`` needs `WinFsp <https://winfsp.dev/>`_ and mfusepy.
|
||||
MOUNTPOINT must either be an unused drive (like ``X:``) or a not yet existing
|
||||
directory (WinFsp creates it when mounting and removes it when unmounting).
|
||||
``borg mount`` always stays in the foreground there and ``borg umount`` is not
|
||||
supported: to unmount, stop ``borg mount`` using Ctrl-C or Ctrl-Break. All files
|
||||
belong to the user who mounts, unless the ``uid`` / ``gid`` mount options (which
|
||||
are implemented by WinFsp there, not by Borg) say otherwise. ``ignore_permissions``
|
||||
has no effect. WinFsp does not support hard link counts (they are always 1).
|
||||
|
||||
The BORG_MOUNT_DATA_CACHE_ENTRIES environment variable is intended for advanced users
|
||||
to tweak performance. It sets the number of cached data chunks; additional
|
||||
memory usage can be up to ~8 MiB times this number. The default is the number
|
||||
|
||||
+20
-2
@@ -4,6 +4,8 @@
|
||||
This is a protocol adapter only: what an archive looks like as a file system is
|
||||
defined in vfs.py, here we just translate between that and the mfusepy operations
|
||||
interface (paths and stat dicts in, errnos out).
|
||||
|
||||
On Windows, mfusepy uses the FUSE 2 API of WinFsp (https://winfsp.dev/).
|
||||
"""
|
||||
|
||||
import errno
|
||||
@@ -23,12 +25,16 @@ from .logger import create_logger
|
||||
logger = create_logger()
|
||||
|
||||
from .helpers import daemonizing, signal_handler
|
||||
from .platformflags import is_win32
|
||||
from .storelocking import LockRefresher
|
||||
from .crypto.low_level import IntegrityError as IntegrityErrorBase
|
||||
from .vfs import ArchiveVFS, ChunkMissing, parse_mount_options
|
||||
|
||||
BLOCK_SIZE = 512 # Standard filesystem block size for st_blocks and statfs
|
||||
|
||||
# WinFsp: if the file system wants this capability (see fuse_conn_info.want), the volume is read-only.
|
||||
FSP_FUSE_CAP_READ_ONLY = 1 << 22
|
||||
|
||||
|
||||
def fuse_options(options):
|
||||
"""Convert a libfuse option list (e.g. ["ro", "fsname=borgfs"]) to mfusepy keyword arguments."""
|
||||
@@ -63,7 +69,7 @@ class borgfs(hlfuse.Operations):
|
||||
self.vfs.create_filesystem()
|
||||
|
||||
# hlfuse.FUSE will block if foreground=True, otherwise it returns immediately
|
||||
if not foreground:
|
||||
if not foreground and not is_win32: # Windows: can not daemonize (no fork), always stays in foreground.
|
||||
# Background mode: daemonize first, then start FUSE (blocking)
|
||||
with daemonizing(show_rc=show_rc) as (old_id, new_id):
|
||||
logger.debug("fuse: mount repo, going to background: migrating lock.")
|
||||
@@ -117,18 +123,26 @@ class borgfs(hlfuse.Operations):
|
||||
"st_size": attrs.size,
|
||||
"st_blocks": (attrs.size + BLOCK_SIZE - 1) // BLOCK_SIZE,
|
||||
}
|
||||
# st_birthtime: mfusepy only uses it if the platform's struct stat has it (Linux does not have it).
|
||||
if self.use_ns:
|
||||
st["st_mtime"] = attrs.mtime_ns
|
||||
st["st_atime"] = attrs.atime_ns
|
||||
st["st_ctime"] = attrs.ctime_ns
|
||||
st["st_birthtime"] = attrs.birthtime_ns
|
||||
else:
|
||||
st["st_mtime"] = attrs.mtime_ns / 1e9
|
||||
st["st_atime"] = attrs.atime_ns / 1e9
|
||||
st["st_ctime"] = attrs.ctime_ns / 1e9
|
||||
st["st_birthtime"] = attrs.birthtime_ns / 1e9
|
||||
return st
|
||||
|
||||
# -- filesystem operations -------------------------------------------------
|
||||
|
||||
def init_with_config(self, conn_info, config_3):
|
||||
if is_win32 and conn_info is not None:
|
||||
# WinFsp ignores the "ro" mount option, a read-only volume must be requested here.
|
||||
conn_info.want |= FSP_FUSE_CAP_READ_ONLY
|
||||
|
||||
def statfs(self, path):
|
||||
return {
|
||||
"f_bsize": BLOCK_SIZE,
|
||||
@@ -200,4 +214,8 @@ class borgfs(hlfuse.Operations):
|
||||
|
||||
def readlink(self, path):
|
||||
node = self._find_node(path)
|
||||
return self.vfs.readlink(node.ino)
|
||||
try:
|
||||
return self.vfs.readlink(node.ino)
|
||||
except ValueError:
|
||||
# not a symlink: like readlink(2). WinFsp calls this for "/" to find out whether we support symlinks.
|
||||
raise hlfuse.FuseOSError(errno.EINVAL) from None
|
||||
|
||||
@@ -4,6 +4,7 @@ import io
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -444,8 +445,8 @@ def _assert_dirs_equal_cmp(diff, ignore_flags=False, ignore_xattrs=False, ignore
|
||||
# Assume path2 is on FUSE if st_dev is different
|
||||
fuse = s1.st_dev != s2.st_dev
|
||||
attrs = ["st_uid", "st_gid", "st_rdev"]
|
||||
if not fuse or not os.path.isdir(path1):
|
||||
# dir nlink is always 1 on our FUSE filesystem
|
||||
if not fuse or not (os.path.isdir(path1) or is_win32):
|
||||
# dir nlink is always 1 on our FUSE filesystem, WinFsp does not support hard link counts at all.
|
||||
attrs.append("st_nlink")
|
||||
d1 = [filename] + [getattr(s1, a) for a in attrs]
|
||||
d2 = [filename] + [getattr(s2, a) for a in attrs]
|
||||
@@ -531,6 +532,51 @@ def wait_for_mountstate(mountpoint, *, mounted, timeout=5):
|
||||
raise TimeoutError(message)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def winfsp_mount(archiver, mountpoint=None, *options, exit_code=EXIT_SUCCESS):
|
||||
"""fuse_mount for Windows (WinFsp).
|
||||
|
||||
WinFsp creates the mountpoint directory when mounting and removes it when unmounting. borg mount
|
||||
always stays in the foreground and there is no borg umount, so we run it as a child process that
|
||||
we stop like Ctrl-Break would do (which unmounts).
|
||||
"""
|
||||
if mountpoint is None:
|
||||
mountpoint = os.path.join(tempfile.mkdtemp(), "mountpoint")
|
||||
borg = (sys.executable, "-m", "borg") if archiver.EXE is None else (archiver.EXE,)
|
||||
args = [*borg, f"--repo={archiver.repository_location}", "mount", "--foreground", mountpoint, *options]
|
||||
# own process group, so that the Ctrl-Break only goes to borg mount. the output goes to a file, as
|
||||
# nobody reads from a pipe while the mount is in use.
|
||||
with tempfile.TemporaryFile() as output:
|
||||
proc = subprocess.Popen(
|
||||
args, stdout=output, stderr=subprocess.STDOUT, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
)
|
||||
try:
|
||||
timeout = time.time() + 30
|
||||
while proc.poll() is None and time.time() < timeout:
|
||||
try:
|
||||
os.listdir(mountpoint) # the directory exists slightly before it can be used
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
if exit_code == EXIT_SUCCESS and proc.poll() is None:
|
||||
yield
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.send_signal(signal.CTRL_BREAK_EVENT)
|
||||
try:
|
||||
ret = proc.wait(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
ret = proc.wait()
|
||||
output.seek(0)
|
||||
if ret != exit_code:
|
||||
print(os.fsdecode(output.read()))
|
||||
assert ret == exit_code
|
||||
assert not os.path.exists(mountpoint)
|
||||
if exit_code != EXIT_SUCCESS:
|
||||
yield # see fuse_mount: the test only wanted to see the mount fail.
|
||||
|
||||
|
||||
@contextmanager
|
||||
def fuse_mount(archiver, mountpoint=None, *options, fork=True, os_fork=False, **kwargs):
|
||||
# For a successful mount, `fork = True` is required for
|
||||
@@ -547,6 +593,10 @@ def fuse_mount(archiver, mountpoint=None, *options, fork=True, os_fork=False, **
|
||||
# example where we need `fork = False`, because the test case
|
||||
# needs an OS fork, not a spawning of the fuse mount.
|
||||
# `fork = False` is implied if `os_fork = True`.
|
||||
if is_win32:
|
||||
with winfsp_mount(archiver, mountpoint, *options, **kwargs):
|
||||
yield
|
||||
return
|
||||
if mountpoint is None:
|
||||
mountpoint = tempfile.mkdtemp()
|
||||
else:
|
||||
|
||||
@@ -15,7 +15,8 @@ import pytest
|
||||
from ... import xattr, platform
|
||||
from ...constants import * # NOQA
|
||||
from ...storelocking import Lock
|
||||
from ...helpers import flags_noatime, flags_normal, Location
|
||||
from ...helpers import flags_noatime, flags_normal, Location, RTError
|
||||
from ...platformflags import is_win32
|
||||
from .. import has_lchflags, has_any_fuse, ENOATTR
|
||||
from .. import changedir, filter_xattrs, same_ts_ns
|
||||
from .. import are_symlinks_supported, are_hardlinks_supported, are_fifos_supported
|
||||
@@ -29,6 +30,36 @@ from . import Archiver
|
||||
pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,binary") # NOQA
|
||||
|
||||
|
||||
def assert_nlink(path, nlink):
|
||||
# WinFsp does not support hard link counts, they are always 1 there.
|
||||
assert os.stat(path).st_nlink == (1 if is_win32 else nlink)
|
||||
|
||||
|
||||
def fuse_symlinks_supported():
|
||||
"""Does a mount show symlinks as symlinks?"""
|
||||
if is_win32:
|
||||
# WinFsp has getdir before readlink in its struct fuse_operations. If mfusepy has them the other way
|
||||
# round (like libfuse has them), WinFsp does not find our readlink and shows symlinks as regular files.
|
||||
import mfusepy
|
||||
|
||||
names = [field[0] for field in mfusepy.fuse_operations._fields_]
|
||||
return names.index("getdir") < names.index("readlink")
|
||||
return True
|
||||
|
||||
|
||||
def is_readonly_mount(mountpoint):
|
||||
if is_win32: # there is no os.statvfs
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
FILE_READ_ONLY_VOLUME = 0x00080000
|
||||
flags = wintypes.DWORD()
|
||||
root = mountpoint.replace("/", "\\") + "\\"
|
||||
assert ctypes.windll.kernel32.GetVolumeInformationW(root, None, 0, None, None, ctypes.byref(flags), None, 0)
|
||||
return bool(flags.value & FILE_READ_ONLY_VOLUME)
|
||||
return bool(os.statvfs(mountpoint).f_flag & os.ST_RDONLY)
|
||||
|
||||
|
||||
@requires_hardlinks
|
||||
@pytest.mark.skipif(not has_any_fuse, reason="FUSE not available")
|
||||
def test_fuse_mount_hardlinks(archivers, request):
|
||||
@@ -46,25 +77,25 @@ def test_fuse_mount_hardlinks(archivers, request):
|
||||
fuse_mount(archiver, mountpoint, "-a", "test", "--strip-components=2", *ignore_perms),
|
||||
changedir(os.path.join(mountpoint, "test")),
|
||||
):
|
||||
assert os.stat("hardlink").st_nlink == 2
|
||||
assert os.stat("subdir/hardlink").st_nlink == 2
|
||||
assert_nlink("hardlink", 2)
|
||||
assert_nlink("subdir/hardlink", 2)
|
||||
assert open("subdir/hardlink", "rb").read() == b"123456"
|
||||
assert os.stat("aaaa").st_nlink == 2
|
||||
assert os.stat("source2").st_nlink == 2
|
||||
assert_nlink("aaaa", 2)
|
||||
assert_nlink("source2", 2)
|
||||
with (
|
||||
fuse_mount(archiver, mountpoint, "input/dir1", "-a", "test", *ignore_perms),
|
||||
changedir(os.path.join(mountpoint, "test")),
|
||||
):
|
||||
assert os.stat("input/dir1/hardlink").st_nlink == 2
|
||||
assert os.stat("input/dir1/subdir/hardlink").st_nlink == 2
|
||||
assert_nlink("input/dir1/hardlink", 2)
|
||||
assert_nlink("input/dir1/subdir/hardlink", 2)
|
||||
assert open("input/dir1/subdir/hardlink", "rb").read() == b"123456"
|
||||
assert os.stat("input/dir1/aaaa").st_nlink == 2
|
||||
assert os.stat("input/dir1/source2").st_nlink == 2
|
||||
assert_nlink("input/dir1/aaaa", 2)
|
||||
assert_nlink("input/dir1/source2", 2)
|
||||
with fuse_mount(archiver, mountpoint, "-a", "test", *ignore_perms), changedir(os.path.join(mountpoint, "test")):
|
||||
assert os.stat("input/source").st_nlink == 4
|
||||
assert os.stat("input/abba").st_nlink == 4
|
||||
assert os.stat("input/dir1/hardlink").st_nlink == 4
|
||||
assert os.stat("input/dir1/subdir/hardlink").st_nlink == 4
|
||||
assert_nlink("input/source", 4)
|
||||
assert_nlink("input/abba", 4)
|
||||
assert_nlink("input/dir1/hardlink", 4)
|
||||
assert_nlink("input/dir1/subdir/hardlink", 4)
|
||||
assert open("input/dir1/subdir/hardlink", "rb").read() == b"123456"
|
||||
|
||||
|
||||
@@ -87,6 +118,8 @@ def test_fuse(archivers, request):
|
||||
|
||||
cmd(archiver, "repo-create", RK_ENCRYPTION)
|
||||
create_test_files(archiver.input_path)
|
||||
if are_symlinks_supported() and not fuse_symlinks_supported():
|
||||
os.remove("input/link1") # the mount would not be equal to the input
|
||||
have_noatime = has_noatime("input/file1")
|
||||
cmd(archiver, "create", "--atime", "archive", "input")
|
||||
cmd(archiver, "create", "--atime", "archive2", "input")
|
||||
@@ -124,7 +157,8 @@ def test_fuse(archivers, request):
|
||||
assert same_ts_ns(sti1.st_mtime * 1e9, sto1.st_mtime * 1e9)
|
||||
if are_hardlinks_supported():
|
||||
# note: there is another hard link to this, see below
|
||||
assert sti1.st_nlink == sto1.st_nlink == 2
|
||||
assert sti1.st_nlink == 2
|
||||
assert_nlink(out_fn, 2)
|
||||
# read
|
||||
with open(in_fn, "rb") as in_f, open(out_fn, "rb") as out_f:
|
||||
assert in_f.read() == out_f.read()
|
||||
@@ -134,15 +168,17 @@ def test_fuse(archivers, request):
|
||||
out_fn = os.path.join(mountpoint, "archive", "input", "hardlink")
|
||||
sti2 = os.stat(in_fn)
|
||||
sto2 = os.stat(out_fn)
|
||||
assert sti2.st_nlink == sto2.st_nlink == 2
|
||||
assert sti2.st_nlink == 2
|
||||
assert_nlink(out_fn, 2)
|
||||
assert sto1.st_ino == sto2.st_ino
|
||||
# symlink
|
||||
if are_symlinks_supported():
|
||||
if are_symlinks_supported() and fuse_symlinks_supported():
|
||||
in_fn = "input/link1"
|
||||
out_fn = os.path.join(mountpoint, "archive", "input", "link1")
|
||||
sti = os.stat(in_fn, follow_symlinks=False)
|
||||
sto = os.stat(out_fn, follow_symlinks=False)
|
||||
assert sti.st_size == len("somewhere")
|
||||
if not is_win32: # NTFS: st_size of a symlink is 0.
|
||||
assert sti.st_size == len("somewhere")
|
||||
assert sto.st_size == len("somewhere")
|
||||
assert stat.S_ISLNK(sti.st_mode)
|
||||
assert stat.S_ISLNK(sto.st_mode)
|
||||
@@ -329,7 +365,8 @@ def test_fuse_allow_damaged_files(archivers, request, damage):
|
||||
with open(os.path.join(mountpoint, "archive", path), "rb") as f:
|
||||
with pytest.raises(OSError) as excinfo:
|
||||
f.read()
|
||||
assert excinfo.value.errno == errno.EIO
|
||||
# Windows: the C runtime does not map the I/O error that Windows makes of our EIO back to EIO.
|
||||
assert excinfo.value.errno == (errno.EINVAL if is_win32 else errno.EIO)
|
||||
|
||||
with fuse_mount(archiver, mountpoint, "-a", "archive", "-o", "allow_damaged_files"):
|
||||
with open(os.path.join(mountpoint, "archive", path), "rb") as f:
|
||||
@@ -353,7 +390,7 @@ def test_fuse_read_to_eof_after_attr_timeout(archivers, request):
|
||||
cmd(archiver, "create", "archive", "input")
|
||||
mountpoint = os.path.join(archiver.tmpdir, "mountpoint")
|
||||
with fuse_mount(archiver, mountpoint, "-a", "archive"):
|
||||
assert os.statvfs(mountpoint).f_flag & os.ST_RDONLY, "libfuse options (ro) not applied"
|
||||
assert is_readonly_mount(mountpoint), "libfuse options (ro) not applied"
|
||||
with open(os.path.join(mountpoint, "archive", "input", "file"), "rb", buffering=0) as f:
|
||||
assert len(f.read(4096)) == 4096
|
||||
time.sleep(1.5) # longer than the default attr_timeout of 1 s
|
||||
@@ -362,6 +399,30 @@ def test_fuse_read_to_eof_after_attr_timeout(archivers, request):
|
||||
assert len(os.read(f.fileno(), 64 * 1024)) == 64 * 1024 - 4096
|
||||
|
||||
|
||||
@pytest.mark.skipif(not is_win32, reason="Windows-only test")
|
||||
@pytest.mark.skipif(not has_any_fuse, reason="FUSE not available")
|
||||
def test_mount_umount_errors_win32(archivers, request):
|
||||
"""WinFsp wants an unused drive or a not existing directory (in an existing one) - and there is no borg umount."""
|
||||
archiver = request.getfixturevalue(archivers)
|
||||
existing_dir = os.path.join(archiver.tmpdir, "existing")
|
||||
os.mkdir(existing_dir)
|
||||
system_drive = os.path.splitdrive(existing_dir)[0] # like C: - surely in use
|
||||
for args, expected_msg in (
|
||||
(("mount", existing_dir), "not existing"),
|
||||
(("mount", os.path.join(archiver.tmpdir, "missing", "mountpoint")), "existing directory"),
|
||||
(("mount", system_drive), "unused"),
|
||||
(("mount", system_drive + "\\"), "unused"),
|
||||
(("umount", existing_dir), "not supported"),
|
||||
):
|
||||
if archiver.FORK_DEFAULT:
|
||||
output = cmd(archiver, *args, exit_code=EXIT_ERROR)
|
||||
assert expected_msg in output
|
||||
else:
|
||||
with pytest.raises(RTError) as excinfo:
|
||||
cmd(archiver, *args)
|
||||
assert expected_msg in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not has_any_fuse, reason="FUSE not available")
|
||||
def test_fuse_mount_options(archivers, request):
|
||||
archiver = request.getfixturevalue(archivers)
|
||||
@@ -386,6 +447,7 @@ def test_fuse_mount_options(archivers, request):
|
||||
|
||||
|
||||
@pytest.mark.skipif(not has_any_fuse, reason="FUSE not available")
|
||||
@pytest.mark.skipif(is_win32, reason="borg mount does not daemonize on Windows")
|
||||
def test_migrate_lock_alive(archivers, request):
|
||||
"""Both old_id and new_id must not be stale during lock migration / daemonization."""
|
||||
archiver = request.getfixturevalue(archivers)
|
||||
|
||||
@@ -132,6 +132,30 @@ def test_mfusepy_getxattr_broken_acl():
|
||||
assert excinfo.value.errno == errno.EIO
|
||||
|
||||
|
||||
@skipif_no_mfusepy
|
||||
def test_mfusepy_readlink():
|
||||
from ..fuse_impl import hlfuse
|
||||
from ..hlfuse import borgfs
|
||||
|
||||
class VFS:
|
||||
def __init__(self, target):
|
||||
self.target = target
|
||||
|
||||
def readlink(self, ino):
|
||||
if self.target is None:
|
||||
raise ValueError("not a symlink")
|
||||
return self.target
|
||||
|
||||
ops, _, _ = mfusepy_ops()
|
||||
ops.vfs = VFS("somewhere")
|
||||
assert borgfs.readlink(ops, "/link") == "somewhere"
|
||||
# not a symlink: EINVAL, like readlink(2). WinFsp tries this for "/" when mounting.
|
||||
ops.vfs = VFS(None)
|
||||
with pytest.raises(hlfuse.FuseOSError) as excinfo:
|
||||
borgfs.readlink(ops, "/")
|
||||
assert excinfo.value.errno == errno.EINVAL
|
||||
|
||||
|
||||
def test_fuse_import_errors_recorded():
|
||||
"""A FUSE impl failing to import must not crash borg and must record why, see #8657."""
|
||||
import borg.fuse_impl
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for the archive VFS core (vfs.py) that do not need a repository."""
|
||||
|
||||
import ntpath
|
||||
import unicodedata
|
||||
|
||||
import pytest
|
||||
@@ -7,7 +8,8 @@ import pytest
|
||||
from ..helpers import StableDict
|
||||
from ..item import Item
|
||||
from ..platform import acl_text_to_xattr
|
||||
from ..vfs import VFSNode, item_getxattr, item_listxattr, lookup_child, versioned_name
|
||||
from ..platformflags import is_win32
|
||||
from ..vfs import VFSNode, item_getxattr, item_listxattr, lookup_child, parse_mount_options, versioned_name
|
||||
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"
|
||||
@@ -112,3 +114,48 @@ def test_getxattr_acl_broken():
|
||||
def test_getxattr_missing():
|
||||
with pytest.raises(KeyError):
|
||||
item_getxattr(make_item(), b"user.nope")
|
||||
|
||||
|
||||
class MountArgs:
|
||||
"""Minimal stand-in for the parsed borg mount arguments."""
|
||||
|
||||
|
||||
@pytest.mark.skipif(is_win32, reason="needs os.getuid / os.getgid")
|
||||
def test_parse_mount_options_posix(monkeypatch):
|
||||
monkeypatch.setattr("borg.vfs.is_win32", False)
|
||||
monkeypatch.setattr("borg.vfs.is_darwin", 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.parametrize(
|
||||
"mount_options, expected",
|
||||
[
|
||||
(None, ["uid=-1", "gid=-1"]), # default: everything belongs to the user who mounts
|
||||
# what the user gives comes after the defaults, WinFsp uses the later ones:
|
||||
("uid=1234", ["uid=-1", "gid=-1", "uid=1234"]),
|
||||
("UserName=foo,GroupName=bar", ["uid=-1", "gid=-1", "UserName=foo", "GroupName=bar"]),
|
||||
],
|
||||
)
|
||||
def test_parse_mount_options_win32_uid_gid(monkeypatch, mount_options, expected):
|
||||
monkeypatch.setattr("borg.vfs.is_win32", True)
|
||||
monkeypatch.setattr("borg.vfs.is_darwin", False)
|
||||
options, vfs_options = parse_mount_options(MountArgs(), "X:", mount_options)
|
||||
# uid and gid are left to WinFsp, borg does not force them.
|
||||
assert [option for option in options if option.startswith(("uid=", "gid=", "UserName=", "GroupName="))] == expected
|
||||
assert (vfs_options.uid_forced, vfs_options.gid_forced) == (None, None)
|
||||
assert (vfs_options.dir_item.uid, vfs_options.dir_item.gid) == (0, 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mountpoint, mount_options, volname",
|
||||
[("X:", None, "borgfs"), ("C:/mnt/point", None, "point (borgfs)"), ("X:", "volname=backup", "backup")],
|
||||
)
|
||||
def test_parse_mount_options_win32_volname(monkeypatch, mountpoint, mount_options, volname):
|
||||
monkeypatch.setattr("borg.vfs.is_win32", True)
|
||||
monkeypatch.setattr("borg.vfs.is_darwin", False)
|
||||
monkeypatch.setattr("borg.vfs.os.path.basename", ntpath.basename)
|
||||
options, _ = parse_mount_options(MountArgs(), mountpoint, mount_options)
|
||||
assert [option for option in options if option.startswith("volname=")] == [f"volname={volname}"]
|
||||
|
||||
+34
-9
@@ -491,8 +491,15 @@ class ArchiveVFS:
|
||||
return item_getxattr(self.get_item(ino), name, numeric_ids=self.options.numeric_ids)
|
||||
|
||||
def readlink(self, ino):
|
||||
"""Return the target of the symlink with inode number *ino*."""
|
||||
return self.get_item(ino).target
|
||||
"""Return the target of the symlink with inode number *ino*.
|
||||
|
||||
Raises ValueError if the node is not a symlink.
|
||||
"""
|
||||
item = self.get_item(ino)
|
||||
try:
|
||||
return item.target
|
||||
except AttributeError: # only symlinks have a target
|
||||
raise ValueError("not a symlink") from None
|
||||
|
||||
def log_stats(self):
|
||||
"""Log some statistics about this file system (used by the SIGUSR1/SIGINFO handler)."""
|
||||
@@ -691,14 +698,24 @@ 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_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,
|
||||
# thus we leave the uid and gid options to WinFsp (which then overrides the owner / group of
|
||||
# all files) and default them to -1, meaning the user who mounts. If the user gives these
|
||||
# options (or UserName / GroupName), they come later in the list and WinFsp uses the later ones.
|
||||
options += ["uid=-1", "gid=-1"]
|
||||
if mount_options:
|
||||
options.extend(mount_options.split(","))
|
||||
if is_darwin:
|
||||
# macFUSE supports a volname mount option to give what finder displays on desktop / in directory list.
|
||||
if is_darwin or is_win32:
|
||||
# macFUSE and WinFsp support a volname mount option: the volume name that the Finder (on the
|
||||
# desktop / in the directory list) respectively the Explorer (as the label of a drive) displays.
|
||||
volname = pop_option(options, "volname", "", "", str)
|
||||
# if the user did not specify it, we make something up,
|
||||
# because otherwise it would be "macFUSE Volume 0 (Python)", #7690.
|
||||
volname = volname or f"{os.path.basename(mountpoint)} (borgfs)"
|
||||
# note: a Windows drive mountpoint like "X:" has no basename.
|
||||
basename = os.path.basename(mountpoint)
|
||||
volname = volname or (f"{basename} (borgfs)" if basename else "borgfs")
|
||||
options.append(f"volname={volname}")
|
||||
ignore_permissions = pop_option(options, "ignore_permissions", True, False, bool)
|
||||
if ignore_permissions:
|
||||
@@ -706,18 +723,26 @@ def parse_mount_options(args, mountpoint, mount_options):
|
||||
# this is enabled by the custom "ignore_permissions" mount option which just
|
||||
# removes "default_permissions" again:
|
||||
pop_option(options, "default_permissions", True, False, bool)
|
||||
if is_win32:
|
||||
# uid / gid are left to WinFsp (see above). there is no os.getuid / os.getgid, see also platform.windows_ug.
|
||||
uid_forced = gid_forced = None
|
||||
default_dir_uid = default_dir_gid = 0
|
||||
else:
|
||||
uid_forced = pop_option(options, "uid", None, None, int)
|
||||
gid_forced = pop_option(options, "gid", None, None, int)
|
||||
default_dir_uid, default_dir_gid = os.getuid(), os.getgid()
|
||||
vfs_options = VFSOptions(
|
||||
allow_damaged_files=pop_option(options, "allow_damaged_files", True, False, bool),
|
||||
versions=pop_option(options, "versions", True, False, bool),
|
||||
uid_forced=pop_option(options, "uid", None, None, int),
|
||||
gid_forced=pop_option(options, "gid", None, None, int),
|
||||
uid_forced=uid_forced,
|
||||
gid_forced=gid_forced,
|
||||
umask=pop_option(options, "umask", 0, 0, int, int_base=8), # umask is octal, e.g. 222 or 0222
|
||||
numeric_ids=getattr(args, "numeric_ids", False),
|
||||
strip_components=getattr(args, "strip_components", 0),
|
||||
item_filter=build_item_filter(args),
|
||||
)
|
||||
dir_uid = vfs_options.uid_forced if vfs_options.uid_forced is not None else os.getuid()
|
||||
dir_gid = vfs_options.gid_forced if vfs_options.gid_forced is not None else os.getgid()
|
||||
dir_uid = vfs_options.uid_forced if vfs_options.uid_forced is not None else default_dir_uid
|
||||
dir_gid = vfs_options.gid_forced if vfs_options.gid_forced is not None else default_dir_gid
|
||||
dir_user = uid2user(dir_uid)
|
||||
dir_group = gid2group(dir_gid)
|
||||
if not isinstance(dir_user, str):
|
||||
|
||||
Reference in New Issue
Block a user