Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/borg/archiver/help_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/borg/archiver/mount_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions src/borg/testsuite/archiver/mount_cmds_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import errno
import os
import shlex
import stat
import sys
import time
Expand All @@ -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

Expand Down Expand Up @@ -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"
Loading