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:
Thomas Waldmann
2026-09-19 14:17:09 +02:00
co-authored by Claude Fable 5.1
parent a6904402ef
commit 0e316b1b52
8 changed files with 293 additions and 38 deletions
+5 -1
View File
@@ -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
+29 -4
View File
@@ -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
View File
@@ -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
+52 -2
View File
@@ -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:
+81 -19
View File
@@ -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)
+24
View File
@@ -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
+48 -1
View File
@@ -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
View File
@@ -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):