Merge pull request #10505 from ThomasWaldmann/mount-opt-strip-numeric

mount: add strip_components, numeric_ids and archive_dir_format mount options, fixes #4880
This commit is contained in:
TW
2026-10-10 22:46:03 +02:00
committed by GitHub
6 changed files with 92 additions and 15 deletions
+3
View File
@@ -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
+2 -1
View File
@@ -991,7 +991,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
+14 -6
View File
@@ -93,10 +93,10 @@ class MountMixIn:
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::
@@ -142,8 +142,8 @@ class MountMixIn:
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
@@ -167,6 +167,14 @@ class MountMixIn:
- ``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
@@ -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")
+45
View File
@@ -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",
[
+25 -8
View File
@@ -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__(
@@ -80,6 +82,7 @@ class VFSOptions:
allow_damaged_files=False,
strip_components=0,
item_filter=None,
archive_dir_format=None,
dir_item=None,
):
self.versions = versions
@@ -90,6 +93,7 @@ class VFSOptions:
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
@@ -263,12 +267,15 @@ class ArchiveVFS:
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]
@@ -645,7 +652,7 @@ 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.
@@ -653,7 +660,7 @@ def build_item_filter(args):
# 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):
@@ -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