Browse Source

Merge branch 'main' into passphrase-via-file-descriptor

Dan Helfman 3 months ago
parent
commit
4ee2603fef

+ 8 - 2
NEWS

@@ -1,9 +1,15 @@
 1.9.11.dev0
 1.9.11.dev0
- * #996: Fix the "create" action to omit the repository label prefix from Borg's output when
-   databases are enabled.
  * #795: Add credential loading from file, KeePassXC, and Docker/Podman secrets. See the
  * #795: Add credential loading from file, KeePassXC, and Docker/Podman secrets. See the
    documentation for more information:
    documentation for more information:
    https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/
    https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/
+ * #996: Fix the "create" action to omit the repository label prefix from Borg's output when
+   databases are enabled.
+ * #1001: For the ZFS, Btrfs, and LVM hooks, only make snapshots for root patterns that come from
+   a borgmatic configuration option (e.g. "source_directories")—not from other hooks within
+   borgmatic.
+ * #1001: Fix a ZFS/LVM error due to colliding snapshot mount points for nested datasets or logical
+   volumes.
+ * #1001: Don't try to snapshot ZFS datasets that have the "canmount=off" property.
  * Send the "encryption_passphrase" option to Borg via an anonymous pipe, which is more secure than
  * Send the "encryption_passphrase" option to Borg via an anonymous pipe, which is more secure than
    using an environment variable.
    using an environment variable.
  * Fix another error in the Btrfs hook when a subvolume mounted at "/" is configured in borgmatic's
  * Fix another error in the Btrfs hook when a subvolume mounted at "/" is configured in borgmatic's

+ 6 - 1
borgmatic/actions/create.py

@@ -36,6 +36,7 @@ def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_sty
         path,
         path,
         borgmatic.borg.pattern.Pattern_type(pattern_type),
         borgmatic.borg.pattern.Pattern_type(pattern_type),
         borgmatic.borg.pattern.Pattern_style(pattern_style),
         borgmatic.borg.pattern.Pattern_style(pattern_style),
+        source=borgmatic.borg.pattern.Pattern_source.CONFIG,
     )
     )
 
 
 
 
@@ -51,7 +52,9 @@ def collect_patterns(config):
     try:
     try:
         return (
         return (
             tuple(
             tuple(
-                borgmatic.borg.pattern.Pattern(source_directory)
+                borgmatic.borg.pattern.Pattern(
+                    source_directory, source=borgmatic.borg.pattern.Pattern_source.CONFIG
+                )
                 for source_directory in config.get('source_directories', ())
                 for source_directory in config.get('source_directories', ())
             )
             )
             + tuple(
             + tuple(
@@ -144,6 +147,7 @@ def expand_patterns(patterns, working_directory=None, skip_paths=None):
                         pattern.type,
                         pattern.type,
                         pattern.style,
                         pattern.style,
                         pattern.device,
                         pattern.device,
+                        pattern.source,
                     )
                     )
                     for expanded_path in expand_directory(pattern.path, working_directory)
                     for expanded_path in expand_directory(pattern.path, working_directory)
                 )
                 )
@@ -178,6 +182,7 @@ def device_map_patterns(patterns, working_directory=None):
                 and os.path.exists(full_path)
                 and os.path.exists(full_path)
                 else None
                 else None
             ),
             ),
+            source=pattern.source,
         )
         )
         for pattern in patterns
         for pattern in patterns
         for full_path in (os.path.join(working_directory or '', pattern.path),)
         for full_path in (os.path.join(working_directory or '', pattern.path),)

+ 19 - 7
borgmatic/borg/create.py

@@ -132,9 +132,12 @@ def collect_special_file_paths(
     used.
     used.
 
 
     Skip looking for special files in the given borgmatic runtime directory, as borgmatic creates
     Skip looking for special files in the given borgmatic runtime directory, as borgmatic creates
-    its own special files there for database dumps. And if the borgmatic runtime directory is
-    configured to be excluded from the files Borg backs up, error, because this means Borg won't be
-    able to consume any database dumps and therefore borgmatic will hang.
+    its own special files there for database dumps and we don't want those omitted.
+
+    Additionally, if the borgmatic runtime directory is not contained somewhere in the files Borg
+    plans to backup, that means the user must have excluded the runtime directory (e.g. via
+    "exclude_patterns" or similar). Therefore, raise, because this means Borg won't be able to
+    consume any database dumps and therefore borgmatic will hang when it tries to do so.
     '''
     '''
     # Omit "--exclude-nodump" from the Borg dry run command, because that flag causes Borg to open
     # Omit "--exclude-nodump" from the Borg dry run command, because that flag causes Borg to open
     # files including any named pipe we've created.
     # files including any named pipe we've created.
@@ -148,25 +151,33 @@ def collect_special_file_paths(
         borg_exit_codes=config.get('borg_exit_codes'),
         borg_exit_codes=config.get('borg_exit_codes'),
     )
     )
 
 
+    # These are all the individual files that Borg is planning to backup as determined by the Borg
+    # create dry run above.
     paths = tuple(
     paths = tuple(
         path_line.split(' ', 1)[1]
         path_line.split(' ', 1)[1]
         for path_line in paths_output.split('\n')
         for path_line in paths_output.split('\n')
         if path_line and path_line.startswith('- ') or path_line.startswith('+ ')
         if path_line and path_line.startswith('- ') or path_line.startswith('+ ')
     )
     )
-    skip_paths = {}
+
+    # These are the subset of those files that contain the borgmatic runtime directory.
+    paths_containing_runtime_directory = {}
 
 
     if os.path.exists(borgmatic_runtime_directory):
     if os.path.exists(borgmatic_runtime_directory):
-        skip_paths = {
+        paths_containing_runtime_directory = {
             path for path in paths if any_parent_directories(path, (borgmatic_runtime_directory,))
             path for path in paths if any_parent_directories(path, (borgmatic_runtime_directory,))
         }
         }
 
 
-        if not skip_paths and not dry_run:
+        # If no paths to backup contain the runtime directory, it must've been excluded.
+        if not paths_containing_runtime_directory and not dry_run:
             raise ValueError(
             raise ValueError(
                 f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes or patterns with excludes. Please ensure the runtime directory is not excluded.'
                 f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes or patterns with excludes. Please ensure the runtime directory is not excluded.'
             )
             )
 
 
     return tuple(
     return tuple(
-        path for path in paths if special_file(path, working_directory) if path not in skip_paths
+        path
+        for path in paths
+        if special_file(path, working_directory)
+        if path not in paths_containing_runtime_directory
     )
     )
 
 
 
 
@@ -325,6 +336,7 @@ def make_base_create_command(
                         special_file_path,
                         special_file_path,
                         borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
                         borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
                         borgmatic.borg.pattern.Pattern_style.FNMATCH,
                         borgmatic.borg.pattern.Pattern_style.FNMATCH,
+                        source=borgmatic.borg.pattern.Pattern_source.INTERNAL,
                     )
                     )
                     for special_file_path in special_file_paths
                     for special_file_path in special_file_paths
                 ),
                 ),

+ 20 - 1
borgmatic/borg/pattern.py

@@ -20,12 +20,31 @@ class Pattern_style(enum.Enum):
     PATH_FULL_MATCH = 'pf'
     PATH_FULL_MATCH = 'pf'
 
 
 
 
+class Pattern_source(enum.Enum):
+    '''
+    Where the pattern came from within borgmatic. This is important because certain use cases (like
+    filesystem snapshotting) only want to consider patterns that the user actually put in a
+    configuration file and not patterns from other sources.
+    '''
+
+    # The pattern is from a borgmatic configuration option, e.g. listed in "source_directories".
+    CONFIG = 'config'
+
+    # The pattern is generated internally within borgmatic, e.g. for special file excludes.
+    INTERNAL = 'internal'
+
+    # The pattern originates from within a borgmatic hook, e.g. a database hook that adds its dump
+    # directory.
+    HOOK = 'hook'
+
+
 Pattern = collections.namedtuple(
 Pattern = collections.namedtuple(
     'Pattern',
     'Pattern',
-    ('path', 'type', 'style', 'device'),
+    ('path', 'type', 'style', 'device', 'source'),
     defaults=(
     defaults=(
         Pattern_type.ROOT,
         Pattern_type.ROOT,
         Pattern_style.NONE,
         Pattern_style.NONE,
         None,
         None,
+        Pattern_source.HOOK,
     ),
     ),
 )
 )

+ 10 - 2
borgmatic/hooks/data_source/bootstrap.py

@@ -55,9 +55,17 @@ def dump_data_sources(
             manifest_file,
             manifest_file,
         )
         )
 
 
-    patterns.extend(borgmatic.borg.pattern.Pattern(config_path) for config_path in config_paths)
+    patterns.extend(
+        borgmatic.borg.pattern.Pattern(
+            config_path, source=borgmatic.borg.pattern.Pattern_source.HOOK
+        )
+        for config_path in config_paths
+    )
     patterns.append(
     patterns.append(
-        borgmatic.borg.pattern.Pattern(os.path.join(borgmatic_runtime_directory, 'bootstrap'))
+        borgmatic.borg.pattern.Pattern(
+            os.path.join(borgmatic_runtime_directory, 'bootstrap'),
+            source=borgmatic.borg.pattern.Pattern_source.HOOK,
+        )
     )
     )
 
 
     return []
     return []

+ 13 - 3
borgmatic/hooks/data_source/btrfs.py

@@ -54,7 +54,9 @@ def get_subvolumes(btrfs_command, findmnt_command, patterns=None):
     between the current Btrfs filesystem and subvolume mount points and the paths of any patterns.
     between the current Btrfs filesystem and subvolume mount points and the paths of any patterns.
     The idea is that these pattern paths represent the requested subvolumes to snapshot.
     The idea is that these pattern paths represent the requested subvolumes to snapshot.
 
 
-    If patterns is None, then return all subvolumes, sorted by path.
+    Only include subvolumes that contain at least one root pattern sourced from borgmatic
+    configuration (as opposed to generated elsewhere in borgmatic). But if patterns is None, then
+    return all subvolumes instead, sorted by path.
 
 
     Return the result as a sequence of matching subvolume mount points.
     Return the result as a sequence of matching subvolume mount points.
     '''
     '''
@@ -73,7 +75,12 @@ def get_subvolumes(btrfs_command, findmnt_command, patterns=None):
                     mount_point, candidate_patterns
                     mount_point, candidate_patterns
                 ),
                 ),
             )
             )
-            if patterns is None or contained_patterns
+            if patterns is None
+            or any(
+                pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
+                and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
+                for pattern in contained_patterns
+            )
         )
         )
 
 
     return tuple(sorted(subvolumes, key=lambda subvolume: subvolume.path))
     return tuple(sorted(subvolumes, key=lambda subvolume: subvolume.path))
@@ -121,6 +128,7 @@ def make_snapshot_exclude_pattern(subvolume_path):  # pragma: no cover
         ),
         ),
         borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
         borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
         borgmatic.borg.pattern.Pattern_style.FNMATCH,
         borgmatic.borg.pattern.Pattern_style.FNMATCH,
+        source=borgmatic.borg.pattern.Pattern_source.HOOK,
     )
     )
 
 
 
 
@@ -153,6 +161,7 @@ def make_borg_snapshot_pattern(subvolume_path, pattern):
         pattern.type,
         pattern.type,
         pattern.style,
         pattern.style,
         pattern.device,
         pattern.device,
+        source=borgmatic.borg.pattern.Pattern_source.HOOK,
     )
     )
 
 
 
 
@@ -198,7 +207,8 @@ def dump_data_sources(
     dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
     dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
     logger.info(f'Snapshotting Btrfs subvolumes{dry_run_label}')
     logger.info(f'Snapshotting Btrfs subvolumes{dry_run_label}')
 
 
-    # Based on the configured patterns, determine Btrfs subvolumes to backup.
+    # Based on the configured patterns, determine Btrfs subvolumes to backup. Only consider those
+    # patterns that came from actual user configuration (as opposed to, say, other hooks).
     btrfs_command = hook_config.get('btrfs_command', 'btrfs')
     btrfs_command = hook_config.get('btrfs_command', 'btrfs')
     findmnt_command = hook_config.get('findmnt_command', 'findmnt')
     findmnt_command = hook_config.get('findmnt_command', 'findmnt')
     subvolumes = get_subvolumes(btrfs_command, findmnt_command, patterns)
     subvolumes = get_subvolumes(btrfs_command, findmnt_command, patterns)

+ 39 - 9
borgmatic/hooks/data_source/lvm.py

@@ -1,5 +1,6 @@
 import collections
 import collections
 import glob
 import glob
+import hashlib
 import json
 import json
 import logging
 import logging
 import os
 import os
@@ -33,7 +34,9 @@ def get_logical_volumes(lsblk_command, patterns=None):
     between the current LVM logical volume mount points and the paths of any patterns. The idea is
     between the current LVM logical volume mount points and the paths of any patterns. The idea is
     that these pattern paths represent the requested logical volumes to snapshot.
     that these pattern paths represent the requested logical volumes to snapshot.
 
 
-    If patterns is None, include all logical volume mounts points, not just those in patterns.
+    Only include logical volumes that contain at least one root pattern sourced from borgmatic
+    configuration (as opposed to generated elsewhere in borgmatic). But if patterns is None, include
+    all logical volume mounts points instead, not just those in patterns.
 
 
     Return the result as a sequence of Logical_volume instances.
     Return the result as a sequence of Logical_volume instances.
     '''
     '''
@@ -72,7 +75,12 @@ def get_logical_volumes(lsblk_command, patterns=None):
                     device['mountpoint'], candidate_patterns
                     device['mountpoint'], candidate_patterns
                 ),
                 ),
             )
             )
-            if not patterns or contained_patterns
+            if not patterns
+            or any(
+                pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
+                and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
+                for pattern in contained_patterns
+            )
         )
         )
     except KeyError as error:
     except KeyError as error:
         raise ValueError(f'Invalid {lsblk_command} output: Missing key "{error}"')
         raise ValueError(f'Invalid {lsblk_command} output: Missing key "{error}"')
@@ -124,10 +132,14 @@ def mount_snapshot(mount_command, snapshot_device, snapshot_mount_path):  # prag
     )
     )
 
 
 
 
-def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
+MOUNT_POINT_HASH_LENGTH = 10
+
+
+def make_borg_snapshot_pattern(pattern, logical_volume, normalized_runtime_directory):
     '''
     '''
-    Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance, return a new Pattern with its
-    path rewritten to be in a snapshot directory based on the given runtime directory.
+    Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance and a Logical_volume
+    containing it, return a new Pattern with its path rewritten to be in a snapshot directory based
+    on both the given runtime directory and the given Logical_volume's mount point.
 
 
     Move any initial caret in a regular expression pattern path to the beginning, so as not to break
     Move any initial caret in a regular expression pattern path to the beginning, so as not to break
     the regular expression.
     the regular expression.
@@ -142,6 +154,13 @@ def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
     rewritten_path = initial_caret + os.path.join(
     rewritten_path = initial_caret + os.path.join(
         normalized_runtime_directory,
         normalized_runtime_directory,
         'lvm_snapshots',
         'lvm_snapshots',
+        # Including this hash prevents conflicts between snapshot patterns for different logical
+        # volumes. For instance, without this, snapshotting a logical volume at /var and another at
+        # /var/spool would result in overlapping snapshot patterns and therefore colliding mount
+        # attempts.
+        hashlib.shake_256(logical_volume.mount_point.encode('utf-8')).hexdigest(
+            MOUNT_POINT_HASH_LENGTH
+        ),
         '.',  # Borg 1.4+ "slashdot" hack.
         '.',  # Borg 1.4+ "slashdot" hack.
         # Included so that the source directory ends up in the Borg archive at its "original" path.
         # Included so that the source directory ends up in the Borg archive at its "original" path.
         pattern.path.lstrip('^').lstrip(os.path.sep),
         pattern.path.lstrip('^').lstrip(os.path.sep),
@@ -152,6 +171,7 @@ def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
         pattern.type,
         pattern.type,
         pattern.style,
         pattern.style,
         pattern.device,
         pattern.device,
+        source=borgmatic.borg.pattern.Pattern_source.HOOK,
     )
     )
 
 
 
 
@@ -180,7 +200,8 @@ def dump_data_sources(
     dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
     dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
     logger.info(f'Snapshotting LVM logical volumes{dry_run_label}')
     logger.info(f'Snapshotting LVM logical volumes{dry_run_label}')
 
 
-    # List logical volumes to get their mount points.
+    # List logical volumes to get their mount points, but only consider those patterns that came
+    # from actual user configuration (as opposed to, say, other hooks).
     lsblk_command = hook_config.get('lsblk_command', 'lsblk')
     lsblk_command = hook_config.get('lsblk_command', 'lsblk')
     requested_logical_volumes = get_logical_volumes(lsblk_command, patterns)
     requested_logical_volumes = get_logical_volumes(lsblk_command, patterns)
 
 
@@ -218,6 +239,9 @@ def dump_data_sources(
         snapshot_mount_path = os.path.join(
         snapshot_mount_path = os.path.join(
             normalized_runtime_directory,
             normalized_runtime_directory,
             'lvm_snapshots',
             'lvm_snapshots',
+            hashlib.shake_256(logical_volume.mount_point.encode('utf-8')).hexdigest(
+                MOUNT_POINT_HASH_LENGTH
+            ),
             logical_volume.mount_point.lstrip(os.path.sep),
             logical_volume.mount_point.lstrip(os.path.sep),
         )
         )
 
 
@@ -233,7 +257,9 @@ def dump_data_sources(
         )
         )
 
 
         for pattern in logical_volume.contained_patterns:
         for pattern in logical_volume.contained_patterns:
-            snapshot_pattern = make_borg_snapshot_pattern(pattern, normalized_runtime_directory)
+            snapshot_pattern = make_borg_snapshot_pattern(
+                pattern, logical_volume, normalized_runtime_directory
+            )
 
 
             # Attempt to update the pattern in place, since pattern order matters to Borg.
             # Attempt to update the pattern in place, since pattern order matters to Borg.
             try:
             try:
@@ -337,6 +363,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
             os.path.normpath(borgmatic_runtime_directory),
             os.path.normpath(borgmatic_runtime_directory),
         ),
         ),
         'lvm_snapshots',
         'lvm_snapshots',
+        '*',
     )
     )
     logger.debug(f'Looking for snapshots to remove in {snapshots_glob}{dry_run_label}')
     logger.debug(f'Looking for snapshots to remove in {snapshots_glob}{dry_run_label}')
     umount_command = hook_config.get('umount_command', 'umount')
     umount_command = hook_config.get('umount_command', 'umount')
@@ -349,7 +376,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
             snapshot_mount_path = os.path.join(
             snapshot_mount_path = os.path.join(
                 snapshots_directory, logical_volume.mount_point.lstrip(os.path.sep)
                 snapshots_directory, logical_volume.mount_point.lstrip(os.path.sep)
             )
             )
-            if not os.path.isdir(snapshot_mount_path):
+
+            # If the snapshot mount path is empty, this is probably just a "shadow" of a nested
+            # logical volume and therefore there's nothing to unmount.
+            if not os.path.isdir(snapshot_mount_path) or not os.listdir(snapshot_mount_path):
                 continue
                 continue
 
 
             # This might fail if the directory is already mounted, but we swallow errors here since
             # This might fail if the directory is already mounted, but we swallow errors here since
@@ -374,7 +404,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
                 return
                 return
             except subprocess.CalledProcessError as error:
             except subprocess.CalledProcessError as error:
                 logger.debug(error)
                 logger.debug(error)
-                return
+                continue
 
 
         if not dry_run:
         if not dry_run:
             shutil.rmtree(snapshots_directory)
             shutil.rmtree(snapshots_directory)

+ 2 - 1
borgmatic/hooks/data_source/mariadb.py

@@ -216,7 +216,8 @@ def dump_data_sources(
     if not dry_run:
     if not dry_run:
         patterns.append(
         patterns.append(
             borgmatic.borg.pattern.Pattern(
             borgmatic.borg.pattern.Pattern(
-                os.path.join(borgmatic_runtime_directory, 'mariadb_databases')
+                os.path.join(borgmatic_runtime_directory, 'mariadb_databases'),
+                source=borgmatic.borg.pattern.Pattern_source.HOOK,
             )
             )
         )
         )
 
 

+ 2 - 1
borgmatic/hooks/data_source/mongodb.py

@@ -81,7 +81,8 @@ def dump_data_sources(
     if not dry_run:
     if not dry_run:
         patterns.append(
         patterns.append(
             borgmatic.borg.pattern.Pattern(
             borgmatic.borg.pattern.Pattern(
-                os.path.join(borgmatic_runtime_directory, 'mongodb_databases')
+                os.path.join(borgmatic_runtime_directory, 'mongodb_databases'),
+                source=borgmatic.borg.pattern.Pattern_source.HOOK,
             )
             )
         )
         )
 
 

+ 2 - 1
borgmatic/hooks/data_source/mysql.py

@@ -215,7 +215,8 @@ def dump_data_sources(
     if not dry_run:
     if not dry_run:
         patterns.append(
         patterns.append(
             borgmatic.borg.pattern.Pattern(
             borgmatic.borg.pattern.Pattern(
-                os.path.join(borgmatic_runtime_directory, 'mysql_databases')
+                os.path.join(borgmatic_runtime_directory, 'mysql_databases'),
+                source=borgmatic.borg.pattern.Pattern_source.HOOK,
             )
             )
         )
         )
 
 

+ 2 - 1
borgmatic/hooks/data_source/postgresql.py

@@ -239,7 +239,8 @@ def dump_data_sources(
     if not dry_run:
     if not dry_run:
         patterns.append(
         patterns.append(
             borgmatic.borg.pattern.Pattern(
             borgmatic.borg.pattern.Pattern(
-                os.path.join(borgmatic_runtime_directory, 'postgresql_databases')
+                os.path.join(borgmatic_runtime_directory, 'postgresql_databases'),
+                source=borgmatic.borg.pattern.Pattern_source.HOOK,
             )
             )
         )
         )
 
 

+ 8 - 6
borgmatic/hooks/data_source/snapshot.py

@@ -11,10 +11,10 @@ def get_contained_patterns(parent_directory, candidate_patterns):
     paths, but there's a parent directory (logical volume, dataset, subvolume, etc.) at /var, then
     paths, but there's a parent directory (logical volume, dataset, subvolume, etc.) at /var, then
     /var is what we want to snapshot.
     /var is what we want to snapshot.
 
 
-    For this to work, a candidate pattern path can't have any globs or other non-literal characters
-    in the initial portion of the path that matches the parent directory. For instance, a parent
-    directory of /var would match a candidate pattern path of /var/log/*/data, but not a pattern
-    path like /v*/log/*/data.
+    For this function to work, a candidate pattern path can't have any globs or other non-literal
+    characters in the initial portion of the path that matches the parent directory. For instance, a
+    parent directory of /var would match a candidate pattern path of /var/log/*/data, but not a
+    pattern path like /v*/log/*/data.
 
 
     The one exception is that if a regular expression pattern path starts with "^", that will get
     The one exception is that if a regular expression pattern path starts with "^", that will get
     stripped off for purposes of matching against a parent directory.
     stripped off for purposes of matching against a parent directory.
@@ -31,8 +31,10 @@ def get_contained_patterns(parent_directory, candidate_patterns):
         candidate
         candidate
         for candidate in candidate_patterns
         for candidate in candidate_patterns
         for candidate_path in (pathlib.PurePath(candidate.path.lstrip('^')),)
         for candidate_path in (pathlib.PurePath(candidate.path.lstrip('^')),)
-        if pathlib.PurePath(parent_directory) == candidate_path
-        or pathlib.PurePath(parent_directory) in candidate_path.parents
+        if (
+            pathlib.PurePath(parent_directory) == candidate_path
+            or pathlib.PurePath(parent_directory) in candidate_path.parents
+        )
     )
     )
     candidate_patterns -= set(contained_patterns)
     candidate_patterns -= set(contained_patterns)
 
 

+ 2 - 1
borgmatic/hooks/data_source/sqlite.py

@@ -90,7 +90,8 @@ def dump_data_sources(
     if not dry_run:
     if not dry_run:
         patterns.append(
         patterns.append(
             borgmatic.borg.pattern.Pattern(
             borgmatic.borg.pattern.Pattern(
-                os.path.join(borgmatic_runtime_directory, 'sqlite_databases')
+                os.path.join(borgmatic_runtime_directory, 'sqlite_databases'),
+                source=borgmatic.borg.pattern.Pattern_source.HOOK,
             )
             )
         )
         )
 
 

+ 49 - 11
borgmatic/hooks/data_source/zfs.py

@@ -1,5 +1,6 @@
 import collections
 import collections
 import glob
 import glob
+import hashlib
 import logging
 import logging
 import os
 import os
 import shutil
 import shutil
@@ -38,6 +39,9 @@ def get_datasets_to_backup(zfs_command, patterns):
     pattern paths represent the requested datasets to snapshot. But also include any datasets tagged
     pattern paths represent the requested datasets to snapshot. But also include any datasets tagged
     with a borgmatic-specific user property, whether or not they appear in the patterns.
     with a borgmatic-specific user property, whether or not they appear in the patterns.
 
 
+    Only include datasets that contain at least one root pattern sourced from borgmatic
+    configuration (as opposed to generated elsewhere in borgmatic).
+
     Return the result as a sequence of Dataset instances, sorted by mount point.
     Return the result as a sequence of Dataset instances, sorted by mount point.
     '''
     '''
     list_output = borgmatic.execute.execute_command_and_capture_output(
     list_output = borgmatic.execute.execute_command_and_capture_output(
@@ -48,7 +52,7 @@ def get_datasets_to_backup(zfs_command, patterns):
             '-t',
             '-t',
             'filesystem',
             'filesystem',
             '-o',
             '-o',
-            f'name,mountpoint,{BORGMATIC_USER_PROPERTY}',
+            f'name,mountpoint,canmount,{BORGMATIC_USER_PROPERTY}',
         )
         )
     )
     )
 
 
@@ -60,7 +64,12 @@ def get_datasets_to_backup(zfs_command, patterns):
             (
             (
                 Dataset(dataset_name, mount_point, (user_property_value == 'auto'), ())
                 Dataset(dataset_name, mount_point, (user_property_value == 'auto'), ())
                 for line in list_output.splitlines()
                 for line in list_output.splitlines()
-                for (dataset_name, mount_point, user_property_value) in (line.rstrip().split('\t'),)
+                for (dataset_name, mount_point, can_mount, user_property_value) in (
+                    line.rstrip().split('\t'),
+                )
+                # Skip datasets that are marked "canmount=off", because mounting their snapshots will
+                # result in completely empty mount points—thereby preventing us from backing them up.
+                if can_mount == 'on'
             ),
             ),
             key=lambda dataset: dataset.mount_point,
             key=lambda dataset: dataset.mount_point,
             reverse=True,
             reverse=True,
@@ -83,7 +92,12 @@ def get_datasets_to_backup(zfs_command, patterns):
                 for contained_patterns in (
                 for contained_patterns in (
                     (
                     (
                         (
                         (
-                            (borgmatic.borg.pattern.Pattern(dataset.mount_point),)
+                            (
+                                borgmatic.borg.pattern.Pattern(
+                                    dataset.mount_point,
+                                    source=borgmatic.borg.pattern.Pattern_source.HOOK,
+                                ),
+                            )
                             if dataset.auto_backup
                             if dataset.auto_backup
                             else ()
                             else ()
                         )
                         )
@@ -92,7 +106,12 @@ def get_datasets_to_backup(zfs_command, patterns):
                         )
                         )
                     ),
                     ),
                 )
                 )
-                if contained_patterns
+                if dataset.auto_backup
+                or any(
+                    pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
+                    and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
+                    for pattern in contained_patterns
+                )
             ),
             ),
             key=lambda dataset: dataset.mount_point,
             key=lambda dataset: dataset.mount_point,
         )
         )
@@ -155,10 +174,14 @@ def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path):  # p
     )
     )
 
 
 
 
-def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
+MOUNT_POINT_HASH_LENGTH = 10
+
+
+def make_borg_snapshot_pattern(pattern, dataset, normalized_runtime_directory):
     '''
     '''
-    Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance, return a new Pattern with its
-    path rewritten to be in a snapshot directory based on the given runtime directory.
+    Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance and the Dataset containing it,
+    return a new Pattern with its path rewritten to be in a snapshot directory based on both the
+    given runtime directory and the given Dataset's mount point.
 
 
     Move any initial caret in a regular expression pattern path to the beginning, so as not to break
     Move any initial caret in a regular expression pattern path to the beginning, so as not to break
     the regular expression.
     the regular expression.
@@ -173,6 +196,10 @@ def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
     rewritten_path = initial_caret + os.path.join(
     rewritten_path = initial_caret + os.path.join(
         normalized_runtime_directory,
         normalized_runtime_directory,
         'zfs_snapshots',
         'zfs_snapshots',
+        # Including this hash prevents conflicts between snapshot patterns for different datasets.
+        # For instance, without this, snapshotting a dataset at /var and another at /var/spool would
+        # result in overlapping snapshot patterns and therefore colliding mount attempts.
+        hashlib.shake_256(dataset.mount_point.encode('utf-8')).hexdigest(MOUNT_POINT_HASH_LENGTH),
         '.',  # Borg 1.4+ "slashdot" hack.
         '.',  # Borg 1.4+ "slashdot" hack.
         # Included so that the source directory ends up in the Borg archive at its "original" path.
         # Included so that the source directory ends up in the Borg archive at its "original" path.
         pattern.path.lstrip('^').lstrip(os.path.sep),
         pattern.path.lstrip('^').lstrip(os.path.sep),
@@ -183,6 +210,7 @@ def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
         pattern.type,
         pattern.type,
         pattern.style,
         pattern.style,
         pattern.device,
         pattern.device,
+        source=borgmatic.borg.pattern.Pattern_source.HOOK,
     )
     )
 
 
 
 
@@ -209,7 +237,8 @@ def dump_data_sources(
     dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
     dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
     logger.info(f'Snapshotting ZFS datasets{dry_run_label}')
     logger.info(f'Snapshotting ZFS datasets{dry_run_label}')
 
 
-    # List ZFS datasets to get their mount points.
+    # List ZFS datasets to get their mount points, but only consider those patterns that came from
+    # actual user configuration (as opposed to, say, other hooks).
     zfs_command = hook_config.get('zfs_command', 'zfs')
     zfs_command = hook_config.get('zfs_command', 'zfs')
     requested_datasets = get_datasets_to_backup(zfs_command, patterns)
     requested_datasets = get_datasets_to_backup(zfs_command, patterns)
 
 
@@ -234,6 +263,9 @@ def dump_data_sources(
         snapshot_mount_path = os.path.join(
         snapshot_mount_path = os.path.join(
             normalized_runtime_directory,
             normalized_runtime_directory,
             'zfs_snapshots',
             'zfs_snapshots',
+            hashlib.shake_256(dataset.mount_point.encode('utf-8')).hexdigest(
+                MOUNT_POINT_HASH_LENGTH
+            ),
             dataset.mount_point.lstrip(os.path.sep),
             dataset.mount_point.lstrip(os.path.sep),
         )
         )
 
 
@@ -249,7 +281,9 @@ def dump_data_sources(
         )
         )
 
 
         for pattern in dataset.contained_patterns:
         for pattern in dataset.contained_patterns:
-            snapshot_pattern = make_borg_snapshot_pattern(pattern, normalized_runtime_directory)
+            snapshot_pattern = make_borg_snapshot_pattern(
+                pattern, dataset, normalized_runtime_directory
+            )
 
 
             # Attempt to update the pattern in place, since pattern order matters to Borg.
             # Attempt to update the pattern in place, since pattern order matters to Borg.
             try:
             try:
@@ -334,6 +368,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
             os.path.normpath(borgmatic_runtime_directory),
             os.path.normpath(borgmatic_runtime_directory),
         ),
         ),
         'zfs_snapshots',
         'zfs_snapshots',
+        '*',
     )
     )
     logger.debug(f'Looking for snapshots to remove in {snapshots_glob}{dry_run_label}')
     logger.debug(f'Looking for snapshots to remove in {snapshots_glob}{dry_run_label}')
     umount_command = hook_config.get('umount_command', 'umount')
     umount_command = hook_config.get('umount_command', 'umount')
@@ -346,7 +381,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
         # child datasets before the shorter mount point paths of parent datasets.
         # child datasets before the shorter mount point paths of parent datasets.
         for mount_point in reversed(dataset_mount_points):
         for mount_point in reversed(dataset_mount_points):
             snapshot_mount_path = os.path.join(snapshots_directory, mount_point.lstrip(os.path.sep))
             snapshot_mount_path = os.path.join(snapshots_directory, mount_point.lstrip(os.path.sep))
-            if not os.path.isdir(snapshot_mount_path):
+
+            # If the snapshot mount path is empty, this is probably just a "shadow" of a nested
+            # dataset and therefore there's nothing to unmount.
+            if not os.path.isdir(snapshot_mount_path) or not os.listdir(snapshot_mount_path):
                 continue
                 continue
 
 
             # This might fail if the path is already mounted, but we swallow errors here since we'll
             # This might fail if the path is already mounted, but we swallow errors here since we'll
@@ -370,7 +408,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
                     return
                     return
                 except subprocess.CalledProcessError as error:
                 except subprocess.CalledProcessError as error:
                     logger.debug(error)
                     logger.debug(error)
-                    return
+                    continue
 
 
         if not dry_run:
         if not dry_run:
             shutil.rmtree(snapshots_directory)
             shutil.rmtree(snapshots_directory)

+ 11 - 4
docs/how-to/snapshot-your-filesystems.md

@@ -54,8 +54,8 @@ You have a couple of options for borgmatic to find and backup your ZFS datasets:
  * For any dataset you'd like backed up, add its mount point to borgmatic's
  * For any dataset you'd like backed up, add its mount point to borgmatic's
    `source_directories` option.
    `source_directories` option.
  * <span class="minilink minilink-addedin">New in version 1.9.6</span> Or
  * <span class="minilink minilink-addedin">New in version 1.9.6</span> Or
-   include the mount point with borgmatic's `patterns` or `patterns_from`
-   options.
+   include the mount point as a root pattern with borgmatic's `patterns` or
+   `patterns_from` options.
  * Or set the borgmatic-specific user property
  * Or set the borgmatic-specific user property
    `org.torsion.borgmatic:backup=auto` onto your dataset, e.g. by running `zfs
    `org.torsion.borgmatic:backup=auto` onto your dataset, e.g. by running `zfs
    set org.torsion.borgmatic:backup=auto datasetname`. Then borgmatic can find
    set org.torsion.borgmatic:backup=auto datasetname`. Then borgmatic can find
@@ -65,6 +65,11 @@ If you have multiple borgmatic configuration files with ZFS enabled, and you'd
 like particular datasets to be backed up only for particular configuration
 like particular datasets to be backed up only for particular configuration
 files, use the `source_directories` option instead of the user property.
 files, use the `source_directories` option instead of the user property.
 
 
+<span class="minilink minilink-addedin">New in version 1.9.11</span> borgmatic
+won't snapshot datasets with the `canmount=off` property, which is often set on
+datasets that only serve as a container for other datasets. Use `zfs get
+canmount datasetname` to see the `canmount` value for a dataset.
+
 During a backup, borgmatic automatically snapshots these discovered datasets
 During a backup, borgmatic automatically snapshots these discovered datasets
 (non-recursively), temporarily mounts the snapshots within its [runtime
 (non-recursively), temporarily mounts the snapshots within its [runtime
 directory](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory),
 directory](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory),
@@ -147,7 +152,8 @@ For any subvolume you'd like backed up, add its path to borgmatic's
 `source_directories` option.
 `source_directories` option.
 
 
 <span class="minilink minilink-addedin">New in version 1.9.6</span> Or include
 <span class="minilink minilink-addedin">New in version 1.9.6</span> Or include
-the mount point with borgmatic's `patterns` or `patterns_from` options.
+the mount point as a root pattern with borgmatic's `patterns` or `patterns_from`
+options.
 
 
 During a backup, borgmatic snapshots these subvolumes (non-recursively) and
 During a backup, borgmatic snapshots these subvolumes (non-recursively) and
 includes the snapshotted files in the paths sent to Borg. borgmatic is also
 includes the snapshotted files in the paths sent to Borg. borgmatic is also
@@ -252,7 +258,8 @@ For any logical volume you'd like backed up, add its mount point to
 borgmatic's `source_directories` option.
 borgmatic's `source_directories` option.
 
 
 <span class="minilink minilink-addedin">New in version 1.9.6</span> Or include
 <span class="minilink minilink-addedin">New in version 1.9.6</span> Or include
-the mount point with borgmatic's `patterns` or `patterns_from` options.
+the mount point as a root pattern with borgmatic's `patterns` or `patterns_from`
+options.
 
 
 During a backup, borgmatic automatically snapshots these discovered logical volumes
 During a backup, borgmatic automatically snapshots these discovered logical volumes
 (non-recursively), temporarily mounts the snapshots within its [runtime
 (non-recursively), temporarily mounts the snapshots within its [runtime

+ 2 - 0
tests/end-to-end/commands/fake_zfs.py

@@ -27,6 +27,7 @@ BUILTIN_DATASETS = (
         'used': '256K',
         'used': '256K',
         'avail': '23.7M',
         'avail': '23.7M',
         'refer': '25K',
         'refer': '25K',
+        'canmount': 'on',
         'mountpoint': '/pool',
         'mountpoint': '/pool',
     },
     },
     {
     {
@@ -34,6 +35,7 @@ BUILTIN_DATASETS = (
         'used': '256K',
         'used': '256K',
         'avail': '23.7M',
         'avail': '23.7M',
         'refer': '25K',
         'refer': '25K',
+        'canmount': 'on',
         'mountpoint': '/pool/dataset',
         'mountpoint': '/pool/dataset',
     },
     },
 )
 )

+ 21 - 10
tests/unit/actions/test_create.py

@@ -5,16 +5,21 @@ import pytest
 from flexmock import flexmock
 from flexmock import flexmock
 
 
 from borgmatic.actions import create as module
 from borgmatic.actions import create as module
-from borgmatic.borg.pattern import Pattern, Pattern_style, Pattern_type
+from borgmatic.borg.pattern import Pattern, Pattern_source, Pattern_style, Pattern_type
 
 
 
 
 @pytest.mark.parametrize(
 @pytest.mark.parametrize(
     'pattern_line,expected_pattern',
     'pattern_line,expected_pattern',
     (
     (
-        ('R /foo', Pattern('/foo')),
-        ('P sh', Pattern('sh', Pattern_type.PATTERN_STYLE)),
-        ('+ /foo*', Pattern('/foo*', Pattern_type.INCLUDE)),
-        ('+ sh:/foo*', Pattern('/foo*', Pattern_type.INCLUDE, Pattern_style.SHELL)),
+        ('R /foo', Pattern('/foo', source=Pattern_source.CONFIG)),
+        ('P sh', Pattern('sh', Pattern_type.PATTERN_STYLE, source=Pattern_source.CONFIG)),
+        ('+ /foo*', Pattern('/foo*', Pattern_type.INCLUDE, source=Pattern_source.CONFIG)),
+        (
+            '+ sh:/foo*',
+            Pattern(
+                '/foo*', Pattern_type.INCLUDE, Pattern_style.SHELL, source=Pattern_source.CONFIG
+            ),
+        ),
     ),
     ),
 )
 )
 def test_parse_pattern_transforms_pattern_line_to_instance(pattern_line, expected_pattern):
 def test_parse_pattern_transforms_pattern_line_to_instance(pattern_line, expected_pattern):
@@ -28,8 +33,8 @@ def test_parse_pattern_with_invalid_pattern_line_errors():
 
 
 def test_collect_patterns_converts_source_directories():
 def test_collect_patterns_converts_source_directories():
     assert module.collect_patterns({'source_directories': ['/foo', '/bar']}) == (
     assert module.collect_patterns({'source_directories': ['/foo', '/bar']}) == (
-        Pattern('/foo'),
-        Pattern('/bar'),
+        Pattern('/foo', source=Pattern_source.CONFIG),
+        Pattern('/bar', source=Pattern_source.CONFIG),
     )
     )
 
 
 
 
@@ -48,9 +53,15 @@ def test_collect_patterns_parses_config_patterns():
 
 
 def test_collect_patterns_converts_exclude_patterns():
 def test_collect_patterns_converts_exclude_patterns():
     assert module.collect_patterns({'exclude_patterns': ['/foo', '/bar', 'sh:**/baz']}) == (
     assert module.collect_patterns({'exclude_patterns': ['/foo', '/bar', 'sh:**/baz']}) == (
-        Pattern('/foo', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
-        Pattern('/bar', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
-        Pattern('**/baz', Pattern_type.NO_RECURSE, Pattern_style.SHELL),
+        Pattern(
+            '/foo', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, source=Pattern_source.CONFIG
+        ),
+        Pattern(
+            '/bar', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, source=Pattern_source.CONFIG
+        ),
+        Pattern(
+            '**/baz', Pattern_type.NO_RECURSE, Pattern_style.SHELL, source=Pattern_source.CONFIG
+        ),
     )
     )
 
 
 
 

+ 3 - 1
tests/unit/borg/test_create.py

@@ -4,7 +4,7 @@ import pytest
 from flexmock import flexmock
 from flexmock import flexmock
 
 
 from borgmatic.borg import create as module
 from borgmatic.borg import create as module
-from borgmatic.borg.pattern import Pattern, Pattern_style, Pattern_type
+from borgmatic.borg.pattern import Pattern, Pattern_source, Pattern_style, Pattern_type
 
 
 from ..test_verbosity import insert_logging_mock
 from ..test_verbosity import insert_logging_mock
 
 
@@ -663,6 +663,7 @@ def test_make_base_create_command_with_stream_processes_ignores_read_special_fal
                 '/dev/null',
                 '/dev/null',
                 Pattern_type.NO_RECURSE,
                 Pattern_type.NO_RECURSE,
                 Pattern_style.FNMATCH,
                 Pattern_style.FNMATCH,
+                source=Pattern_source.INTERNAL,
             ),
             ),
         ),
         ),
         '/run/borgmatic',
         '/run/borgmatic',
@@ -713,6 +714,7 @@ def test_make_base_create_command_without_patterns_and_with_stream_processes_ign
                 '/dev/null',
                 '/dev/null',
                 Pattern_type.NO_RECURSE,
                 Pattern_type.NO_RECURSE,
                 Pattern_style.FNMATCH,
                 Pattern_style.FNMATCH,
+                source=Pattern_source.INTERNAL,
             ),
             ),
         ),
         ),
         '/run/borgmatic',
         '/run/borgmatic',

+ 70 - 3
tests/unit/hooks/data_source/test_btrfs.py

@@ -1,7 +1,7 @@
 import pytest
 import pytest
 from flexmock import flexmock
 from flexmock import flexmock
 
 
-from borgmatic.borg.pattern import Pattern, Pattern_style, Pattern_type
+from borgmatic.borg.pattern import Pattern, Pattern_source, Pattern_style, Pattern_type
 from borgmatic.hooks.data_source import btrfs as module
 from borgmatic.hooks.data_source import btrfs as module
 
 
 
 
@@ -52,9 +52,14 @@ def test_get_subvolume_mount_points_with_findmnt_json_missing_filesystems_errors
 def test_get_subvolumes_collects_subvolumes_matching_patterns():
 def test_get_subvolumes_collects_subvolumes_matching_patterns():
     flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2'))
     flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2'))
 
 
+    contained_pattern = Pattern(
+        '/mnt1',
+        type=Pattern_type.ROOT,
+        source=Pattern_source.CONFIG,
+    )
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
         'get_contained_patterns'
         'get_contained_patterns'
-    ).with_args('/mnt1', object).and_return((Pattern('/mnt1'),))
+    ).with_args('/mnt1', object).and_return((contained_pattern,))
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
         'get_contained_patterns'
         'get_contained_patterns'
     ).with_args('/mnt2', object).and_return(())
     ).with_args('/mnt2', object).and_return(())
@@ -66,7 +71,69 @@ def test_get_subvolumes_collects_subvolumes_matching_patterns():
             Pattern('/mnt1'),
             Pattern('/mnt1'),
             Pattern('/mnt3'),
             Pattern('/mnt3'),
         ],
         ],
-    ) == (module.Subvolume('/mnt1', contained_patterns=(Pattern('/mnt1'),)),)
+    ) == (module.Subvolume('/mnt1', contained_patterns=(contained_pattern,)),)
+
+
+def test_get_subvolumes_skips_non_root_patterns():
+    flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2'))
+
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/mnt1', object).and_return(
+        (
+            Pattern(
+                '/mnt1',
+                type=Pattern_type.EXCLUDE,
+                source=Pattern_source.CONFIG,
+            ),
+        )
+    )
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/mnt2', object).and_return(())
+
+    assert (
+        module.get_subvolumes(
+            'btrfs',
+            'findmnt',
+            patterns=[
+                Pattern('/mnt1'),
+                Pattern('/mnt3'),
+            ],
+        )
+        == ()
+    )
+
+
+def test_get_subvolumes_skips_non_config_patterns():
+    flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2'))
+
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/mnt1', object).and_return(
+        (
+            Pattern(
+                '/mnt1',
+                type=Pattern_type.ROOT,
+                source=Pattern_source.HOOK,
+            ),
+        )
+    )
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/mnt2', object).and_return(())
+
+    assert (
+        module.get_subvolumes(
+            'btrfs',
+            'findmnt',
+            patterns=[
+                Pattern('/mnt1'),
+                Pattern('/mnt3'),
+            ],
+        )
+        == ()
+    )
 
 
 
 
 def test_get_subvolumes_without_patterns_collects_all_subvolumes():
 def test_get_subvolumes_without_patterns_collects_all_subvolumes():

+ 358 - 137
tests/unit/hooks/data_source/test_lvm.py

@@ -1,7 +1,7 @@
 import pytest
 import pytest
 from flexmock import flexmock
 from flexmock import flexmock
 
 
-from borgmatic.borg.pattern import Pattern, Pattern_style, Pattern_type
+from borgmatic.borg.pattern import Pattern, Pattern_source, Pattern_style, Pattern_type
 from borgmatic.hooks.data_source import lvm as module
 from borgmatic.hooks.data_source import lvm as module
 
 
 
 
@@ -37,14 +37,20 @@ def test_get_logical_volumes_filters_by_patterns():
         }
         }
         '''
         '''
     )
     )
-    contained = {Pattern('/mnt/lvolume'), Pattern('/mnt/lvolume/subdir')}
+    contained = {
+        Pattern('/mnt/lvolume', source=Pattern_source.CONFIG),
+        Pattern('/mnt/lvolume/subdir', source=Pattern_source.CONFIG),
+    }
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
         'get_contained_patterns'
         'get_contained_patterns'
     ).with_args(None, contained).never()
     ).with_args(None, contained).never()
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
         'get_contained_patterns'
         'get_contained_patterns'
     ).with_args('/mnt/lvolume', contained).and_return(
     ).with_args('/mnt/lvolume', contained).and_return(
-        (Pattern('/mnt/lvolume'), Pattern('/mnt/lvolume/subdir'))
+        (
+            Pattern('/mnt/lvolume', source=Pattern_source.CONFIG),
+            Pattern('/mnt/lvolume/subdir', source=Pattern_source.CONFIG),
+        )
     )
     )
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
         'get_contained_patterns'
         'get_contained_patterns'
@@ -54,17 +60,116 @@ def test_get_logical_volumes_filters_by_patterns():
     ).with_args('/mnt/notlvm', contained).never()
     ).with_args('/mnt/notlvm', contained).never()
 
 
     assert module.get_logical_volumes(
     assert module.get_logical_volumes(
-        'lsblk', patterns=(Pattern('/mnt/lvolume'), Pattern('/mnt/lvolume/subdir'))
+        'lsblk',
+        patterns=(
+            Pattern('/mnt/lvolume', source=Pattern_source.CONFIG),
+            Pattern('/mnt/lvolume/subdir', source=Pattern_source.CONFIG),
+        ),
     ) == (
     ) == (
         module.Logical_volume(
         module.Logical_volume(
             name='vgroup-lvolume',
             name='vgroup-lvolume',
             device_path='/dev/mapper/vgroup-lvolume',
             device_path='/dev/mapper/vgroup-lvolume',
             mount_point='/mnt/lvolume',
             mount_point='/mnt/lvolume',
-            contained_patterns=(Pattern('/mnt/lvolume'), Pattern('/mnt/lvolume/subdir')),
+            contained_patterns=(
+                Pattern('/mnt/lvolume', source=Pattern_source.CONFIG),
+                Pattern('/mnt/lvolume/subdir', source=Pattern_source.CONFIG),
+            ),
         ),
         ),
     )
     )
 
 
 
 
+def test_get_logical_volumes_skips_non_root_patterns():
+    flexmock(module.borgmatic.execute).should_receive(
+        'execute_command_and_capture_output'
+    ).and_return(
+        '''
+        {
+            "blockdevices": [
+                {
+                   "name": "vgroup-lvolume",
+                   "path": "/dev/mapper/vgroup-lvolume",
+                   "mountpoint": "/mnt/lvolume",
+                   "type": "lvm"
+                }
+            ]
+        }
+        '''
+    )
+    contained = {
+        Pattern('/mnt/lvolume', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG),
+        Pattern('/mnt/lvolume/subdir', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG),
+    }
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args(None, contained).never()
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/mnt/lvolume', contained).and_return(
+        (
+            Pattern('/mnt/lvolume', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG),
+            Pattern('/mnt/lvolume/subdir', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG),
+        )
+    )
+
+    assert (
+        module.get_logical_volumes(
+            'lsblk',
+            patterns=(
+                Pattern('/mnt/lvolume', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG),
+                Pattern(
+                    '/mnt/lvolume/subdir', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG
+                ),
+            ),
+        )
+        == ()
+    )
+
+
+def test_get_logical_volumes_skips_non_config_patterns():
+    flexmock(module.borgmatic.execute).should_receive(
+        'execute_command_and_capture_output'
+    ).and_return(
+        '''
+        {
+            "blockdevices": [
+                {
+                   "name": "vgroup-lvolume",
+                   "path": "/dev/mapper/vgroup-lvolume",
+                   "mountpoint": "/mnt/lvolume",
+                   "type": "lvm"
+                }
+            ]
+        }
+        '''
+    )
+    contained = {
+        Pattern('/mnt/lvolume', source=Pattern_source.HOOK),
+        Pattern('/mnt/lvolume/subdir', source=Pattern_source.HOOK),
+    }
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args(None, contained).never()
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/mnt/lvolume', contained).and_return(
+        (
+            Pattern('/mnt/lvolume', source=Pattern_source.HOOK),
+            Pattern('/mnt/lvolume/subdir', source=Pattern_source.HOOK),
+        )
+    )
+
+    assert (
+        module.get_logical_volumes(
+            'lsblk',
+            patterns=(
+                Pattern('/mnt/lvolume', source=Pattern_source.HOOK),
+                Pattern('/mnt/lvolume/subdir', source=Pattern_source.HOOK),
+            ),
+        )
+        == ()
+    )
+
+
 def test_get_logical_volumes_with_invalid_lsblk_json_errors():
 def test_get_logical_volumes_with_invalid_lsblk_json_errors():
     flexmock(module.borgmatic.execute).should_receive(
     flexmock(module.borgmatic.execute).should_receive(
         'execute_command_and_capture_output'
         'execute_command_and_capture_output'
@@ -138,13 +243,13 @@ def test_snapshot_logical_volume_with_non_percentage_snapshot_name_uses_lvcreate
     (
     (
         (
         (
             Pattern('/foo/bar/baz'),
             Pattern('/foo/bar/baz'),
-            Pattern('/run/borgmatic/lvm_snapshots/./foo/bar/baz'),
+            Pattern('/run/borgmatic/lvm_snapshots/b33f/./foo/bar/baz'),
         ),
         ),
-        (Pattern('/foo/bar'), Pattern('/run/borgmatic/lvm_snapshots/./foo/bar')),
+        (Pattern('/foo/bar'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./foo/bar')),
         (
         (
             Pattern('^/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
             Pattern('^/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
             Pattern(
             Pattern(
-                '^/run/borgmatic/lvm_snapshots/./foo/bar',
+                '^/run/borgmatic/lvm_snapshots/b33f/./foo/bar',
                 Pattern_type.INCLUDE,
                 Pattern_type.INCLUDE,
                 Pattern_style.REGULAR_EXPRESSION,
                 Pattern_style.REGULAR_EXPRESSION,
             ),
             ),
@@ -152,40 +257,48 @@ def test_snapshot_logical_volume_with_non_percentage_snapshot_name_uses_lvcreate
         (
         (
             Pattern('/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
             Pattern('/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
             Pattern(
             Pattern(
-                '/run/borgmatic/lvm_snapshots/./foo/bar',
+                '/run/borgmatic/lvm_snapshots/b33f/./foo/bar',
                 Pattern_type.INCLUDE,
                 Pattern_type.INCLUDE,
                 Pattern_style.REGULAR_EXPRESSION,
                 Pattern_style.REGULAR_EXPRESSION,
             ),
             ),
         ),
         ),
-        (Pattern('/foo'), Pattern('/run/borgmatic/lvm_snapshots/./foo')),
-        (Pattern('/'), Pattern('/run/borgmatic/lvm_snapshots/./')),
+        (Pattern('/foo'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./foo')),
+        (Pattern('/'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./')),
     ),
     ),
 )
 )
 def test_make_borg_snapshot_pattern_includes_slashdot_hack_and_stripped_pattern_path(
 def test_make_borg_snapshot_pattern_includes_slashdot_hack_and_stripped_pattern_path(
     pattern, expected_pattern
     pattern, expected_pattern
 ):
 ):
-    assert module.make_borg_snapshot_pattern(pattern, '/run/borgmatic') == expected_pattern
+    flexmock(module.hashlib).should_receive('shake_256').and_return(
+        flexmock(hexdigest=lambda length: 'b33f')
+    )
+
+    assert (
+        module.make_borg_snapshot_pattern(
+            pattern, flexmock(mount_point='/something'), '/run/borgmatic'
+        )
+        == expected_pattern
+    )
 
 
 
 
 def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns():
 def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns():
     config = {'lvm': {}}
     config = {'lvm': {}}
     patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')]
     patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')]
-    flexmock(module).should_receive('get_logical_volumes').and_return(
-        (
-            module.Logical_volume(
-                name='lvolume1',
-                device_path='/dev/lvolume1',
-                mount_point='/mnt/lvolume1',
-                contained_patterns=(Pattern('/mnt/lvolume1/subdir'),),
-            ),
-            module.Logical_volume(
-                name='lvolume2',
-                device_path='/dev/lvolume2',
-                mount_point='/mnt/lvolume2',
-                contained_patterns=(Pattern('/mnt/lvolume2'),),
-            ),
-        )
+    logical_volumes = (
+        module.Logical_volume(
+            name='lvolume1',
+            device_path='/dev/lvolume1',
+            mount_point='/mnt/lvolume1',
+            contained_patterns=(Pattern('/mnt/lvolume1/subdir'),),
+        ),
+        module.Logical_volume(
+            name='lvolume2',
+            device_path='/dev/lvolume2',
+            mount_point='/mnt/lvolume2',
+            contained_patterns=(Pattern('/mnt/lvolume2'),),
+        ),
     )
     )
+    flexmock(module).should_receive('get_logical_volumes').and_return(logical_volumes)
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module).should_receive('snapshot_logical_volume').with_args(
     flexmock(module).should_receive('snapshot_logical_volume').with_args(
         'lvcreate', 'lvolume1_borgmatic-1234', '/dev/lvolume1', module.DEFAULT_SNAPSHOT_SIZE
         'lvcreate', 'lvolume1_borgmatic-1234', '/dev/lvolume1', module.DEFAULT_SNAPSHOT_SIZE
@@ -203,18 +316,21 @@ def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns():
     ).and_return(
     ).and_return(
         (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),)
         (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),)
     )
     )
+    flexmock(module.hashlib).should_receive('shake_256').and_return(
+        flexmock(hexdigest=lambda length: 'b33f')
+    )
     flexmock(module).should_receive('mount_snapshot').with_args(
     flexmock(module).should_receive('mount_snapshot').with_args(
-        'mount', '/dev/lvolume1_snap', '/run/borgmatic/lvm_snapshots/mnt/lvolume1'
+        'mount', '/dev/lvolume1_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1'
     ).once()
     ).once()
     flexmock(module).should_receive('mount_snapshot').with_args(
     flexmock(module).should_receive('mount_snapshot').with_args(
-        'mount', '/dev/lvolume2_snap', '/run/borgmatic/lvm_snapshots/mnt/lvolume2'
+        'mount', '/dev/lvolume2_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2'
     ).once()
     ).once()
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
-        Pattern('/mnt/lvolume1/subdir'), '/run/borgmatic'
-    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume1/subdir'))
+        Pattern('/mnt/lvolume1/subdir'), logical_volumes[0], '/run/borgmatic'
+    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'))
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
-        Pattern('/mnt/lvolume2'), '/run/borgmatic'
-    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume2'))
+        Pattern('/mnt/lvolume2'), logical_volumes[1], '/run/borgmatic'
+    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -229,8 +345,8 @@ def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns():
     )
     )
 
 
     assert patterns == [
     assert patterns == [
-        Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume1/subdir'),
-        Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume2'),
+        Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'),
+        Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'),
     ]
     ]
 
 
 
 
@@ -259,22 +375,21 @@ def test_dump_data_sources_with_no_logical_volumes_skips_snapshots():
 def test_dump_data_sources_uses_snapshot_size_for_snapshot():
 def test_dump_data_sources_uses_snapshot_size_for_snapshot():
     config = {'lvm': {'snapshot_size': '1000PB'}}
     config = {'lvm': {'snapshot_size': '1000PB'}}
     patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')]
     patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')]
-    flexmock(module).should_receive('get_logical_volumes').and_return(
-        (
-            module.Logical_volume(
-                name='lvolume1',
-                device_path='/dev/lvolume1',
-                mount_point='/mnt/lvolume1',
-                contained_patterns=(Pattern('/mnt/lvolume1/subdir'),),
-            ),
-            module.Logical_volume(
-                name='lvolume2',
-                device_path='/dev/lvolume2',
-                mount_point='/mnt/lvolume2',
-                contained_patterns=(Pattern('/mnt/lvolume2'),),
-            ),
-        )
+    logical_volumes = (
+        module.Logical_volume(
+            name='lvolume1',
+            device_path='/dev/lvolume1',
+            mount_point='/mnt/lvolume1',
+            contained_patterns=(Pattern('/mnt/lvolume1/subdir'),),
+        ),
+        module.Logical_volume(
+            name='lvolume2',
+            device_path='/dev/lvolume2',
+            mount_point='/mnt/lvolume2',
+            contained_patterns=(Pattern('/mnt/lvolume2'),),
+        ),
     )
     )
+    flexmock(module).should_receive('get_logical_volumes').and_return(logical_volumes)
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module).should_receive('snapshot_logical_volume').with_args(
     flexmock(module).should_receive('snapshot_logical_volume').with_args(
         'lvcreate',
         'lvcreate',
@@ -298,18 +413,21 @@ def test_dump_data_sources_uses_snapshot_size_for_snapshot():
     ).and_return(
     ).and_return(
         (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),)
         (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),)
     )
     )
+    flexmock(module.hashlib).should_receive('shake_256').and_return(
+        flexmock(hexdigest=lambda length: 'b33f')
+    )
     flexmock(module).should_receive('mount_snapshot').with_args(
     flexmock(module).should_receive('mount_snapshot').with_args(
-        'mount', '/dev/lvolume1_snap', '/run/borgmatic/lvm_snapshots/mnt/lvolume1'
+        'mount', '/dev/lvolume1_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1'
     ).once()
     ).once()
     flexmock(module).should_receive('mount_snapshot').with_args(
     flexmock(module).should_receive('mount_snapshot').with_args(
-        'mount', '/dev/lvolume2_snap', '/run/borgmatic/lvm_snapshots/mnt/lvolume2'
+        'mount', '/dev/lvolume2_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2'
     ).once()
     ).once()
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
-        Pattern('/mnt/lvolume1/subdir'), '/run/borgmatic'
-    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume1/subdir'))
+        Pattern('/mnt/lvolume1/subdir'), logical_volumes[0], '/run/borgmatic'
+    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'))
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
-        Pattern('/mnt/lvolume2'), '/run/borgmatic'
-    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume2'))
+        Pattern('/mnt/lvolume2'), logical_volumes[1], '/run/borgmatic'
+    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -324,8 +442,8 @@ def test_dump_data_sources_uses_snapshot_size_for_snapshot():
     )
     )
 
 
     assert patterns == [
     assert patterns == [
-        Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume1/subdir'),
-        Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume2'),
+        Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'),
+        Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'),
     ]
     ]
 
 
 
 
@@ -339,22 +457,21 @@ def test_dump_data_sources_uses_custom_commands():
         },
         },
     }
     }
     patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')]
     patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')]
-    flexmock(module).should_receive('get_logical_volumes').and_return(
-        (
-            module.Logical_volume(
-                name='lvolume1',
-                device_path='/dev/lvolume1',
-                mount_point='/mnt/lvolume1',
-                contained_patterns=(Pattern('/mnt/lvolume1/subdir'),),
-            ),
-            module.Logical_volume(
-                name='lvolume2',
-                device_path='/dev/lvolume2',
-                mount_point='/mnt/lvolume2',
-                contained_patterns=(Pattern('/mnt/lvolume2'),),
-            ),
-        )
+    logical_volumes = (
+        module.Logical_volume(
+            name='lvolume1',
+            device_path='/dev/lvolume1',
+            mount_point='/mnt/lvolume1',
+            contained_patterns=(Pattern('/mnt/lvolume1/subdir'),),
+        ),
+        module.Logical_volume(
+            name='lvolume2',
+            device_path='/dev/lvolume2',
+            mount_point='/mnt/lvolume2',
+            contained_patterns=(Pattern('/mnt/lvolume2'),),
+        ),
     )
     )
+    flexmock(module).should_receive('get_logical_volumes').and_return(logical_volumes)
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module).should_receive('snapshot_logical_volume').with_args(
     flexmock(module).should_receive('snapshot_logical_volume').with_args(
         '/usr/local/bin/lvcreate',
         '/usr/local/bin/lvcreate',
@@ -378,18 +495,25 @@ def test_dump_data_sources_uses_custom_commands():
     ).and_return(
     ).and_return(
         (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),)
         (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),)
     )
     )
+    flexmock(module.hashlib).should_receive('shake_256').and_return(
+        flexmock(hexdigest=lambda length: 'b33f')
+    )
     flexmock(module).should_receive('mount_snapshot').with_args(
     flexmock(module).should_receive('mount_snapshot').with_args(
-        '/usr/local/bin/mount', '/dev/lvolume1_snap', '/run/borgmatic/lvm_snapshots/mnt/lvolume1'
+        '/usr/local/bin/mount',
+        '/dev/lvolume1_snap',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1',
     ).once()
     ).once()
     flexmock(module).should_receive('mount_snapshot').with_args(
     flexmock(module).should_receive('mount_snapshot').with_args(
-        '/usr/local/bin/mount', '/dev/lvolume2_snap', '/run/borgmatic/lvm_snapshots/mnt/lvolume2'
+        '/usr/local/bin/mount',
+        '/dev/lvolume2_snap',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2',
     ).once()
     ).once()
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
-        Pattern('/mnt/lvolume1/subdir'), '/run/borgmatic'
-    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume1/subdir'))
+        Pattern('/mnt/lvolume1/subdir'), logical_volumes[0], '/run/borgmatic'
+    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'))
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
-        Pattern('/mnt/lvolume2'), '/run/borgmatic'
-    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume2'))
+        Pattern('/mnt/lvolume2'), logical_volumes[1], '/run/borgmatic'
+    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -404,8 +528,8 @@ def test_dump_data_sources_uses_custom_commands():
     )
     )
 
 
     assert patterns == [
     assert patterns == [
-        Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume1/subdir'),
-        Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume2'),
+        Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'),
+        Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'),
     ]
     ]
 
 
 
 
@@ -463,22 +587,21 @@ def test_dump_data_sources_with_dry_run_skips_snapshots_and_does_not_touch_patte
 def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained_patterns():
 def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained_patterns():
     config = {'lvm': {}}
     config = {'lvm': {}}
     patterns = [Pattern('/hmm')]
     patterns = [Pattern('/hmm')]
-    flexmock(module).should_receive('get_logical_volumes').and_return(
-        (
-            module.Logical_volume(
-                name='lvolume1',
-                device_path='/dev/lvolume1',
-                mount_point='/mnt/lvolume1',
-                contained_patterns=(Pattern('/mnt/lvolume1/subdir'),),
-            ),
-            module.Logical_volume(
-                name='lvolume2',
-                device_path='/dev/lvolume2',
-                mount_point='/mnt/lvolume2',
-                contained_patterns=(Pattern('/mnt/lvolume2'),),
-            ),
-        )
+    logical_volumes = (
+        module.Logical_volume(
+            name='lvolume1',
+            device_path='/dev/lvolume1',
+            mount_point='/mnt/lvolume1',
+            contained_patterns=(Pattern('/mnt/lvolume1/subdir'),),
+        ),
+        module.Logical_volume(
+            name='lvolume2',
+            device_path='/dev/lvolume2',
+            mount_point='/mnt/lvolume2',
+            contained_patterns=(Pattern('/mnt/lvolume2'),),
+        ),
     )
     )
+    flexmock(module).should_receive('get_logical_volumes').and_return(logical_volumes)
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module).should_receive('snapshot_logical_volume').with_args(
     flexmock(module).should_receive('snapshot_logical_volume').with_args(
         'lvcreate', 'lvolume1_borgmatic-1234', '/dev/lvolume1', module.DEFAULT_SNAPSHOT_SIZE
         'lvcreate', 'lvolume1_borgmatic-1234', '/dev/lvolume1', module.DEFAULT_SNAPSHOT_SIZE
@@ -496,18 +619,21 @@ def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained
     ).and_return(
     ).and_return(
         (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),)
         (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),)
     )
     )
+    flexmock(module.hashlib).should_receive('shake_256').and_return(
+        flexmock(hexdigest=lambda length: 'b33f')
+    )
     flexmock(module).should_receive('mount_snapshot').with_args(
     flexmock(module).should_receive('mount_snapshot').with_args(
-        'mount', '/dev/lvolume1_snap', '/run/borgmatic/lvm_snapshots/mnt/lvolume1'
+        'mount', '/dev/lvolume1_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1'
     ).once()
     ).once()
     flexmock(module).should_receive('mount_snapshot').with_args(
     flexmock(module).should_receive('mount_snapshot').with_args(
-        'mount', '/dev/lvolume2_snap', '/run/borgmatic/lvm_snapshots/mnt/lvolume2'
+        'mount', '/dev/lvolume2_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2'
     ).once()
     ).once()
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
-        Pattern('/mnt/lvolume1/subdir'), '/run/borgmatic'
-    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume1/subdir'))
+        Pattern('/mnt/lvolume1/subdir'), logical_volumes[0], '/run/borgmatic'
+    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'))
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
-        Pattern('/mnt/lvolume2'), '/run/borgmatic'
-    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume2'))
+        Pattern('/mnt/lvolume2'), logical_volumes[1], '/run/borgmatic'
+    ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -523,8 +649,8 @@ def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained
 
 
     assert patterns == [
     assert patterns == [
         Pattern('/hmm'),
         Pattern('/hmm'),
-        Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume1/subdir'),
-        Pattern('/run/borgmatic/lvm_snapshots/./mnt/lvolume2'),
+        Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'),
+        Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'),
     ]
     ]
 
 
 
 
@@ -694,16 +820,19 @@ def test_remove_data_source_dumps_unmounts_and_remove_snapshots():
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').and_return(True)
     flexmock(module.os.path).should_receive('isdir').and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume1',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1',
     ).once()
     ).once()
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume2',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2',
     ).once()
     ).once()
     flexmock(module).should_receive('get_snapshots').and_return(
     flexmock(module).should_receive('get_snapshots').and_return(
         (
         (
@@ -799,9 +928,11 @@ def test_remove_data_source_dumps_with_missing_snapshot_directory_skips_unmount(
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').with_args(
     flexmock(module.os.path).should_receive('isdir').with_args(
-        '/run/borgmatic/lvm_snapshots'
+        '/run/borgmatic/lvm_snapshots/b33f'
     ).and_return(False)
     ).and_return(False)
     flexmock(module.shutil).should_receive('rmtree').never()
     flexmock(module.shutil).should_receive('rmtree').never()
     flexmock(module).should_receive('unmount_snapshot').never()
     flexmock(module).should_receive('unmount_snapshot').never()
@@ -843,24 +974,92 @@ def test_remove_data_source_dumps_with_missing_snapshot_mount_path_skips_unmount
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').with_args(
     flexmock(module.os.path).should_receive('isdir').with_args(
-        '/run/borgmatic/lvm_snapshots'
+        '/run/borgmatic/lvm_snapshots/b33f'
     ).and_return(True)
     ).and_return(True)
     flexmock(module.os.path).should_receive('isdir').with_args(
     flexmock(module.os.path).should_receive('isdir').with_args(
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume1'
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1'
     ).and_return(False)
     ).and_return(False)
     flexmock(module.os.path).should_receive('isdir').with_args(
     flexmock(module.os.path).should_receive('isdir').with_args(
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume2'
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2'
+    ).and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
+    flexmock(module.shutil).should_receive('rmtree')
+    flexmock(module).should_receive('unmount_snapshot').with_args(
+        'umount',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1',
+    ).never()
+    flexmock(module).should_receive('unmount_snapshot').with_args(
+        'umount',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2',
+    ).once()
+    flexmock(module).should_receive('get_snapshots').and_return(
+        (
+            module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'),
+            module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'),
+        ),
+    )
+    flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once()
+    flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once()
+
+    module.remove_data_source_dumps(
+        hook_config=config['lvm'],
+        config=config,
+        borgmatic_runtime_directory='/run/borgmatic',
+        dry_run=False,
+    )
+
+
+def test_remove_data_source_dumps_with_empty_snapshot_mount_path_skips_unmount():
+    config = {'lvm': {}}
+    flexmock(module).should_receive('get_logical_volumes').and_return(
+        (
+            module.Logical_volume(
+                name='lvolume1',
+                device_path='/dev/lvolume1',
+                mount_point='/mnt/lvolume1',
+                contained_patterns=(Pattern('/mnt/lvolume1/subdir'),),
+            ),
+            module.Logical_volume(
+                name='lvolume2',
+                device_path='/dev/lvolume2',
+                mount_point='/mnt/lvolume2',
+                contained_patterns=(Pattern('/mnt/lvolume2'),),
+            ),
+        )
+    )
+    flexmock(module.borgmatic.config.paths).should_receive(
+        'replace_temporary_subdirectory_with_glob'
+    ).and_return('/run/borgmatic')
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
+    flexmock(module.os.path).should_receive('isdir').with_args(
+        '/run/borgmatic/lvm_snapshots/b33f'
+    ).and_return(True)
+    flexmock(module.os.path).should_receive('isdir').with_args(
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1'
+    ).and_return(True)
+    flexmock(module.os).should_receive('listdir').with_args(
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1'
+    ).and_return([])
+    flexmock(module.os.path).should_receive('isdir').with_args(
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2'
     ).and_return(True)
     ).and_return(True)
+    flexmock(module.os).should_receive('listdir').with_args(
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2'
+    ).and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume1',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1',
     ).never()
     ).never()
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume2',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2',
     ).once()
     ).once()
     flexmock(module).should_receive('get_snapshots').and_return(
     flexmock(module).should_receive('get_snapshots').and_return(
         (
         (
@@ -900,24 +1099,27 @@ def test_remove_data_source_dumps_with_successful_mount_point_removal_skips_unmo
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').with_args(
     flexmock(module.os.path).should_receive('isdir').with_args(
-        '/run/borgmatic/lvm_snapshots'
+        '/run/borgmatic/lvm_snapshots/b33f'
     ).and_return(True)
     ).and_return(True)
     flexmock(module.os.path).should_receive('isdir').with_args(
     flexmock(module.os.path).should_receive('isdir').with_args(
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume1'
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1'
     ).and_return(True).and_return(False)
     ).and_return(True).and_return(False)
     flexmock(module.os.path).should_receive('isdir').with_args(
     flexmock(module.os.path).should_receive('isdir').with_args(
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume2'
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2'
     ).and_return(True).and_return(True)
     ).and_return(True).and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume1',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1',
     ).never()
     ).never()
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume2',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2',
     ).once()
     ).once()
     flexmock(module).should_receive('get_snapshots').and_return(
     flexmock(module).should_receive('get_snapshots').and_return(
         (
         (
@@ -957,16 +1159,19 @@ def test_remove_data_source_dumps_bails_for_missing_umount_command():
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').and_return(True)
     flexmock(module.os.path).should_receive('isdir').and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume1',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1',
     ).and_raise(FileNotFoundError)
     ).and_raise(FileNotFoundError)
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume2',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2',
     ).never()
     ).never()
     flexmock(module).should_receive('get_snapshots').never()
     flexmock(module).should_receive('get_snapshots').never()
     flexmock(module).should_receive('remove_snapshot').never()
     flexmock(module).should_receive('remove_snapshot').never()
@@ -979,7 +1184,7 @@ def test_remove_data_source_dumps_bails_for_missing_umount_command():
     )
     )
 
 
 
 
-def test_remove_data_source_dumps_bails_for_umount_command_error():
+def test_remove_data_source_dumps_swallows_umount_command_error():
     config = {'lvm': {}}
     config = {'lvm': {}}
     flexmock(module).should_receive('get_logical_volumes').and_return(
     flexmock(module).should_receive('get_logical_volumes').and_return(
         (
         (
@@ -1000,19 +1205,28 @@ def test_remove_data_source_dumps_bails_for_umount_command_error():
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').and_return(True)
     flexmock(module.os.path).should_receive('isdir').and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume1',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1',
     ).and_raise(module.subprocess.CalledProcessError(1, 'wtf'))
     ).and_raise(module.subprocess.CalledProcessError(1, 'wtf'))
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume2',
-    ).never()
-    flexmock(module).should_receive('get_snapshots').never()
-    flexmock(module).should_receive('remove_snapshot').never()
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2',
+    ).once()
+    flexmock(module).should_receive('get_snapshots').and_return(
+        (
+            module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'),
+            module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'),
+        ),
+    )
+    flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once()
+    flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once()
 
 
     module.remove_data_source_dumps(
     module.remove_data_source_dumps(
         hook_config=config['lvm'],
         hook_config=config['lvm'],
@@ -1043,16 +1257,19 @@ def test_remove_data_source_dumps_bails_for_missing_lvs_command():
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').and_return(True)
     flexmock(module.os.path).should_receive('isdir').and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume1',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1',
     ).once()
     ).once()
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume2',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2',
     ).once()
     ).once()
     flexmock(module).should_receive('get_snapshots').and_raise(FileNotFoundError)
     flexmock(module).should_receive('get_snapshots').and_raise(FileNotFoundError)
     flexmock(module).should_receive('remove_snapshot').never()
     flexmock(module).should_receive('remove_snapshot').never()
@@ -1086,16 +1303,19 @@ def test_remove_data_source_dumps_bails_for_lvs_command_error():
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').and_return(True)
     flexmock(module.os.path).should_receive('isdir').and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume1',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1',
     ).once()
     ).once()
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
         'umount',
         'umount',
-        '/run/borgmatic/lvm_snapshots/mnt/lvolume2',
+        '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2',
     ).once()
     ).once()
     flexmock(module).should_receive('get_snapshots').and_raise(
     flexmock(module).should_receive('get_snapshots').and_raise(
         module.subprocess.CalledProcessError(1, 'wtf')
         module.subprocess.CalledProcessError(1, 'wtf')
@@ -1133,6 +1353,7 @@ def test_remove_data_source_with_dry_run_skips_snapshot_unmount_and_delete():
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
     flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
     flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
     flexmock(module.os.path).should_receive('isdir').and_return(True)
     flexmock(module.os.path).should_receive('isdir').and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree').never()
     flexmock(module.shutil).should_receive('rmtree').never()
     flexmock(module).should_receive('unmount_snapshot').never()
     flexmock(module).should_receive('unmount_snapshot').never()
     flexmock(module).should_receive('get_snapshots').and_return(
     flexmock(module).should_receive('get_snapshots').and_return(

+ 286 - 69
tests/unit/hooks/data_source/test_zfs.py

@@ -3,7 +3,7 @@ import os
 import pytest
 import pytest
 from flexmock import flexmock
 from flexmock import flexmock
 
 
-from borgmatic.borg.pattern import Pattern, Pattern_style, Pattern_type
+from borgmatic.borg.pattern import Pattern, Pattern_source, Pattern_style, Pattern_type
 from borgmatic.hooks.data_source import zfs as module
 from borgmatic.hooks.data_source import zfs as module
 
 
 
 
@@ -11,11 +11,19 @@ def test_get_datasets_to_backup_filters_datasets_by_patterns():
     flexmock(module.borgmatic.execute).should_receive(
     flexmock(module.borgmatic.execute).should_receive(
         'execute_command_and_capture_output'
         'execute_command_and_capture_output'
     ).and_return(
     ).and_return(
-        'dataset\t/dataset\t-\nother\t/other\t-',
+        'dataset\t/dataset\ton\t-\nother\t/other\ton\t-',
     )
     )
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
         'get_contained_patterns'
         'get_contained_patterns'
-    ).with_args('/dataset', object).and_return((Pattern('/dataset'),))
+    ).with_args('/dataset', object).and_return(
+        (
+            Pattern(
+                '/dataset',
+                Pattern_type.ROOT,
+                source=Pattern_source.CONFIG,
+            ),
+        )
+    )
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
         'get_contained_patterns'
         'get_contained_patterns'
     ).with_args('/other', object).and_return(())
     ).with_args('/other', object).and_return(())
@@ -23,24 +31,134 @@ def test_get_datasets_to_backup_filters_datasets_by_patterns():
     assert module.get_datasets_to_backup(
     assert module.get_datasets_to_backup(
         'zfs',
         'zfs',
         patterns=(
         patterns=(
-            Pattern('/foo'),
-            Pattern('/dataset'),
-            Pattern('/bar'),
+            Pattern(
+                '/foo',
+                Pattern_type.ROOT,
+                source=Pattern_source.CONFIG,
+            ),
+            Pattern(
+                '/dataset',
+                Pattern_type.ROOT,
+                source=Pattern_source.CONFIG,
+            ),
+            Pattern(
+                '/bar',
+                Pattern_type.ROOT,
+                source=Pattern_source.CONFIG,
+            ),
         ),
         ),
     ) == (
     ) == (
         module.Dataset(
         module.Dataset(
             name='dataset',
             name='dataset',
             mount_point='/dataset',
             mount_point='/dataset',
-            contained_patterns=(Pattern('/dataset'),),
+            contained_patterns=(
+                Pattern(
+                    '/dataset',
+                    Pattern_type.ROOT,
+                    source=Pattern_source.CONFIG,
+                ),
+            ),
         ),
         ),
     )
     )
 
 
 
 
+def test_get_datasets_to_backup_skips_non_root_patterns():
+    flexmock(module.borgmatic.execute).should_receive(
+        'execute_command_and_capture_output'
+    ).and_return(
+        'dataset\t/dataset\ton\t-\nother\t/other\ton\t-',
+    )
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/dataset', object).and_return(
+        (
+            Pattern(
+                '/dataset',
+                Pattern_type.EXCLUDE,
+                source=Pattern_source.CONFIG,
+            ),
+        )
+    )
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/other', object).and_return(())
+
+    assert (
+        module.get_datasets_to_backup(
+            'zfs',
+            patterns=(
+                Pattern(
+                    '/foo',
+                    Pattern_type.ROOT,
+                    source=Pattern_source.CONFIG,
+                ),
+                Pattern(
+                    '/dataset',
+                    Pattern_type.EXCLUDE,
+                    source=Pattern_source.CONFIG,
+                ),
+                Pattern(
+                    '/bar',
+                    Pattern_type.ROOT,
+                    source=Pattern_source.CONFIG,
+                ),
+            ),
+        )
+        == ()
+    )
+
+
+def test_get_datasets_to_backup_skips_non_config_patterns():
+    flexmock(module.borgmatic.execute).should_receive(
+        'execute_command_and_capture_output'
+    ).and_return(
+        'dataset\t/dataset\ton\t-\nother\t/other\ton\t-',
+    )
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/dataset', object).and_return(
+        (
+            Pattern(
+                '/dataset',
+                Pattern_type.ROOT,
+                source=Pattern_source.HOOK,
+            ),
+        )
+    )
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/other', object).and_return(())
+
+    assert (
+        module.get_datasets_to_backup(
+            'zfs',
+            patterns=(
+                Pattern(
+                    '/foo',
+                    Pattern_type.ROOT,
+                    source=Pattern_source.CONFIG,
+                ),
+                Pattern(
+                    '/dataset',
+                    Pattern_type.ROOT,
+                    source=Pattern_source.HOOK,
+                ),
+                Pattern(
+                    '/bar',
+                    Pattern_type.ROOT,
+                    source=Pattern_source.CONFIG,
+                ),
+            ),
+        )
+        == ()
+    )
+
+
 def test_get_datasets_to_backup_filters_datasets_by_user_property():
 def test_get_datasets_to_backup_filters_datasets_by_user_property():
     flexmock(module.borgmatic.execute).should_receive(
     flexmock(module.borgmatic.execute).should_receive(
         'execute_command_and_capture_output'
         'execute_command_and_capture_output'
     ).and_return(
     ).and_return(
-        'dataset\t/dataset\tauto\nother\t/other\t-',
+        'dataset\t/dataset\ton\tauto\nother\t/other\ton\t-',
     )
     )
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
     flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
         'get_contained_patterns'
         'get_contained_patterns'
@@ -49,16 +167,45 @@ def test_get_datasets_to_backup_filters_datasets_by_user_property():
         'get_contained_patterns'
         'get_contained_patterns'
     ).with_args('/other', object).and_return(())
     ).with_args('/other', object).and_return(())
 
 
-    assert module.get_datasets_to_backup('zfs', patterns=(Pattern('/foo'), Pattern('/bar'))) == (
+    assert module.get_datasets_to_backup(
+        'zfs',
+        patterns=(Pattern('/foo'), Pattern('/bar')),
+    ) == (
         module.Dataset(
         module.Dataset(
             name='dataset',
             name='dataset',
             mount_point='/dataset',
             mount_point='/dataset',
             auto_backup=True,
             auto_backup=True,
-            contained_patterns=(Pattern('/dataset'),),
+            contained_patterns=(Pattern('/dataset', source=Pattern_source.HOOK),),
         ),
         ),
     )
     )
 
 
 
 
+def test_get_datasets_to_backup_filters_datasets_by_canmount_property():
+    flexmock(module.borgmatic.execute).should_receive(
+        'execute_command_and_capture_output'
+    ).and_return(
+        'dataset\t/dataset\toff\t-\nother\t/other\ton\t-',
+    )
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/dataset', object).and_return((Pattern('/dataset'),))
+    flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
+        'get_contained_patterns'
+    ).with_args('/other', object).and_return(())
+
+    assert (
+        module.get_datasets_to_backup(
+            'zfs',
+            patterns=(
+                Pattern('/foo'),
+                Pattern('/dataset'),
+                Pattern('/bar'),
+            ),
+        )
+        == ()
+    )
+
+
 def test_get_datasets_to_backup_with_invalid_list_output_raises():
 def test_get_datasets_to_backup_with_invalid_list_output_raises():
     flexmock(module.borgmatic.execute).should_receive(
     flexmock(module.borgmatic.execute).should_receive(
         'execute_command_and_capture_output'
         'execute_command_and_capture_output'
@@ -94,13 +241,13 @@ def test_get_all_dataset_mount_points_does_not_filter_datasets():
     (
     (
         (
         (
             Pattern('/foo/bar/baz'),
             Pattern('/foo/bar/baz'),
-            Pattern('/run/borgmatic/zfs_snapshots/./foo/bar/baz'),
+            Pattern('/run/borgmatic/zfs_snapshots/b33f/./foo/bar/baz'),
         ),
         ),
-        (Pattern('/foo/bar'), Pattern('/run/borgmatic/zfs_snapshots/./foo/bar')),
+        (Pattern('/foo/bar'), Pattern('/run/borgmatic/zfs_snapshots/b33f/./foo/bar')),
         (
         (
             Pattern('^/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
             Pattern('^/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
             Pattern(
             Pattern(
-                '^/run/borgmatic/zfs_snapshots/./foo/bar',
+                '^/run/borgmatic/zfs_snapshots/b33f/./foo/bar',
                 Pattern_type.INCLUDE,
                 Pattern_type.INCLUDE,
                 Pattern_style.REGULAR_EXPRESSION,
                 Pattern_style.REGULAR_EXPRESSION,
             ),
             ),
@@ -108,46 +255,55 @@ def test_get_all_dataset_mount_points_does_not_filter_datasets():
         (
         (
             Pattern('/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
             Pattern('/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
             Pattern(
             Pattern(
-                '/run/borgmatic/zfs_snapshots/./foo/bar',
+                '/run/borgmatic/zfs_snapshots/b33f/./foo/bar',
                 Pattern_type.INCLUDE,
                 Pattern_type.INCLUDE,
                 Pattern_style.REGULAR_EXPRESSION,
                 Pattern_style.REGULAR_EXPRESSION,
             ),
             ),
         ),
         ),
-        (Pattern('/foo'), Pattern('/run/borgmatic/zfs_snapshots/./foo')),
-        (Pattern('/'), Pattern('/run/borgmatic/zfs_snapshots/./')),
+        (Pattern('/foo'), Pattern('/run/borgmatic/zfs_snapshots/b33f/./foo')),
+        (Pattern('/'), Pattern('/run/borgmatic/zfs_snapshots/b33f/./')),
     ),
     ),
 )
 )
 def test_make_borg_snapshot_pattern_includes_slashdot_hack_and_stripped_pattern_path(
 def test_make_borg_snapshot_pattern_includes_slashdot_hack_and_stripped_pattern_path(
     pattern, expected_pattern
     pattern, expected_pattern
 ):
 ):
-    assert module.make_borg_snapshot_pattern(pattern, '/run/borgmatic') == expected_pattern
+    flexmock(module.hashlib).should_receive('shake_256').and_return(
+        flexmock(hexdigest=lambda length: 'b33f')
+    )
+
+    assert (
+        module.make_borg_snapshot_pattern(
+            pattern, flexmock(mount_point='/something'), '/run/borgmatic'
+        )
+        == expected_pattern
+    )
 
 
 
 
 def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns():
 def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns():
-    flexmock(module).should_receive('get_datasets_to_backup').and_return(
-        (
-            flexmock(
-                name='dataset',
-                mount_point='/mnt/dataset',
-                contained_patterns=(Pattern('/mnt/dataset/subdir'),),
-            )
-        )
+    dataset = flexmock(
+        name='dataset',
+        mount_point='/mnt/dataset',
+        contained_patterns=(Pattern('/mnt/dataset/subdir'),),
     )
     )
+    flexmock(module).should_receive('get_datasets_to_backup').and_return((dataset,))
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module.os).should_receive('getpid').and_return(1234)
     full_snapshot_name = 'dataset@borgmatic-1234'
     full_snapshot_name = 'dataset@borgmatic-1234'
     flexmock(module).should_receive('snapshot_dataset').with_args(
     flexmock(module).should_receive('snapshot_dataset').with_args(
         'zfs',
         'zfs',
         full_snapshot_name,
         full_snapshot_name,
     ).once()
     ).once()
-    snapshot_mount_path = '/run/borgmatic/zfs_snapshots/./mnt/dataset'
+    flexmock(module.hashlib).should_receive('shake_256').and_return(
+        flexmock(hexdigest=lambda length: 'b33f')
+    )
+    snapshot_mount_path = '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset'
     flexmock(module).should_receive('mount_snapshot').with_args(
     flexmock(module).should_receive('mount_snapshot').with_args(
         'mount',
         'mount',
         full_snapshot_name,
         full_snapshot_name,
         module.os.path.normpath(snapshot_mount_path),
         module.os.path.normpath(snapshot_mount_path),
     ).once()
     ).once()
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
-        Pattern('/mnt/dataset/subdir'), '/run/borgmatic'
-    ).and_return(Pattern('/run/borgmatic/zfs_snapshots/./mnt/dataset/subdir'))
+        Pattern('/mnt/dataset/subdir'), dataset, '/run/borgmatic'
+    ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir'))
     patterns = [Pattern('/mnt/dataset/subdir')]
     patterns = [Pattern('/mnt/dataset/subdir')]
 
 
     assert (
     assert (
@@ -188,30 +344,30 @@ def test_dump_data_sources_with_no_datasets_skips_snapshots():
 
 
 
 
 def test_dump_data_sources_uses_custom_commands():
 def test_dump_data_sources_uses_custom_commands():
-    flexmock(module).should_receive('get_datasets_to_backup').and_return(
-        (
-            flexmock(
-                name='dataset',
-                mount_point='/mnt/dataset',
-                contained_patterns=(Pattern('/mnt/dataset/subdir'),),
-            )
-        )
+    dataset = flexmock(
+        name='dataset',
+        mount_point='/mnt/dataset',
+        contained_patterns=(Pattern('/mnt/dataset/subdir'),),
     )
     )
+    flexmock(module).should_receive('get_datasets_to_backup').and_return((dataset,))
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module.os).should_receive('getpid').and_return(1234)
     full_snapshot_name = 'dataset@borgmatic-1234'
     full_snapshot_name = 'dataset@borgmatic-1234'
     flexmock(module).should_receive('snapshot_dataset').with_args(
     flexmock(module).should_receive('snapshot_dataset').with_args(
         '/usr/local/bin/zfs',
         '/usr/local/bin/zfs',
         full_snapshot_name,
         full_snapshot_name,
     ).once()
     ).once()
-    snapshot_mount_path = '/run/borgmatic/zfs_snapshots/./mnt/dataset'
+    flexmock(module.hashlib).should_receive('shake_256').and_return(
+        flexmock(hexdigest=lambda length: 'b33f')
+    )
+    snapshot_mount_path = '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset'
     flexmock(module).should_receive('mount_snapshot').with_args(
     flexmock(module).should_receive('mount_snapshot').with_args(
         '/usr/local/bin/mount',
         '/usr/local/bin/mount',
         full_snapshot_name,
         full_snapshot_name,
         module.os.path.normpath(snapshot_mount_path),
         module.os.path.normpath(snapshot_mount_path),
     ).once()
     ).once()
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
-        Pattern('/mnt/dataset/subdir'), '/run/borgmatic'
-    ).and_return(Pattern('/run/borgmatic/zfs_snapshots/./mnt/dataset/subdir'))
+        Pattern('/mnt/dataset/subdir'), dataset, '/run/borgmatic'
+    ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir'))
     patterns = [Pattern('/mnt/dataset/subdir')]
     patterns = [Pattern('/mnt/dataset/subdir')]
     hook_config = {
     hook_config = {
         'zfs_command': '/usr/local/bin/zfs',
         'zfs_command': '/usr/local/bin/zfs',
@@ -261,30 +417,30 @@ def test_dump_data_sources_with_dry_run_skips_commands_and_does_not_touch_patter
 
 
 
 
 def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained_patterns():
 def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained_patterns():
-    flexmock(module).should_receive('get_datasets_to_backup').and_return(
-        (
-            flexmock(
-                name='dataset',
-                mount_point='/mnt/dataset',
-                contained_patterns=(Pattern('/mnt/dataset/subdir'),),
-            )
-        )
+    dataset = flexmock(
+        name='dataset',
+        mount_point='/mnt/dataset',
+        contained_patterns=(Pattern('/mnt/dataset/subdir'),),
     )
     )
+    flexmock(module).should_receive('get_datasets_to_backup').and_return((dataset,))
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module.os).should_receive('getpid').and_return(1234)
     full_snapshot_name = 'dataset@borgmatic-1234'
     full_snapshot_name = 'dataset@borgmatic-1234'
     flexmock(module).should_receive('snapshot_dataset').with_args(
     flexmock(module).should_receive('snapshot_dataset').with_args(
         'zfs',
         'zfs',
         full_snapshot_name,
         full_snapshot_name,
     ).once()
     ).once()
-    snapshot_mount_path = '/run/borgmatic/zfs_snapshots/./mnt/dataset'
+    flexmock(module.hashlib).should_receive('shake_256').and_return(
+        flexmock(hexdigest=lambda length: 'b33f')
+    )
+    snapshot_mount_path = '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset'
     flexmock(module).should_receive('mount_snapshot').with_args(
     flexmock(module).should_receive('mount_snapshot').with_args(
         'mount',
         'mount',
         full_snapshot_name,
         full_snapshot_name,
         module.os.path.normpath(snapshot_mount_path),
         module.os.path.normpath(snapshot_mount_path),
     ).once()
     ).once()
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
     flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
-        Pattern('/mnt/dataset/subdir'), '/run/borgmatic'
-    ).and_return(Pattern('/run/borgmatic/zfs_snapshots/./mnt/dataset/subdir'))
+        Pattern('/mnt/dataset/subdir'), dataset, '/run/borgmatic'
+    ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir'))
     patterns = [Pattern('/hmm')]
     patterns = [Pattern('/hmm')]
 
 
     assert (
     assert (
@@ -317,11 +473,14 @@ def test_remove_data_source_dumps_unmounts_and_destroys_snapshots():
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').and_return(True)
     flexmock(module.os.path).should_receive('isdir').and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
-        'umount', '/run/borgmatic/zfs_snapshots/mnt/dataset'
+        'umount', '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset'
     ).once()
     ).once()
     flexmock(module).should_receive('get_all_snapshots').and_return(
     flexmock(module).should_receive('get_all_snapshots').and_return(
         ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
         ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
@@ -343,11 +502,14 @@ def test_remove_data_source_dumps_use_custom_commands():
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').and_return(True)
     flexmock(module.os.path).should_receive('isdir').and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
-        '/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/mnt/dataset'
+        '/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset'
     ).once()
     ).once()
     flexmock(module).should_receive('get_all_snapshots').and_return(
     flexmock(module).should_receive('get_all_snapshots').and_return(
         ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
         ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
@@ -416,11 +578,14 @@ def test_remove_data_source_dumps_bails_for_missing_umount_command():
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').and_return(True)
     flexmock(module.os.path).should_receive('isdir').and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
-        '/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/mnt/dataset'
+        '/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset'
     ).and_raise(FileNotFoundError)
     ).and_raise(FileNotFoundError)
     flexmock(module).should_receive('get_all_snapshots').never()
     flexmock(module).should_receive('get_all_snapshots').never()
     flexmock(module).should_receive('destroy_snapshot').never()
     flexmock(module).should_receive('destroy_snapshot').never()
@@ -434,19 +599,26 @@ def test_remove_data_source_dumps_bails_for_missing_umount_command():
     )
     )
 
 
 
 
-def test_remove_data_source_dumps_bails_for_umount_command_error():
+def test_remove_data_source_dumps_swallows_umount_command_error():
     flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
     flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').and_return(True)
     flexmock(module.os.path).should_receive('isdir').and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').with_args(
     flexmock(module).should_receive('unmount_snapshot').with_args(
-        '/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/mnt/dataset'
+        '/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset'
     ).and_raise(module.subprocess.CalledProcessError(1, 'wtf'))
     ).and_raise(module.subprocess.CalledProcessError(1, 'wtf'))
-    flexmock(module).should_receive('get_all_snapshots').never()
-    flexmock(module).should_receive('destroy_snapshot').never()
+    flexmock(module).should_receive('get_all_snapshots').and_return(
+        ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
+    )
+    flexmock(module).should_receive('destroy_snapshot').with_args(
+        '/usr/local/bin/zfs', 'dataset@borgmatic-1234'
+    ).once()
     hook_config = {'zfs_command': '/usr/local/bin/zfs', 'umount_command': '/usr/local/bin/umount'}
     hook_config = {'zfs_command': '/usr/local/bin/zfs', 'umount_command': '/usr/local/bin/umount'}
 
 
     module.remove_data_source_dumps(
     module.remove_data_source_dumps(
@@ -462,7 +634,9 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_directories_that_are_no
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').and_return(False)
     flexmock(module.os.path).should_receive('isdir').and_return(False)
     flexmock(module.shutil).should_receive('rmtree').never()
     flexmock(module.shutil).should_receive('rmtree').never()
     flexmock(module).should_receive('unmount_snapshot').never()
     flexmock(module).should_receive('unmount_snapshot').never()
@@ -486,13 +660,50 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_no
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').with_args(
     flexmock(module.os.path).should_receive('isdir').with_args(
-        '/run/borgmatic/zfs_snapshots'
+        '/run/borgmatic/zfs_snapshots/b33f'
     ).and_return(True)
     ).and_return(True)
     flexmock(module.os.path).should_receive('isdir').with_args(
     flexmock(module.os.path).should_receive('isdir').with_args(
-        '/run/borgmatic/zfs_snapshots/mnt/dataset'
+        '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset'
     ).and_return(False)
     ).and_return(False)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
+    flexmock(module.shutil).should_receive('rmtree')
+    flexmock(module).should_receive('unmount_snapshot').never()
+    flexmock(module).should_receive('get_all_snapshots').and_return(
+        ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
+    )
+    flexmock(module).should_receive('destroy_snapshot').with_args(
+        'zfs', 'dataset@borgmatic-1234'
+    ).once()
+
+    module.remove_data_source_dumps(
+        hook_config={},
+        config={'source_directories': '/mnt/dataset', 'zfs': {}},
+        borgmatic_runtime_directory='/run/borgmatic',
+        dry_run=False,
+    )
+
+
+def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_empty():
+    flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
+    flexmock(module.borgmatic.config.paths).should_receive(
+        'replace_temporary_subdirectory_with_glob'
+    ).and_return('/run/borgmatic')
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
+    flexmock(module.os.path).should_receive('isdir').with_args(
+        '/run/borgmatic/zfs_snapshots/b33f'
+    ).and_return(True)
+    flexmock(module.os.path).should_receive('isdir').with_args(
+        '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset'
+    ).and_return(True)
+    flexmock(module.os).should_receive('listdir').with_args(
+        '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset'
+    ).and_return([])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').never()
     flexmock(module).should_receive('unmount_snapshot').never()
     flexmock(module).should_receive('get_all_snapshots').and_return(
     flexmock(module).should_receive('get_all_snapshots').and_return(
@@ -515,13 +726,16 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_after_rmtre
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').with_args(
     flexmock(module.os.path).should_receive('isdir').with_args(
-        '/run/borgmatic/zfs_snapshots'
+        '/run/borgmatic/zfs_snapshots/b33f'
     ).and_return(True)
     ).and_return(True)
     flexmock(module.os.path).should_receive('isdir').with_args(
     flexmock(module.os.path).should_receive('isdir').with_args(
-        '/run/borgmatic/zfs_snapshots/mnt/dataset'
+        '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset'
     ).and_return(True).and_return(False)
     ).and_return(True).and_return(False)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module.shutil).should_receive('rmtree')
     flexmock(module).should_receive('unmount_snapshot').never()
     flexmock(module).should_receive('unmount_snapshot').never()
     flexmock(module).should_receive('get_all_snapshots').and_return(
     flexmock(module).should_receive('get_all_snapshots').and_return(
@@ -544,8 +758,11 @@ def test_remove_data_source_dumps_with_dry_run_skips_unmount_and_destroy():
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'replace_temporary_subdirectory_with_glob'
         'replace_temporary_subdirectory_with_glob'
     ).and_return('/run/borgmatic')
     ).and_return('/run/borgmatic')
-    flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
+    flexmock(module.glob).should_receive('glob').replace_with(
+        lambda path: [path.replace('*', 'b33f')]
+    )
     flexmock(module.os.path).should_receive('isdir').and_return(True)
     flexmock(module.os.path).should_receive('isdir').and_return(True)
+    flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
     flexmock(module.shutil).should_receive('rmtree').never()
     flexmock(module.shutil).should_receive('rmtree').never()
     flexmock(module).should_receive('unmount_snapshot').never()
     flexmock(module).should_receive('unmount_snapshot').never()
     flexmock(module).should_receive('get_all_snapshots').and_return(
     flexmock(module).should_receive('get_all_snapshots').and_return(