From cd3ce399729a95dca548c2809c0d5147bc6905c6 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 6 Oct 2026 23:12:19 +0200 Subject: [PATCH 1/3] mount: add strip_components mount option, fixes #4880 fstab and autofs entries can only give mount options, not borg CLI options, so -o strip_components=N now does what --strip-components N does (the mount option wins if both are given). Co-Authored-By: Claude Opus 5.5 --- src/borg/archiver/mount_cmds.py | 3 +++ src/borg/testsuite/vfs_test.py | 15 +++++++++++++++ src/borg/vfs.py | 12 ++++++++---- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/borg/archiver/mount_cmds.py b/src/borg/archiver/mount_cmds.py index 626ab12d65..292ac5c776 100644 --- a/src/borg/archiver/mount_cmds.py +++ b/src/borg/archiver/mount_cmds.py @@ -167,6 +167,9 @@ 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. On Windows, ``borg mount`` needs `WinFsp `_ and mfusepy. MOUNTPOINT must either be an unused drive (like ``X:``) or a not yet existing diff --git a/src/borg/testsuite/vfs_test.py b/src/borg/testsuite/vfs_test.py index 055a213c5c..6f8cebeb23 100644 --- a/src/borg/testsuite/vfs_test.py +++ b/src/borg/testsuite/vfs_test.py @@ -130,6 +130,21 @@ 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( "mount_options, expected", [ diff --git a/src/borg/vfs.py b/src/borg/vfs.py index 48b51919fe..2dbff5953e 100644 --- a/src/borg/vfs.py +++ b/src/borg/vfs.py @@ -645,7 +645,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 +653,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,6 +731,10 @@ 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 mount option is 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) vfs_options = VFSOptions( allow_damaged_files=pop_option(options, "allow_damaged_files", True, False, bool), versions=pop_option(options, "versions", True, False, bool), @@ -738,8 +742,8 @@ def parse_mount_options(args, mountpoint, mount_options): 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), + strip_components=strip_components, + item_filter=build_item_filter(args, strip_components), ) 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 From 6a06e4ff859cb2ecd6f5b17cfc6e9aae98b39a28 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 6 Oct 2026 23:12:51 +0200 Subject: [PATCH 2/3] mount: add numeric_ids mount option Like strip_components, this makes --numeric-ids usable from fstab and autofs entries. Also clarify in the docs that numeric-ids was the --numeric-ids CLI option, not a mount option. Co-Authored-By: Claude Opus 5.5 --- src/borg/archiver/mount_cmds.py | 6 ++++-- src/borg/testsuite/vfs_test.py | 19 +++++++++++++++++++ src/borg/vfs.py | 8 ++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/borg/archiver/mount_cmds.py b/src/borg/archiver/mount_cmds.py index 292ac5c776..7a3e8c54cf 100644 --- a/src/borg/archiver/mount_cmds.py +++ b/src/borg/archiver/mount_cmds.py @@ -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 @@ -170,6 +170,8 @@ def build_parser_mount_umount(self, subparsers, common_parser, mid_common_parser - ``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``. On Windows, ``borg mount`` needs `WinFsp `_ and mfusepy. MOUNTPOINT must either be an unused drive (like ``X:``) or a not yet existing diff --git a/src/borg/testsuite/vfs_test.py b/src/borg/testsuite/vfs_test.py index 6f8cebeb23..8bf93ca139 100644 --- a/src/borg/testsuite/vfs_test.py +++ b/src/borg/testsuite/vfs_test.py @@ -145,6 +145,25 @@ def test_parse_mount_options_strip_components(cli_strip_components, mount_option 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", [ diff --git a/src/borg/vfs.py b/src/borg/vfs.py index 2dbff5953e..7e71334750 100644 --- a/src/borg/vfs.py +++ b/src/borg/vfs.py @@ -731,17 +731,21 @@ 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 mount option is for fstab / autofs entries, which can only give mount options. + # the strip_components and numeric_ids 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) 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), + numeric_ids=numeric_ids, strip_components=strip_components, item_filter=build_item_filter(args, strip_components), ) From c7f2f2d8ce4621c0667e9c2177012b0ab8ac921a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 7 Oct 2026 11:10:08 +0200 Subject: [PATCH 3/3] mount: add archive_dir_format mount option Same as BORG_MOUNT_ARCHIVE_DIR_FORMAT, but usable from fstab and autofs entries; if both are given, the mount option is used. As mount options are separated by commas, the format can not contain a comma. Co-Authored-By: Claude Opus 5.5 --- docs/usage/mount.rst | 3 +++ src/borg/archiver/help_cmd.py | 3 ++- src/borg/archiver/mount_cmds.py | 11 +++++++---- .../testsuite/archiver/mount_cmds_test.py | 3 +++ src/borg/testsuite/vfs_test.py | 11 +++++++++++ src/borg/vfs.py | 19 ++++++++++++++----- 6 files changed, 40 insertions(+), 10 deletions(-) diff --git a/docs/usage/mount.rst b/docs/usage/mount.rst index 537e963956..b0aea14f46 100644 --- a/docs/usage/mount.rst +++ b/docs/usage/mount.rst @@ -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 diff --git a/src/borg/archiver/help_cmd.py b/src/borg/archiver/help_cmd.py index 96cca3a20e..b8e1dc8faf 100644 --- a/src/borg/archiver/help_cmd.py +++ b/src/borg/archiver/help_cmd.py @@ -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 diff --git a/src/borg/archiver/mount_cmds.py b/src/borg/archiver/mount_cmds.py index 7a3e8c54cf..27f33eb042 100644 --- a/src/borg/archiver/mount_cmds.py +++ b/src/borg/archiver/mount_cmds.py @@ -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:: @@ -172,6 +172,9 @@ def build_parser_mount_umount(self, subparsers, common_parser, mid_common_parser 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 `_ and mfusepy. MOUNTPOINT must either be an unused drive (like ``X:``) or a not yet existing diff --git a/src/borg/testsuite/archiver/mount_cmds_test.py b/src/borg/testsuite/archiver/mount_cmds_test.py index 16a4f0c511..ec3070f6b0 100644 --- a/src/borg/testsuite/archiver/mount_cmds_test.py +++ b/src/borg/testsuite/archiver/mount_cmds_test.py @@ -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") diff --git a/src/borg/testsuite/vfs_test.py b/src/borg/testsuite/vfs_test.py index 8bf93ca139..600a8d62af 100644 --- a/src/borg/testsuite/vfs_test.py +++ b/src/borg/testsuite/vfs_test.py @@ -164,6 +164,17 @@ def test_parse_mount_options_numeric_ids(cli_numeric_ids, mount_options, expecte 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", [ diff --git a/src/borg/vfs.py b/src/borg/vfs.py index 7e71334750..5967592520 100644 --- a/src/borg/vfs.py +++ b/src/borg/vfs.py @@ -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 @@ def __init__( allow_damaged_files=False, strip_components=0, item_filter=None, + archive_dir_format=None, dir_item=None, ): self.versions = versions @@ -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 @@ -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] @@ -731,14 +738,15 @@ 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 and numeric_ids mount options are for fstab / autofs entries, - # which can only give mount options. + # 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), @@ -748,6 +756,7 @@ def parse_mount_options(args, mountpoint, mount_options): 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