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: 3 additions & 0 deletions docs/usage/mount.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ Examples
root-2016-02-14 root-2016-02-15
$ borg umount /tmp/mymountpoint

# The archive_dir_format mount option does the same, e.g. for fstab / autofs entries:
$ borg mount -o 'archive_dir_format={name}-{time:%Y-%m-%d}' /tmp/mymountpoint

# The "versions view" merges all archives in the repository
# and provides a versioned view on files.
$ borg mount -o versions /tmp/mymountpoint
Expand Down
3 changes: 2 additions & 1 deletion src/borg/archiver/help_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -987,7 +987,8 @@ class HelpMixIn:
Giving the format of the archive directory names when ``borg mount`` or
``borg webdav`` show a whole repository, default: ``{name}``. The placeholders
are the ones of ``borg repo-list --format``; names that are not unique get
``-{id:.8}`` appended. See ``borg mount --help``.
``-{id:.8}`` appended. ``borg mount`` also has an ``archive_dir_format`` mount
option for this, which overrides this variable. See ``borg mount --help``.
BORG_JSON_INDENT
Indentation of the ``--json`` output (default: ``4``).
A number gives that many spaces per nesting level (``0`` still puts every item on
Expand Down
20 changes: 14 additions & 6 deletions src/borg/archiver/mount_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ def build_parser_mount_umount(self, subparsers, common_parser, mid_common_parser
By default, these top directories are named like the archives; as the archives
of a series all have the same name, ``-{id:.8}`` (the first 8 hex digits of the
archive id) is appended whenever a name is not unique. To name them differently,
set the ``BORG_MOUNT_ARCHIVE_DIR_FORMAT`` environment variable to a format string
using the placeholders of ``borg repo-list --format``, e.g.
``{name}-{time:%Y-%m-%dT%H:%M:%S}`` or ``{hostname}-{name}``; names that are
still not unique get ``-{id:.8}`` appended.
set the ``BORG_MOUNT_ARCHIVE_DIR_FORMAT`` environment variable (or the
``archive_dir_format`` mount option) to a format string using the placeholders
of ``borg repo-list --format``, e.g. ``{name}-{time:%Y-%m-%dT%H:%M:%S}`` or
``{hostname}-{name}``; names that are still not unique get ``-{id:.8}`` appended.

.. note::

Expand Down Expand Up @@ -142,8 +142,8 @@ def build_parser_mount_umount(self, subparsers, common_parser, mid_common_parser

Borg's default behavior is to use the archived user and group names of each
file and map them to the system's respective user and group IDs.
Alternatively, using ``numeric-ids`` will instead use the archived user and
group IDs without any mapping.
Alternatively, using ``--numeric-ids`` (or the ``numeric_ids`` mount option)
will instead use the archived user and group IDs without any mapping.

The ``uid`` and ``gid`` mount options (implemented by Borg) can be used to
override the user and group IDs of all files (i.e., ``borg mount -o
Expand All @@ -167,6 +167,14 @@ def build_parser_mount_umount(self, subparsers, common_parser, mid_common_parser
- ``ignore_permissions``: for security reasons the ``default_permissions`` mount
option is internally enforced by Borg. ``ignore_permissions`` can be given to
not enforce ``default_permissions``.
- ``strip_components=NUMBER``: same as ``--strip-components NUMBER``; useful for
fstab / autofs entries, which can only give mount options. If both are given,
the mount option is used.
- ``numeric_ids``: same as ``--numeric-ids``; useful for fstab / autofs entries.
``numeric_ids=no`` overrides ``--numeric-ids``.
- ``archive_dir_format=FORMAT``: same as ``BORG_MOUNT_ARCHIVE_DIR_FORMAT=FORMAT``;
useful for fstab / autofs entries. If both are given, the mount option is used.
As mount options are separated by commas, FORMAT can not contain a comma.

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
Expand Down
3 changes: 3 additions & 0 deletions src/borg/testsuite/archiver/mount_cmds_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,9 @@ def test_fuse_archive_dir_format(archivers, request, monkeypatch):
monkeypatch.setenv("BORG_MOUNT_ARCHIVE_DIR_FORMAT", "{hostname}")
with fuse_mount(archiver, mountpoint):
assert set(os.listdir(mountpoint)) == {f"{hostname}-{id[:8]}" for name, hostname, id in archives}
# the archive_dir_format mount option does the same, it wins over BORG_MOUNT_ARCHIVE_DIR_FORMAT:
with fuse_mount(archiver, mountpoint, "-o", "archive_dir_format={name}-{id}"):
assert set(os.listdir(mountpoint)) == {f"{name}-{id}" for name, hostname, id in archives}


@pytest.mark.skipif(not has_any_fuse, reason="FUSE not available")
Expand Down
45 changes: 45 additions & 0 deletions src/borg/testsuite/vfs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,51 @@ def test_parse_mount_options_posix(monkeypatch):
assert (vfs_options.uid_forced, vfs_options.gid_forced) == (0, 0)


@pytest.mark.parametrize(
"cli_strip_components, mount_options, expected",
[(0, None, 0), (2, None, 2), (0, "strip_components=3", 3), (2, "strip_components=3", 3)],
)
def test_parse_mount_options_strip_components(cli_strip_components, mount_options, expected):
args = MountArgs()
args.strip_components = cli_strip_components
options, vfs_options = parse_mount_options(args, "/mnt/point", mount_options)
# strip_components is implemented by borg, so it is not passed on to libfuse.
assert not [option for option in options if option.startswith("strip_components")]
assert vfs_options.strip_components == expected
# the item filter also uses it: it skips paths with no more than strip_components elements.
assert vfs_options.item_filter(Item(path="a/b/c")) == (expected < 3)


@pytest.mark.parametrize(
"cli_numeric_ids, mount_options, expected",
[
(False, None, False),
(True, None, True),
(False, "numeric_ids", True),
(False, "numeric_ids=yes", True),
(True, "numeric_ids=no", False),
],
)
def test_parse_mount_options_numeric_ids(cli_numeric_ids, mount_options, expected):
args = MountArgs()
args.numeric_ids = cli_numeric_ids
options, vfs_options = parse_mount_options(args, "/mnt/point", mount_options)
# numeric_ids is implemented by borg, so it is not passed on to libfuse.
assert not [option for option in options if option.startswith("numeric_ids")]
assert vfs_options.numeric_ids is expected


@pytest.mark.parametrize(
"mount_options, expected",
[(None, None), ("allow_other", None), ("archive_dir_format={name}-{time:%Y-%m-%d}", "{name}-{time:%Y-%m-%d}")],
)
def test_parse_mount_options_archive_dir_format(mount_options, expected):
options, vfs_options = parse_mount_options(MountArgs(), "/mnt/point", mount_options)
# archive_dir_format is implemented by borg, so it is not passed on to libfuse.
assert not [option for option in options if option.startswith("archive_dir_format")]
assert vfs_options.archive_dir_format == expected


@pytest.mark.parametrize(
"mount_options, expected",
[
Expand Down
33 changes: 25 additions & 8 deletions src/borg/vfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ class VFSOptions:
*numeric_ids*, *uid_forced*, *gid_forced* and *umask* control the ownership and
permissions mapping, *strip_components* and *item_filter* which items are shown,
*allow_damaged_files* whether reads of files with missing or corrupted chunks return
zeros instead of failing, and *dir_item* is the item used for synthesized directories.
zeros instead of failing, *archive_dir_format* how the archive directories are named
(None: as given by BORG_MOUNT_ARCHIVE_DIR_FORMAT) and *dir_item* is the item used for
synthesized directories.
"""

def __init__(
Expand All @@ -80,6 +82,7 @@ def __init__(
allow_damaged_files=False,
strip_components=0,
item_filter=None,
archive_dir_format=None,
dir_item=None,
):
self.versions = versions
Expand All @@ -90,6 +93,7 @@ def __init__(
self.allow_damaged_files = allow_damaged_files
self.strip_components = strip_components
self.item_filter = item_filter
self.archive_dir_format = archive_dir_format
self.dir_item = dir_item


Expand Down Expand Up @@ -263,12 +267,15 @@ def create_filesystem(self):

def _archive_dir_names(self, archives):
"""The root directory names of *archives*, see BORG_MOUNT_ARCHIVE_DIR_FORMAT (borg mount --help)."""
format = os.environ.get("BORG_MOUNT_ARCHIVE_DIR_FORMAT", "{name}")
if self.options.archive_dir_format is not None:
format, source = self.options.archive_dir_format, "archive_dir_format mount option"
else:
format, source = os.environ.get("BORG_MOUNT_ARCHIVE_DIR_FORMAT", "{name}"), "BORG_MOUNT_ARCHIVE_DIR_FORMAT"
try:
formatter = ArchiveFormatter(format, self.repository, self.manifest, self.manifest.key)
names = [formatter.format_item(archive) for archive in archives]
except (CommandError, ValueError) as err: # unknown placeholder, malformed format string / format spec
raise Error(f"BORG_MOUNT_ARCHIVE_DIR_FORMAT: {err}") from None
raise Error(f"{source}: {err}") from None
# "/" and NUL can not be part of a directory name
return [name.replace("/", "_").replace("\0", "_") for name in names]

Expand Down Expand Up @@ -645,15 +652,15 @@ def data_cache_capacity():
return max(1, capacity)


def build_item_filter(args):
def build_item_filter(args, strip_components):
"""Build the item filter selecting the paths/patterns given on the command line."""
# lazy import: pulling in the archiver package at module import time would be heavy
# (it defines all subcommands) and risks an import cycle.
from .archiver._common import build_matcher, build_filter

# omitting args.pattern_roots here, restricting to paths only by cli args.paths:
matcher = build_matcher(getattr(args, "patterns", None) or [], getattr(args, "paths", None) or [])
return build_filter(matcher, getattr(args, "strip_components", 0))
return build_filter(matcher, strip_components)


def pop_option(options, key, present, not_present, wanted_type, int_base=0):
Expand Down Expand Up @@ -731,15 +738,25 @@ def parse_mount_options(args, mountpoint, mount_options):
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()
# the strip_components, numeric_ids and archive_dir_format mount options are for fstab / autofs
# entries, which can only give mount options.
strip_components = pop_option(options, "strip_components", None, None, int)
if strip_components is None:
strip_components = getattr(args, "strip_components", 0)
numeric_ids = pop_option(options, "numeric_ids", True, None, bool)
if numeric_ids is None:
numeric_ids = getattr(args, "numeric_ids", False)
archive_dir_format = pop_option(options, "archive_dir_format", None, None, str)
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=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),
numeric_ids=numeric_ids,
strip_components=strip_components,
item_filter=build_item_filter(args, strip_components),
archive_dir_format=archive_dir_format,
)
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
Expand Down
Loading