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"