2
0
Эх сурвалжийг харах

Add a passcommand hook so borgmatic can collect the encryption passphrase once and pass it to Borg multiple times (#961).

Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/984
Dan Helfman 4 сар өмнө
parent
commit
0073366dfc

+ 2 - 0
NEWS

@@ -1,5 +1,7 @@
 1.9.9.dev0
 1.9.9.dev0
  * #635: Log the repository path or label on every relevant log message, not just some logs.
  * #635: Log the repository path or label on every relevant log message, not just some logs.
+ * #961: Add a passcommand hook so borgmatic can collect the encryption passphrase once and pass it
+   to Borg multiple times.
  * #981: Fix a "spot" check file count delta error.
  * #981: Fix a "spot" check file count delta error.
  * #982: Fix for borgmatic "exclude_patterns" and "exclude_from" recursing into excluded
  * #982: Fix for borgmatic "exclude_patterns" and "exclude_from" recursing into excluded
    subdirectories.
    subdirectories.

+ 20 - 1
borgmatic/borg/environment.py

@@ -1,5 +1,7 @@
 import os
 import os
 
 
+import borgmatic.hooks.dispatch
+
 OPTION_TO_ENVIRONMENT_VARIABLE = {
 OPTION_TO_ENVIRONMENT_VARIABLE = {
     'borg_base_directory': 'BORG_BASE_DIR',
     'borg_base_directory': 'BORG_BASE_DIR',
     'borg_config_directory': 'BORG_CONFIG_DIR',
     'borg_config_directory': 'BORG_CONFIG_DIR',
@@ -7,7 +9,6 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
     'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
     'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
     'borg_security_directory': 'BORG_SECURITY_DIR',
     'borg_security_directory': 'BORG_SECURITY_DIR',
     'borg_keys_directory': 'BORG_KEYS_DIR',
     'borg_keys_directory': 'BORG_KEYS_DIR',
-    'encryption_passcommand': 'BORG_PASSCOMMAND',
     'encryption_passphrase': 'BORG_PASSPHRASE',
     'encryption_passphrase': 'BORG_PASSPHRASE',
     'ssh_command': 'BORG_RSH',
     'ssh_command': 'BORG_RSH',
     'temporary_directory': 'TMPDIR',
     'temporary_directory': 'TMPDIR',
@@ -36,6 +37,24 @@ def make_environment(config):
         if value:
         if value:
             environment[environment_variable_name] = str(value)
             environment[environment_variable_name] = str(value)
 
 
+    passphrase = borgmatic.hooks.dispatch.call_hook(
+        function_name='load_credential',
+        config=config,
+        hook_name='passcommand',
+        credential_name='encryption_passphrase',
+    )
+
+    # If the passcommand produced a passphrase, send it to Borg via an anonymous pipe.
+    if passphrase:
+        read_file_descriptor, write_file_descriptor = os.pipe()
+        os.write(write_file_descriptor, passphrase.encode('utf-8'))
+        os.close(write_file_descriptor)
+
+        # This, plus subprocess.Popen(..., close_fds=False) in execute.py, is necessary for the Borg
+        # child process to inherit the file descriptor.
+        os.set_inheritable(read_file_descriptor, True)
+        environment['BORG_PASSPHRASE_FD'] = str(read_file_descriptor)
+
     for (
     for (
         option_name,
         option_name,
         environment_variable_name,
         environment_variable_name,

+ 6 - 0
borgmatic/execute.py

@@ -307,6 +307,8 @@ def execute_command(
         shell=shell,
         shell=shell,
         env=environment,
         env=environment,
         cwd=working_directory,
         cwd=working_directory,
+        # Necessary for the passcommand credential hook to work.
+        close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
     )
     )
     if not run_to_completion:
     if not run_to_completion:
         return process
         return process
@@ -354,6 +356,8 @@ def execute_command_and_capture_output(
             shell=shell,
             shell=shell,
             env=environment,
             env=environment,
             cwd=working_directory,
             cwd=working_directory,
+            # Necessary for the passcommand credential hook to work.
+            close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
         )
         )
     except subprocess.CalledProcessError as error:
     except subprocess.CalledProcessError as error:
         if (
         if (
@@ -414,6 +418,8 @@ def execute_command_with_processes(
             shell=shell,
             shell=shell,
             env=environment,
             env=environment,
             cwd=working_directory,
             cwd=working_directory,
+            # Necessary for the passcommand credential hook to work.
+            close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
         )
         )
     except (subprocess.CalledProcessError, OSError):
     except (subprocess.CalledProcessError, OSError):
         # Something has gone wrong. So vent each process' output buffer to prevent it from hanging.
         # Something has gone wrong. So vent each process' output buffer to prevent it from hanging.

+ 0 - 0
borgmatic/hooks/credential/__init__.py


+ 59 - 0
borgmatic/hooks/credential/passcommand.py

@@ -0,0 +1,59 @@
+import functools
+import logging
+import shlex
+
+import borgmatic.config.paths
+import borgmatic.execute
+
+logger = logging.getLogger(__name__)
+
+
+@functools.cache
+def run_passcommand(passcommand, passphrase_configured, working_directory):
+    '''
+    Run the given passcommand using the given working directory and return the passphrase produced
+    by the command. But bail first if a passphrase is already configured; this mimics Borg's
+    behavior.
+
+    Cache the results so that the passcommand only needs to run—and potentially prompt the user—once
+    per borgmatic invocation.
+    '''
+    if passcommand and passphrase_configured:
+        logger.warning(
+            'Ignoring the "encryption_passcommand" option because "encryption_passphrase" is set'
+        )
+        return None
+
+    return borgmatic.execute.execute_command_and_capture_output(
+        shlex.split(passcommand),
+        working_directory=working_directory,
+    )
+
+
+def load_credential(hook_config, config, credential_name):
+    '''
+    Given the hook configuration dict, the configuration dict, and a credential name to load, call
+    the configured passcommand to produce and return an encryption passphrase. In effect, we're
+    doing an end-run around Borg by invoking its passcommand ourselves. This allows us to pass the
+    resulting passphrase to multiple different Borg invocations without the user having to be
+    prompted multiple times.
+
+    If no passcommand is configured, then return None.
+
+    The credential name must be "encryption_passphrase"; that's the only supported credential with
+    this particular hook.
+    '''
+    if credential_name != 'encryption_passphrase':
+        raise ValueError(
+            f'Credential name "{credential_name}" is not supported for the passphrase credential hook'
+        )
+
+    passcommand = config.get('encryption_passcommand')
+
+    if not passcommand:
+        return None
+
+    passphrase = config.get('encryption_passphrase')
+    working_directory = borgmatic.config.paths.get_working_directory(config)
+
+    return run_passcommand(passcommand, bool(passphrase), working_directory)

+ 12 - 6
borgmatic/hooks/dispatch.py

@@ -3,6 +3,7 @@ import importlib
 import logging
 import logging
 import pkgutil
 import pkgutil
 
 
+import borgmatic.hooks.credential
 import borgmatic.hooks.data_source
 import borgmatic.hooks.data_source
 import borgmatic.hooks.monitoring
 import borgmatic.hooks.monitoring
 
 
@@ -10,6 +11,7 @@ logger = logging.getLogger(__name__)
 
 
 
 
 class Hook_type(enum.Enum):
 class Hook_type(enum.Enum):
+    CREDENTIAL = 'credential'
     DATA_SOURCE = 'data_source'
     DATA_SOURCE = 'data_source'
     MONITORING = 'monitoring'
     MONITORING = 'monitoring'
 
 
@@ -40,7 +42,11 @@ def call_hook(function_name, config, hook_name, *args, **kwargs):
     module_name = hook_name.split('_databases')[0]
     module_name = hook_name.split('_databases')[0]
 
 
     # Probe for a data source or monitoring hook module corresponding to the hook name.
     # Probe for a data source or monitoring hook module corresponding to the hook name.
-    for parent_module in (borgmatic.hooks.data_source, borgmatic.hooks.monitoring):
+    for parent_module in (
+        borgmatic.hooks.credential,
+        borgmatic.hooks.data_source,
+        borgmatic.hooks.monitoring,
+    ):
         if module_name not in get_submodule_names(parent_module):
         if module_name not in get_submodule_names(parent_module):
             continue
             continue
 
 
@@ -62,8 +68,8 @@ def call_hook(function_name, config, hook_name, *args, **kwargs):
 def call_hooks(function_name, config, hook_type, *args, **kwargs):
 def call_hooks(function_name, config, hook_type, *args, **kwargs):
     '''
     '''
     Given a configuration dict, call the requested function of the Python module corresponding to
     Given a configuration dict, call the requested function of the Python module corresponding to
-    each hook of the given hook type (either "data_source" or "monitoring"). Supply each call with
-    the configuration for that hook, and any given args and kwargs.
+    each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each
+    call with the configuration for that hook, and any given args and kwargs.
 
 
     Collect any return values into a dict from module name to return value. Note that the module
     Collect any return values into a dict from module name to return value. Note that the module
     name is the name of the hook module itself, which might be different from the hook configuration
     name is the name of the hook module itself, which might be different from the hook configuration
@@ -88,9 +94,9 @@ def call_hooks(function_name, config, hook_type, *args, **kwargs):
 def call_hooks_even_if_unconfigured(function_name, config, hook_type, *args, **kwargs):
 def call_hooks_even_if_unconfigured(function_name, config, hook_type, *args, **kwargs):
     '''
     '''
     Given a configuration dict, call the requested function of the Python module corresponding to
     Given a configuration dict, call the requested function of the Python module corresponding to
-    each hook of the given hook type (either "data_source" or "monitoring"). Supply each call with
-    the configuration for that hook and any given args and kwargs. Collect any return values into a
-    dict from hook name to return value.
+    each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each
+    call with the configuration for that hook and any given args and kwargs. Collect any return
+    values into a dict from hook name to return value.
 
 
     Raise AttributeError if the function name is not found in the module.
     Raise AttributeError if the function name is not found in the module.
     Raise anything else that a called function raises. An error stops calls to subsequent functions.
     Raise anything else that a called function raises. An error stops calls to subsequent functions.

+ 21 - 2
docs/how-to/provide-your-passwords.md

@@ -24,11 +24,30 @@ or security reasons, read on.
 borgmatic supports calling another application such as a password manager to 
 borgmatic supports calling another application such as a password manager to 
 obtain the Borg passphrase to a repository.
 obtain the Borg passphrase to a repository.
 
 
-For example, to ask the *Pass* password manager to provide the passphrase:
+For example, to ask the [Pass](https://www.passwordstore.org/) password manager
+to provide the passphrase:
+
+```yaml
+encryption_passcommand: pass path/to/borg-passphrase
+```
+
+Another example for [KeePassXC](https://keepassxc.org/):
+
 ```yaml
 ```yaml
-encryption_passcommand: pass path/to/borg-repokey
+encryption_passcommand: keepassxc-cli show --show-protected --attributes Password credentials.kdbx borg_passphrase
 ```
 ```
 
 
+... where `borg_passphrase` is the title of the KeePassXC entry containing your
+Borg encryption passphrase in its `Password` field.
+
+<span class="minilink minilink-addedin">New in version 1.9.9</span> Instead of
+letting Borg run the passcommand—potentially multiple times since borgmatic runs
+Borg multiple times—borgmatic now runs the passcommand itself and passes the
+resulting passprhase securely to Borg via an anonymous pipe. This means you
+should only ever get prompted for your password manager's passphrase at most
+once per borgmatic run.
+
+
 ### Using systemd service credentials
 ### Using systemd service credentials
 
 
 Borgmatic supports using encrypted [credentials](https://systemd.io/CREDENTIALS/).
 Borgmatic supports using encrypted [credentials](https://systemd.io/CREDENTIALS/).

+ 1 - 0
docs/reference/source-code.md

@@ -18,6 +18,7 @@ you get started. Starting at the top level, we have:
    * [commands](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/commands): Looking to add a new flag or action? Start here. This contains borgmatic's entry point, argument parsing, and shell completion. 
    * [commands](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/commands): Looking to add a new flag or action? Start here. This contains borgmatic's entry point, argument parsing, and shell completion. 
    * [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration. Interested in adding a new configuration option? Check out `schema.yaml` here.
    * [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration. Interested in adding a new configuration option? Check out `schema.yaml` here.
    * [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database, filesystem, or monitoring integration? Start here.
    * [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database, filesystem, or monitoring integration? Start here.
+     * [credential](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/credential): Credential hooks—for loading passphrases and secrets from external providers.
      * [data_source](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/data_source): Database and filesystem hooks—anything that produces data or files to go into a backup archive.
      * [data_source](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/data_source): Database and filesystem hooks—anything that produces data or files to go into a backup archive.
      * [monitoring](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/monitoring): Monitoring hooks—integrations with third-party or self-hosted monitoring services.
      * [monitoring](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/monitoring): Monitoring hooks—integrations with third-party or self-hosted monitoring services.
  * [docs](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/docs): How-to and reference documentation, including the document you're reading now.
  * [docs](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/docs): How-to and reference documentation, including the document you're reading now.

+ 0 - 0
tests/end-to-end/hooks/credential/__init__.py


+ 65 - 0
tests/end-to-end/hooks/credential/test_passcommand.py

@@ -0,0 +1,65 @@
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+import pytest
+
+
+def generate_configuration(config_path, repository_path):
+    '''
+    Generate borgmatic configuration into a file at the config path, and update the defaults so as
+    to work for testing, including updating the source directories, injecting the given repository
+    path, and tacking on an encryption passcommand.
+    '''
+    subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' '))
+    config = (
+        open(config_path)
+        .read()
+        .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path)
+        .replace('- path: /mnt/backup', '')
+        .replace('label: local', '')
+        .replace('- /home/user/path with spaces', '')
+        .replace('- /home', f'- {config_path}')
+        .replace('- /etc', '')
+        .replace('- /var/log/syslog*', '')
+        + '\nencryption_passcommand: "echo test"'
+    )
+    config_file = open(config_path, 'w')
+    config_file.write(config)
+    config_file.close()
+
+
+def test_borgmatic_command():
+    # Create a Borg repository.
+    temporary_directory = tempfile.mkdtemp()
+    repository_path = os.path.join(temporary_directory, 'test.borg')
+    extract_path = os.path.join(temporary_directory, 'extract')
+
+    original_working_directory = os.getcwd()
+    os.mkdir(extract_path)
+    os.chdir(extract_path)
+
+    try:
+        config_path = os.path.join(temporary_directory, 'test.yaml')
+        generate_configuration(config_path, repository_path)
+
+        subprocess.check_call(
+            f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ')
+        )
+
+        # Run borgmatic to generate a backup archive, and then list it to make sure it exists.
+        subprocess.check_call(f'borgmatic -v 2 --config {config_path}'.split(' '))
+        output = subprocess.check_output(
+            f'borgmatic --config {config_path} list --json'.split(' ')
+        ).decode(sys.stdout.encoding)
+        parsed_output = json.loads(output)
+
+        assert len(parsed_output) == 1
+        assert len(parsed_output[0]['archives']) == 1
+        archive_name = parsed_output[0]['archives'][0]['archive']
+    finally:
+        os.chdir(original_working_directory)
+        shutil.rmtree(temporary_directory)

+ 27 - 2
tests/unit/borg/test_environment.py

@@ -3,13 +3,22 @@ from flexmock import flexmock
 from borgmatic.borg import environment as module
 from borgmatic.borg import environment as module
 
 
 
 
-def test_make_environment_with_passcommand_should_set_environment():
+def test_make_environment_with_passcommand_should_call_it_and_set_passphrase_file_descriptor_in_environment():
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return('passphrase')
+    flexmock(module.os).should_receive('pipe').and_return((3, 4))
+    flexmock(module.os).should_receive('write')
+    flexmock(module.os).should_receive('close')
+    flexmock(module.os).should_receive('set_inheritable')
+
     environment = module.make_environment({'encryption_passcommand': 'command'})
     environment = module.make_environment({'encryption_passcommand': 'command'})
 
 
-    assert environment.get('BORG_PASSCOMMAND') == 'command'
+    assert not environment.get('BORG_PASSCOMMAND')
+    assert environment.get('BORG_PASSPHRASE_FD') == '3'
 
 
 
 
 def test_make_environment_with_passphrase_should_set_environment():
 def test_make_environment_with_passphrase_should_set_environment():
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
+    flexmock(module.os).should_receive('pipe').never()
     flexmock(module.os.environ).should_receive('get').and_return(None)
     flexmock(module.os.environ).should_receive('get').and_return(None)
     environment = module.make_environment({'encryption_passphrase': 'pass'})
     environment = module.make_environment({'encryption_passphrase': 'pass'})
 
 
@@ -17,6 +26,8 @@ def test_make_environment_with_passphrase_should_set_environment():
 
 
 
 
 def test_make_environment_with_ssh_command_should_set_environment():
 def test_make_environment_with_ssh_command_should_set_environment():
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
+    flexmock(module.os).should_receive('pipe').never()
     flexmock(module.os.environ).should_receive('get').and_return(None)
     flexmock(module.os.environ).should_receive('get').and_return(None)
     environment = module.make_environment({'ssh_command': 'ssh -C'})
     environment = module.make_environment({'ssh_command': 'ssh -C'})
 
 
@@ -24,6 +35,8 @@ def test_make_environment_with_ssh_command_should_set_environment():
 
 
 
 
 def test_make_environment_without_configuration_sets_certain_environment_variables():
 def test_make_environment_without_configuration_sets_certain_environment_variables():
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
+    flexmock(module.os).should_receive('pipe').never()
     flexmock(module.os.environ).should_receive('get').and_return(None)
     flexmock(module.os.environ).should_receive('get').and_return(None)
     environment = module.make_environment({})
     environment = module.make_environment({})
 
 
@@ -36,6 +49,8 @@ def test_make_environment_without_configuration_sets_certain_environment_variabl
 
 
 
 
 def test_make_environment_without_configuration_does_not_set_certain_environment_variables_if_already_set():
 def test_make_environment_without_configuration_does_not_set_certain_environment_variables_if_already_set():
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
+    flexmock(module.os).should_receive('pipe').never()
     flexmock(module.os.environ).should_receive('get').with_args(
     flexmock(module.os.environ).should_receive('get').with_args(
         'BORG_RELOCATED_REPO_ACCESS_IS_OK'
         'BORG_RELOCATED_REPO_ACCESS_IS_OK'
     ).and_return('yup')
     ).and_return('yup')
@@ -48,6 +63,8 @@ def test_make_environment_without_configuration_does_not_set_certain_environment
 
 
 
 
 def test_make_environment_with_relocated_repo_access_true_should_set_environment_yes():
 def test_make_environment_with_relocated_repo_access_true_should_set_environment_yes():
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
+    flexmock(module.os).should_receive('pipe').never()
     flexmock(module.os.environ).should_receive('get').and_return(None)
     flexmock(module.os.environ).should_receive('get').and_return(None)
     environment = module.make_environment({'relocated_repo_access_is_ok': True})
     environment = module.make_environment({'relocated_repo_access_is_ok': True})
 
 
@@ -55,6 +72,8 @@ def test_make_environment_with_relocated_repo_access_true_should_set_environment
 
 
 
 
 def test_make_environment_with_relocated_repo_access_false_should_set_environment_no():
 def test_make_environment_with_relocated_repo_access_false_should_set_environment_no():
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
+    flexmock(module.os).should_receive('pipe').never()
     flexmock(module.os.environ).should_receive('get').and_return(None)
     flexmock(module.os.environ).should_receive('get').and_return(None)
     environment = module.make_environment({'relocated_repo_access_is_ok': False})
     environment = module.make_environment({'relocated_repo_access_is_ok': False})
 
 
@@ -62,6 +81,8 @@ def test_make_environment_with_relocated_repo_access_false_should_set_environmen
 
 
 
 
 def test_make_environment_check_i_know_what_i_am_doing_true_should_set_environment_YES():
 def test_make_environment_check_i_know_what_i_am_doing_true_should_set_environment_YES():
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
+    flexmock(module.os).should_receive('pipe').never()
     flexmock(module.os.environ).should_receive('get').and_return(None)
     flexmock(module.os.environ).should_receive('get').and_return(None)
     environment = module.make_environment({'check_i_know_what_i_am_doing': True})
     environment = module.make_environment({'check_i_know_what_i_am_doing': True})
 
 
@@ -69,6 +90,8 @@ def test_make_environment_check_i_know_what_i_am_doing_true_should_set_environme
 
 
 
 
 def test_make_environment_check_i_know_what_i_am_doing_false_should_set_environment_NO():
 def test_make_environment_check_i_know_what_i_am_doing_false_should_set_environment_NO():
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
+    flexmock(module.os).should_receive('pipe').never()
     flexmock(module.os.environ).should_receive('get').and_return(None)
     flexmock(module.os.environ).should_receive('get').and_return(None)
     environment = module.make_environment({'check_i_know_what_i_am_doing': False})
     environment = module.make_environment({'check_i_know_what_i_am_doing': False})
 
 
@@ -76,6 +99,8 @@ def test_make_environment_check_i_know_what_i_am_doing_false_should_set_environm
 
 
 
 
 def test_make_environment_with_integer_variable_value():
 def test_make_environment_with_integer_variable_value():
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
+    flexmock(module.os).should_receive('pipe').never()
     flexmock(module.os.environ).should_receive('get').and_return(None)
     flexmock(module.os.environ).should_receive('get').and_return(None)
     environment = module.make_environment({'borg_files_cache_ttl': 40})
     environment = module.make_environment({'borg_files_cache_ttl': 40})
     assert environment.get('BORG_FILES_CACHE_TTL') == '40'
     assert environment.get('BORG_FILES_CACHE_TTL') == '40'

+ 0 - 0
tests/unit/hooks/credential/__init__.py


+ 65 - 0
tests/unit/hooks/credential/test_passcommand.py

@@ -0,0 +1,65 @@
+import pytest
+from flexmock import flexmock
+
+from borgmatic.hooks.credential import passcommand as module
+
+
+def test_run_passcommand_with_passphrase_configured_bails():
+    flexmock(module.borgmatic.execute).should_receive('execute_command_and_capture_output').never()
+
+    assert (
+        module.run_passcommand('passcommand', passphrase_configured=True, working_directory=None)
+        is None
+    )
+
+
+def test_run_passcommand_without_passphrase_configured_executes_passcommand():
+    flexmock(module.borgmatic.execute).should_receive(
+        'execute_command_and_capture_output'
+    ).and_return('passphrase').once()
+
+    assert (
+        module.run_passcommand('passcommand', passphrase_configured=False, working_directory=None)
+        == 'passphrase'
+    )
+
+
+def test_load_credential_with_unknown_credential_name_errors():
+    with pytest.raises(ValueError):
+        module.load_credential(hook_config={}, config={}, credential_name='wtf')
+
+
+def test_load_credential_with_configured_passcommand_runs_it():
+    flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
+        '/working'
+    )
+    flexmock(module).should_receive('run_passcommand').with_args(
+        'command', False, '/working'
+    ).and_return('passphrase').once()
+
+    assert (
+        module.load_credential(
+            hook_config={},
+            config={'encryption_passcommand': 'command'},
+            credential_name='encryption_passphrase',
+        )
+        == 'passphrase'
+    )
+
+
+def test_load_credential_with_configured_passphrase_and_passcommand_detects_passphrase():
+    flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
+        '/working'
+    )
+    flexmock(module).should_receive('run_passcommand').with_args(
+        'command', True, '/working'
+    ).and_return(None).once()
+
+    assert (
+        module.load_credential(
+            hook_config={},
+            config={'encryption_passphrase': 'passphrase', 'encryption_passcommand': 'command'},
+            credential_name='encryption_passphrase',
+        )
+        is None
+    )

+ 18 - 0
tests/unit/hooks/test_dispatch.py

@@ -17,6 +17,9 @@ def test_call_hook_invokes_module_function_with_arguments_and_returns_value():
     config = {'super_hook': flexmock(), 'other_hook': flexmock()}
     config = {'super_hook': flexmock(), 'other_hook': flexmock()}
     expected_return_value = flexmock()
     expected_return_value = flexmock()
     test_module = sys.modules[__name__]
     test_module = sys.modules[__name__]
+    flexmock(module).should_receive('get_submodule_names').with_args(
+        module.borgmatic.hooks.credential
+    ).and_return(['other_hook'])
     flexmock(module).should_receive('get_submodule_names').with_args(
     flexmock(module).should_receive('get_submodule_names').with_args(
         module.borgmatic.hooks.data_source
         module.borgmatic.hooks.data_source
     ).and_return(['other_hook'])
     ).and_return(['other_hook'])
@@ -39,6 +42,9 @@ def test_call_hook_probes_config_with_databases_suffix():
     config = {'super_hook_databases': flexmock(), 'other_hook': flexmock()}
     config = {'super_hook_databases': flexmock(), 'other_hook': flexmock()}
     expected_return_value = flexmock()
     expected_return_value = flexmock()
     test_module = sys.modules[__name__]
     test_module = sys.modules[__name__]
+    flexmock(module).should_receive('get_submodule_names').with_args(
+        module.borgmatic.hooks.credential
+    ).and_return(['other_hook'])
     flexmock(module).should_receive('get_submodule_names').with_args(
     flexmock(module).should_receive('get_submodule_names').with_args(
         module.borgmatic.hooks.data_source
         module.borgmatic.hooks.data_source
     ).and_return(['other_hook'])
     ).and_return(['other_hook'])
@@ -61,6 +67,9 @@ def test_call_hook_strips_databases_suffix_from_hook_name():
     config = {'super_hook_databases': flexmock(), 'other_hook_databases': flexmock()}
     config = {'super_hook_databases': flexmock(), 'other_hook_databases': flexmock()}
     expected_return_value = flexmock()
     expected_return_value = flexmock()
     test_module = sys.modules[__name__]
     test_module = sys.modules[__name__]
+    flexmock(module).should_receive('get_submodule_names').with_args(
+        module.borgmatic.hooks.credential
+    ).and_return(['other_hook'])
     flexmock(module).should_receive('get_submodule_names').with_args(
     flexmock(module).should_receive('get_submodule_names').with_args(
         module.borgmatic.hooks.data_source
         module.borgmatic.hooks.data_source
     ).and_return(['other_hook'])
     ).and_return(['other_hook'])
@@ -83,6 +92,9 @@ def test_call_hook_without_hook_config_invokes_module_function_with_arguments_an
     config = {'other_hook': flexmock()}
     config = {'other_hook': flexmock()}
     expected_return_value = flexmock()
     expected_return_value = flexmock()
     test_module = sys.modules[__name__]
     test_module = sys.modules[__name__]
+    flexmock(module).should_receive('get_submodule_names').with_args(
+        module.borgmatic.hooks.credential
+    ).and_return(['other_hook'])
     flexmock(module).should_receive('get_submodule_names').with_args(
     flexmock(module).should_receive('get_submodule_names').with_args(
         module.borgmatic.hooks.data_source
         module.borgmatic.hooks.data_source
     ).and_return(['other_hook'])
     ).and_return(['other_hook'])
@@ -104,6 +116,9 @@ def test_call_hook_without_hook_config_invokes_module_function_with_arguments_an
 def test_call_hook_without_corresponding_module_raises():
 def test_call_hook_without_corresponding_module_raises():
     config = {'super_hook': flexmock(), 'other_hook': flexmock()}
     config = {'super_hook': flexmock(), 'other_hook': flexmock()}
     test_module = sys.modules[__name__]
     test_module = sys.modules[__name__]
+    flexmock(module).should_receive('get_submodule_names').with_args(
+        module.borgmatic.hooks.credential
+    ).and_return(['other_hook'])
     flexmock(module).should_receive('get_submodule_names').with_args(
     flexmock(module).should_receive('get_submodule_names').with_args(
         module.borgmatic.hooks.data_source
         module.borgmatic.hooks.data_source
     ).and_return(['other_hook'])
     ).and_return(['other_hook'])
@@ -121,6 +136,9 @@ def test_call_hook_without_corresponding_module_raises():
 
 
 def test_call_hook_skips_non_hook_modules():
 def test_call_hook_skips_non_hook_modules():
     config = {'not_a_hook': flexmock(), 'other_hook': flexmock()}
     config = {'not_a_hook': flexmock(), 'other_hook': flexmock()}
+    flexmock(module).should_receive('get_submodule_names').with_args(
+        module.borgmatic.hooks.credential
+    ).and_return(['other_hook'])
     flexmock(module).should_receive('get_submodule_names').with_args(
     flexmock(module).should_receive('get_submodule_names').with_args(
         module.borgmatic.hooks.data_source
         module.borgmatic.hooks.data_source
     ).and_return(['other_hook'])
     ).and_return(['other_hook'])

+ 139 - 8
tests/unit/test_execute.py

@@ -185,6 +185,7 @@ def test_execute_command_calls_full_command():
         shell=False,
         shell=False,
         env=None,
         env=None,
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(flexmock(stdout=None)).once()
     ).and_return(flexmock(stdout=None)).once()
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
@@ -207,6 +208,7 @@ def test_execute_command_calls_full_command_with_output_file():
         shell=False,
         shell=False,
         env=None,
         env=None,
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(flexmock(stderr=None)).once()
     ).and_return(flexmock(stderr=None)).once()
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
@@ -221,7 +223,14 @@ def test_execute_command_calls_full_command_without_capturing_output():
     flexmock(module).should_receive('log_command')
     flexmock(module).should_receive('log_command')
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.subprocess).should_receive('Popen').with_args(
     flexmock(module.subprocess).should_receive('Popen').with_args(
-        full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None
+        full_command,
+        stdin=None,
+        stdout=None,
+        stderr=None,
+        shell=False,
+        env=None,
+        cwd=None,
+        close_fds=True,
     ).and_return(flexmock(wait=lambda: 0)).once()
     ).and_return(flexmock(wait=lambda: 0)).once()
     flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS)
     flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS)
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
@@ -245,6 +254,7 @@ def test_execute_command_calls_full_command_with_input_file():
         shell=False,
         shell=False,
         env=None,
         env=None,
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(flexmock(stdout=None)).once()
     ).and_return(flexmock(stdout=None)).once()
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
@@ -266,6 +276,7 @@ def test_execute_command_calls_full_command_with_shell():
         shell=True,
         shell=True,
         env=None,
         env=None,
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(flexmock(stdout=None)).once()
     ).and_return(flexmock(stdout=None)).once()
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
@@ -287,6 +298,7 @@ def test_execute_command_calls_full_command_with_extra_environment():
         shell=False,
         shell=False,
         env={'a': 'b', 'c': 'd'},
         env={'a': 'b', 'c': 'd'},
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(flexmock(stdout=None)).once()
     ).and_return(flexmock(stdout=None)).once()
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
@@ -308,6 +320,7 @@ def test_execute_command_calls_full_command_with_working_directory():
         shell=False,
         shell=False,
         env=None,
         env=None,
         cwd='/working',
         cwd='/working',
+        close_fds=True,
     ).and_return(flexmock(stdout=None)).once()
     ).and_return(flexmock(stdout=None)).once()
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
@@ -317,6 +330,28 @@ def test_execute_command_calls_full_command_with_working_directory():
     assert output is None
     assert output is None
 
 
 
 
+def test_execute_command_with_BORG_PASSPHRASE_FD_leaves_file_descriptors_open():
+    full_command = ['foo', 'bar']
+    flexmock(module).should_receive('log_command')
+    flexmock(module.os, environ={'a': 'b'})
+    flexmock(module.subprocess).should_receive('Popen').with_args(
+        full_command,
+        stdin=None,
+        stdout=module.subprocess.PIPE,
+        stderr=module.subprocess.STDOUT,
+        shell=False,
+        env={'a': 'b', 'BORG_PASSPHRASE_FD': '4'},
+        cwd=None,
+        close_fds=False,
+    ).and_return(flexmock(stdout=None)).once()
+    flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
+    flexmock(module).should_receive('log_outputs')
+
+    output = module.execute_command(full_command, extra_environment={'BORG_PASSPHRASE_FD': '4'})
+
+    assert output is None
+
+
 def test_execute_command_without_run_to_completion_returns_process():
 def test_execute_command_without_run_to_completion_returns_process():
     full_command = ['foo', 'bar']
     full_command = ['foo', 'bar']
     process = flexmock()
     process = flexmock()
@@ -330,6 +365,7 @@ def test_execute_command_without_run_to_completion_returns_process():
         shell=False,
         shell=False,
         env=None,
         env=None,
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(process).once()
     ).and_return(process).once()
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
@@ -343,7 +379,12 @@ def test_execute_command_and_capture_output_returns_stdout():
     flexmock(module).should_receive('log_command')
     flexmock(module).should_receive('log_command')
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.subprocess).should_receive('check_output').with_args(
     flexmock(module.subprocess).should_receive('check_output').with_args(
-        full_command, stderr=None, shell=False, env=None, cwd=None
+        full_command,
+        stderr=None,
+        shell=False,
+        env=None,
+        cwd=None,
+        close_fds=True,
     ).and_return(flexmock(decode=lambda: expected_output)).once()
     ).and_return(flexmock(decode=lambda: expected_output)).once()
 
 
     output = module.execute_command_and_capture_output(full_command)
     output = module.execute_command_and_capture_output(full_command)
@@ -357,7 +398,12 @@ def test_execute_command_and_capture_output_with_capture_stderr_returns_stderr()
     flexmock(module).should_receive('log_command')
     flexmock(module).should_receive('log_command')
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.subprocess).should_receive('check_output').with_args(
     flexmock(module.subprocess).should_receive('check_output').with_args(
-        full_command, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None
+        full_command,
+        stderr=module.subprocess.STDOUT,
+        shell=False,
+        env=None,
+        cwd=None,
+        close_fds=True,
     ).and_return(flexmock(decode=lambda: expected_output)).once()
     ).and_return(flexmock(decode=lambda: expected_output)).once()
 
 
     output = module.execute_command_and_capture_output(full_command, capture_stderr=True)
     output = module.execute_command_and_capture_output(full_command, capture_stderr=True)
@@ -372,7 +418,12 @@ def test_execute_command_and_capture_output_returns_output_when_process_error_is
     flexmock(module).should_receive('log_command')
     flexmock(module).should_receive('log_command')
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.subprocess).should_receive('check_output').with_args(
     flexmock(module.subprocess).should_receive('check_output').with_args(
-        full_command, stderr=None, shell=False, env=None, cwd=None
+        full_command,
+        stderr=None,
+        shell=False,
+        env=None,
+        cwd=None,
+        close_fds=True,
     ).and_raise(subprocess.CalledProcessError(1, full_command, err_output)).once()
     ).and_raise(subprocess.CalledProcessError(1, full_command, err_output)).once()
     flexmock(module).should_receive('interpret_exit_code').and_return(
     flexmock(module).should_receive('interpret_exit_code').and_return(
         module.Exit_status.SUCCESS
         module.Exit_status.SUCCESS
@@ -389,7 +440,12 @@ def test_execute_command_and_capture_output_raises_when_command_errors():
     flexmock(module).should_receive('log_command')
     flexmock(module).should_receive('log_command')
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.subprocess).should_receive('check_output').with_args(
     flexmock(module.subprocess).should_receive('check_output').with_args(
-        full_command, stderr=None, shell=False, env=None, cwd=None
+        full_command,
+        stderr=None,
+        shell=False,
+        env=None,
+        cwd=None,
+        close_fds=True,
     ).and_raise(subprocess.CalledProcessError(2, full_command, expected_output)).once()
     ).and_raise(subprocess.CalledProcessError(2, full_command, expected_output)).once()
     flexmock(module).should_receive('interpret_exit_code').and_return(
     flexmock(module).should_receive('interpret_exit_code').and_return(
         module.Exit_status.ERROR
         module.Exit_status.ERROR
@@ -405,7 +461,12 @@ def test_execute_command_and_capture_output_returns_output_with_shell():
     flexmock(module).should_receive('log_command')
     flexmock(module).should_receive('log_command')
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.subprocess).should_receive('check_output').with_args(
     flexmock(module.subprocess).should_receive('check_output').with_args(
-        'foo bar', stderr=None, shell=True, env=None, cwd=None
+        'foo bar',
+        stderr=None,
+        shell=True,
+        env=None,
+        cwd=None,
+        close_fds=True,
     ).and_return(flexmock(decode=lambda: expected_output)).once()
     ).and_return(flexmock(decode=lambda: expected_output)).once()
 
 
     output = module.execute_command_and_capture_output(full_command, shell=True)
     output = module.execute_command_and_capture_output(full_command, shell=True)
@@ -424,6 +485,7 @@ def test_execute_command_and_capture_output_returns_output_with_extra_environmen
         shell=False,
         shell=False,
         env={'a': 'b', 'c': 'd'},
         env={'a': 'b', 'c': 'd'},
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(flexmock(decode=lambda: expected_output)).once()
     ).and_return(flexmock(decode=lambda: expected_output)).once()
 
 
     output = module.execute_command_and_capture_output(
     output = module.execute_command_and_capture_output(
@@ -439,7 +501,12 @@ def test_execute_command_and_capture_output_returns_output_with_working_director
     flexmock(module).should_receive('log_command')
     flexmock(module).should_receive('log_command')
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.subprocess).should_receive('check_output').with_args(
     flexmock(module.subprocess).should_receive('check_output').with_args(
-        full_command, stderr=None, shell=False, env=None, cwd='/working'
+        full_command,
+        stderr=None,
+        shell=False,
+        env=None,
+        cwd='/working',
+        close_fds=True,
     ).and_return(flexmock(decode=lambda: expected_output)).once()
     ).and_return(flexmock(decode=lambda: expected_output)).once()
 
 
     output = module.execute_command_and_capture_output(
     output = module.execute_command_and_capture_output(
@@ -449,6 +516,29 @@ def test_execute_command_and_capture_output_returns_output_with_working_director
     assert output == expected_output
     assert output == expected_output
 
 
 
 
+def test_execute_command_and_capture_output_with_BORG_PASSPHRASE_FD_leaves_file_descriptors_open():
+    full_command = ['foo', 'bar']
+    expected_output = '[]'
+    flexmock(module).should_receive('log_command')
+    flexmock(module.os, environ={'a': 'b'})
+    flexmock(module.subprocess).should_receive('check_output').with_args(
+        full_command,
+        stderr=None,
+        shell=False,
+        env={'a': 'b', 'BORG_PASSPHRASE_FD': '4'},
+        cwd=None,
+        close_fds=False,
+    ).and_return(flexmock(decode=lambda: expected_output)).once()
+
+    output = module.execute_command_and_capture_output(
+        full_command,
+        shell=False,
+        extra_environment={'BORG_PASSPHRASE_FD': '4'},
+    )
+
+    assert output == expected_output
+
+
 def test_execute_command_with_processes_calls_full_command():
 def test_execute_command_with_processes_calls_full_command():
     full_command = ['foo', 'bar']
     full_command = ['foo', 'bar']
     processes = (flexmock(),)
     processes = (flexmock(),)
@@ -462,6 +552,7 @@ def test_execute_command_with_processes_calls_full_command():
         shell=False,
         shell=False,
         env=None,
         env=None,
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(flexmock(stdout=None)).once()
     ).and_return(flexmock(stdout=None)).once()
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
 
 
@@ -484,6 +575,7 @@ def test_execute_command_with_processes_returns_output_with_output_log_level_non
         shell=False,
         shell=False,
         env=None,
         env=None,
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(process).once()
     ).and_return(process).once()
     flexmock(module).should_receive('log_outputs').and_return({process: 'out'})
     flexmock(module).should_receive('log_outputs').and_return({process: 'out'})
 
 
@@ -506,6 +598,7 @@ def test_execute_command_with_processes_calls_full_command_with_output_file():
         shell=False,
         shell=False,
         env=None,
         env=None,
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(flexmock(stderr=None)).once()
     ).and_return(flexmock(stderr=None)).once()
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
 
 
@@ -520,7 +613,14 @@ def test_execute_command_with_processes_calls_full_command_without_capturing_out
     flexmock(module).should_receive('log_command')
     flexmock(module).should_receive('log_command')
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.os, environ={'a': 'b'})
     flexmock(module.subprocess).should_receive('Popen').with_args(
     flexmock(module.subprocess).should_receive('Popen').with_args(
-        full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None
+        full_command,
+        stdin=None,
+        stdout=None,
+        stderr=None,
+        shell=False,
+        env=None,
+        cwd=None,
+        close_fds=True,
     ).and_return(flexmock(wait=lambda: 0)).once()
     ).and_return(flexmock(wait=lambda: 0)).once()
     flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS)
     flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS)
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
@@ -546,6 +646,7 @@ def test_execute_command_with_processes_calls_full_command_with_input_file():
         shell=False,
         shell=False,
         env=None,
         env=None,
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(flexmock(stdout=None)).once()
     ).and_return(flexmock(stdout=None)).once()
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
 
 
@@ -567,6 +668,7 @@ def test_execute_command_with_processes_calls_full_command_with_shell():
         shell=True,
         shell=True,
         env=None,
         env=None,
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(flexmock(stdout=None)).once()
     ).and_return(flexmock(stdout=None)).once()
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
 
 
@@ -588,6 +690,7 @@ def test_execute_command_with_processes_calls_full_command_with_extra_environmen
         shell=False,
         shell=False,
         env={'a': 'b', 'c': 'd'},
         env={'a': 'b', 'c': 'd'},
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_return(flexmock(stdout=None)).once()
     ).and_return(flexmock(stdout=None)).once()
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
 
 
@@ -611,6 +714,7 @@ def test_execute_command_with_processes_calls_full_command_with_working_director
         shell=False,
         shell=False,
         env=None,
         env=None,
         cwd='/working',
         cwd='/working',
+        close_fds=True,
     ).and_return(flexmock(stdout=None)).once()
     ).and_return(flexmock(stdout=None)).once()
     flexmock(module).should_receive('log_outputs')
     flexmock(module).should_receive('log_outputs')
 
 
@@ -621,6 +725,32 @@ def test_execute_command_with_processes_calls_full_command_with_working_director
     assert output is None
     assert output is None
 
 
 
 
+def test_execute_command_with_processes_with_BORG_PASSPHRASE_FD_leaves_file_descriptors_open():
+    full_command = ['foo', 'bar']
+    processes = (flexmock(),)
+    flexmock(module).should_receive('log_command')
+    flexmock(module.os, environ={'a': 'b'})
+    flexmock(module.subprocess).should_receive('Popen').with_args(
+        full_command,
+        stdin=None,
+        stdout=module.subprocess.PIPE,
+        stderr=module.subprocess.STDOUT,
+        shell=False,
+        env={'a': 'b', 'BORG_PASSPHRASE_FD': '4'},
+        cwd=None,
+        close_fds=False,
+    ).and_return(flexmock(stdout=None)).once()
+    flexmock(module).should_receive('log_outputs')
+
+    output = module.execute_command_with_processes(
+        full_command,
+        processes,
+        extra_environment={'BORG_PASSPHRASE_FD': '4'},
+    )
+
+    assert output is None
+
+
 def test_execute_command_with_processes_kills_processes_on_error():
 def test_execute_command_with_processes_kills_processes_on_error():
     full_command = ['foo', 'bar']
     full_command = ['foo', 'bar']
     flexmock(module).should_receive('log_command')
     flexmock(module).should_receive('log_command')
@@ -637,6 +767,7 @@ def test_execute_command_with_processes_kills_processes_on_error():
         shell=False,
         shell=False,
         env=None,
         env=None,
         cwd=None,
         cwd=None,
+        close_fds=True,
     ).and_raise(subprocess.CalledProcessError(1, full_command, 'error')).once()
     ).and_raise(subprocess.CalledProcessError(1, full_command, 'error')).once()
     flexmock(module).should_receive('log_outputs').never()
     flexmock(module).should_receive('log_outputs').never()