From 9c08f32792056d0576ea9ccb705c60eae0eda9bc Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 7 Oct 2026 11:46:33 +0200 Subject: [PATCH] mount: add passcommand mount option, fixes #6804 fstab and autofs entries can only give mount options, not environment variables, so an encrypted repository could not be mounted from there without setting BORG_PASSCOMMAND for the whole automount daemon. -o passcommand=CMD works like BORG_PASSCOMMAND=CMD. It is applied before the repository is opened (the key is loaded before the other mount options are processed) and is not passed on to libfuse. Like the passphrase environment variables, it is mutually exclusive with them. There is deliberately no mount option for the passphrase itself: mount options are visible to other users (/etc/fstab, ps output, systemd mount units, autofs logs). Co-Authored-By: Claude Opus 5.5 --- src/borg/archiver/help_cmd.py | 3 +- src/borg/archiver/mount_cmds.py | 34 +++++++++ .../testsuite/archiver/mount_cmds_test.py | 73 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver/help_cmd.py b/src/borg/archiver/help_cmd.py index 96cca3a20e..9a581f1480 100644 --- a/src/borg/archiver/help_cmd.py +++ b/src/borg/archiver/help_cmd.py @@ -630,7 +630,8 @@ class HelpMixIn: passphrase should be initially set when initializing an encrypted repo. Note that the command is executed without a shell. So variables, like ``$HOME`` will work, but ``~`` won't. Mutually exclusive with BORG_PASSPHRASE and BORG_PASSPHRASE_FD, see there. - See also BORG_NEW_PASSPHRASE. + See also BORG_NEW_PASSPHRASE. For fstab / autofs entries, ``borg mount`` has a + ``passcommand`` mount option that works like BORG_PASSCOMMAND. BORG_PASSPHRASE_FD (and BORG_NEW_PASSPHRASE_FD, BORG_OTHER_PASSPHRASE_FD) When set, specifies a file descriptor to read a passphrase from. Programs starting borg may choose to open an anonymous pipe diff --git a/src/borg/archiver/mount_cmds.py b/src/borg/archiver/mount_cmds.py index 626ab12d65..4d26aca429 100644 --- a/src/borg/archiver/mount_cmds.py +++ b/src/borg/archiver/mount_cmds.py @@ -14,6 +14,30 @@ logger = create_logger() +def use_passcommand_mount_option(args): + """Move the passcommand mount option from args.options to BORG_PASSCOMMAND. + + fstab / autofs entries can only give mount options, not environment variables. The key + (and thus the passphrase) is loaded before the mount options are processed, so this has + to happen before the repository is opened. + """ + from ..vfs import pop_option + + if not args.options: + return + options = args.options.split(",") + passcommand = pop_option(options, "passcommand", "", None, str) + if passcommand is None: + return + if not passcommand: + raise RTError("passcommand mount option: no command given") + set_vars = [var for var in ("BORG_PASSPHRASE", "BORG_PASSCOMMAND", "BORG_PASSPHRASE_FD") if var in os.environ] + if set_vars: + raise RTError(f"The passcommand mount option and {', '.join(set_vars)} are mutually exclusive.") + args.options = ",".join(options) or None + os.environ["BORG_PASSCOMMAND"] = passcommand + + class MountMixIn: def do_mount(self, args): """Mounts an archive or an entire repository as a FUSE filesystem.""" @@ -44,6 +68,7 @@ def do_mount(self, args): 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") + use_passcommand_mount_option(args) self._do_mount(args) @with_repository() @@ -138,6 +163,15 @@ def build_parser_mount_umount(self, subparsers, common_parser, mid_common_parser To allow a regular user to use fstab entries, add the ``user`` option: ``/path/to/repo /mnt/point fuse.borgfs defaults,noauto,user 0 0`` + fstab / autofs entries can not set environment variables, so for an encrypted + repository, use the ``passcommand`` mount option: it works like + ``BORG_PASSCOMMAND``, e.g. ``passcommand=/usr/local/sbin/borg-pass-backup1``. + As mount options are separated by commas, the command can not contain a comma, + so better use a script without arguments. ``passcommand`` is mutually exclusive + with ``BORG_PASSPHRASE``, ``BORG_PASSCOMMAND`` and ``BORG_PASSPHRASE_FD``. + There is no mount option for the passphrase itself, because mount options are + visible to other users (e.g. in /etc/fstab and in the ``ps`` output). + For FUSE configuration and mount options, see the mount.fuse(8) manual page. Borg's default behavior is to use the archived user and group names of each diff --git a/src/borg/testsuite/archiver/mount_cmds_test.py b/src/borg/testsuite/archiver/mount_cmds_test.py index 16a4f0c511..3f43fae918 100644 --- a/src/borg/testsuite/archiver/mount_cmds_test.py +++ b/src/borg/testsuite/archiver/mount_cmds_test.py @@ -6,6 +6,7 @@ import errno import os +import shlex import stat import sys import time @@ -27,6 +28,7 @@ from . import RK_ENCRYPTION, cmd, assert_dirs_equal, create_regular_file, create_src_archive, open_archive, src_file 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 pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,binary") # NOQA @@ -679,3 +681,74 @@ def test_borg_mount_has_no_repository_positional(): assert args.mountpoint == "/mnt/point" assert args.paths == ["some/path"] assert not args.location.valid + + +def print_passphrase_command(passphrase): + """Return a passcommand printing *passphrase* (shlex syntax, run without a shell, no commas).""" + python = sys.executable.replace("\\", "/") # see set_empty_passphrase + return f"{shlex.quote(python)} -c \"print('{passphrase}')\"" + + +@pytest.mark.skipif(not has_any_fuse, reason="FUSE not available") +def test_fuse_passcommand_mount_option(archivers, request, monkeypatch): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + create_src_archive(archiver, "archive") + passphrase = os.environ["BORG_PASSPHRASE"] + monkeypatch.delenv("BORG_PASSPHRASE") + mountpoint = os.path.join(archiver.tmpdir, "mountpoint") + with fuse_mount(archiver, mountpoint, "-o", f"passcommand={print_passphrase_command(passphrase)}"): + assert os.listdir(mountpoint) == ["archive"] + + +class PasscommandArgs: + """Minimal stand-in for the parsed borg mount arguments.""" + + def __init__(self, options): + self.options = options + + +@pytest.fixture +def no_passphrase_env(monkeypatch): + for var in "BORG_PASSPHRASE", "BORG_PASSCOMMAND", "BORG_PASSPHRASE_FD": + monkeypatch.delenv(var, raising=False) + + +@pytest.mark.parametrize( + "options, remaining", + [ + ("passcommand=/usr/local/sbin/pass backup1", None), + ("allow_other,passcommand=/usr/local/sbin/pass backup1,versions", "allow_other,versions"), + ], +) +def test_passcommand_mount_option(no_passphrase_env, options, remaining): + args = PasscommandArgs(options) + use_passcommand_mount_option(args) + # it is not a FUSE mount option, so it must not stay in the mount options: + assert args.options == remaining + assert os.environ["BORG_PASSCOMMAND"] == "/usr/local/sbin/pass backup1" + + +@pytest.mark.parametrize("options", [None, "", "allow_other,versions"]) +def test_passcommand_mount_option_not_given(no_passphrase_env, options): + args = PasscommandArgs(options) + use_passcommand_mount_option(args) + assert args.options == options + assert "BORG_PASSCOMMAND" not in os.environ + + +@pytest.mark.parametrize("options", ["passcommand", "passcommand="]) +def test_passcommand_mount_option_empty(no_passphrase_env, options): + with pytest.raises(RTError, match="no command given"): + use_passcommand_mount_option(PasscommandArgs(options)) + assert "BORG_PASSCOMMAND" not in os.environ + + +@pytest.mark.parametrize("var", ["BORG_PASSPHRASE", "BORG_PASSCOMMAND", "BORG_PASSPHRASE_FD"]) +def test_passcommand_mount_option_exclusive(no_passphrase_env, monkeypatch, var): + # like the passphrase environment variables, the passcommand mount option is mutually exclusive with them. + monkeypatch.setenv(var, "0") + args = PasscommandArgs("allow_other,passcommand=/usr/local/sbin/pass") + with pytest.raises(RTError, match=f"The passcommand mount option and {var} are mutually exclusive"): + use_passcommand_mount_option(args) + assert os.environ[var] == "0"