Skip to content

mount: support Windows using WinFsp (via mfusepy), fixes #2316 - #10391

Open
ThomasWaldmann wants to merge 2 commits into
borgbackup:masterfrom
ThomasWaldmann:mount-winfsp-2316
Open

ThomasWaldmann wants to merge 2 commits into
borgbackup:masterfrom
ThomasWaldmann:mount-winfsp-2316

Conversation

@ThomasWaldmann

Copy link
Copy Markdown
Member

Fixes #2316.

borg mount now works on Windows, using WinFsp via mfusepy. mfusepy already knows how to talk to WinFsp's FUSE 2 API (it finds winfsp-x64.dll via the registry), so this is about the borg side.

What changes on Windows

  • Mountpoint: WinFsp mounts on an unused drive (X:, X:\ is accepted, too) or on a directory that does not exist yet (WinFsp creates it when mounting and removes it when unmounting). do_mount checks for that instead of "existing, writable directory".
  • No daemonizing (no fork): borg mount always stays in the foreground. Ctrl-C and Ctrl-Break unmount cleanly (rc 0, mountpoint gone, repository lock released).
  • borg umount is not supported there and says so: WinFsp has nothing like fusermount -u (checked fsptool / launchctl), so borg would need an own way to tell a running borg mount to stop. There is a TODO for that.
  • Read-only: WinFsp silently ignores the ro mount option, a read-only volume has to be requested from the init callback (conn_info.want |= FSP_FUSE_CAP_READ_ONLY, via mfusepy's init_with_config).
  • Owner / group: WinFsp makes a Windows security descriptor from uid / gid / mode and checks all access against it. Archived uids / gids do not refer to anybody on a Windows machine (one would be locked out of e.g. a 0600 file of uid 1000), so on Windows the uid / gid mount options are left to WinFsp (which then overrides owner / group of all files) and default to -1 = the user who mounts. UserName= / GroupName= also work.
  • Volume label: volname (default borgfs for a drive, <dirname> (borgfs) otherwise), like we already do for macFUSE.
  • Creation time: st_birthtime is given to mfusepy now (WinFsp uses it unconditionally as the creation time; harmless elsewhere, mfusepy ignores it if the platform's struct stat does not have it).
  • readlink of something that is not a symlink fails with EINVAL, like readlink(2). WinFsp tries that for / when mounting to find out whether symlinks are supported - that gave an "Uncaught exception" traceback in the log.

Known limitations (documented in borg mount --help)

  • WinFsp does not support hard link counts: st_nlink is always 1 (inode numbers of hard links are equal, though).
  • Symlinks need a fix in mfusepy: WinFsp's struct fuse_operations has getattr, getdir, readlink, while libfuse (and thus mfusepy 3.1.1) has getattr, readlink, getdir. So WinFsp does not find our readlink, decides "no symlink support" and shows symlinks as regular files. With the two fields swapped for Windows in mfusepy, file / directory / dangling symlinks all work (verified). The tests here cope with both mfusepy variants. I'll take that to mfusepy separately.

Tests

  • fuse_mount() has a Windows branch (winfsp_mount()): it runs borg mount --foreground as a child process in an own process group and stops it with CTRL_BREAK_EVENT, i.e. like a user would do it.
  • adapted expectations: hard link counts, EIO arrives as EINVAL (C runtime errno mapping), read-only check via GetVolumeInformationW (no os.statvfs), NTFS symlinks have st_size 0, test_migrate_lock_alive needs daemonizing.
  • new: parse_mount_options tests (they run on all platforms), readlink EINVAL test for the mfusepy adapter.
  • CI: the Windows job installs WinFsp (choco install winfsp) and the mfusepy extra, so the mount tests run there and the Windows binary bundles mfusepy. This CI part is untested until it runs here.

How this was tested

Windows 11 Pro, WinFsp 2.1, MSYS2 UCRT64 (Python 3.14), mfusepy 3.1.1:

  • manually: drive and directory mountpoints, file content (3 MB file, sha256), exact mtime / creation time, read-only volume (fsutil fsinfo volumeinfo), volume label, ownership, Ctrl-C / Ctrl-Break / kill behaviour, mountpoint validation errors.
  • mount_cmds_test.py, vfs_test.py, fuse_test.py: 42 passed with -n4, with python -m borg and with a PyInstaller-built borg.exe (one-dir and single-file both mount and unmount fine), with released and with fixed mfusepy.
  • Linux (Debian 13), same test files: 42 passed with libfuse 2.9 and with libfuse 3.17.

Not tested: using the mount from the Explorer of an interactive desktop session (everything was done via ssh).

🤖 Generated with Claude Code

ThomasWaldmann and others added 2 commits September 19, 2026 03:22


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]>
So the borg mount tests also run on Windows and the Windows binary
can mount (if WinFsp is installed).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 64.28571% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.19%. Comparing base (7a16a03) to head (9576aa6).
⚠️ Report is 1 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/borg/archiver/mount_cmds.py 17.64% 9 Missing and 5 partials ⚠️
src/borg/hlfuse.py 90.90% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10391      +/-   ##
==========================================
- Coverage   88.19%   88.19%   -0.01%     
==========================================
  Files         103      103              
  Lines       18822    18855      +33     
  Branches     2919     2930      +11     
==========================================
+ Hits        16600    16629      +29     
  Misses       1548     1548              
- Partials      674      678       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows fuse support

1 participant