From 4f2f2255c3c8e7a8df2af2765fff7bb3e98b61f9 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sat, 7 Mar 2026 01:37:32 +0100 Subject: [PATCH] create --paths-from-shell-command, fixes #5968 This adds the `--paths-from-shell-command` option to the `create` command, enabling the use of shell-specific features like pipes and redirection when specifying input paths. Includes related test coverage. --- docs/usage/create.rst | 3 ++ src/borg/archiver/create_cmd.py | 30 ++++++++++++++----- .../testsuite/archiver/create_cmd_test.py | 15 ++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/docs/usage/create.rst b/docs/usage/create.rst index ee02f0be9..0f42aa887 100644 --- a/docs/usage/create.rst +++ b/docs/usage/create.rst @@ -89,6 +89,9 @@ Examples $ find ~ -size -1000k | borg create --paths-from-stdin small-files-only # Use --paths-from-command with find to back up files from only a given user $ borg create --paths-from-command joes-files -- find /srv/samba/shared -user joe + # Use --paths-from-shell-command with find to back up a few files from only a given user - + # BE VERY CAREFUL AND ONLY USE TRUSTED INPUT FOR THE SHELL COMMAND! + $ borg create --paths-from-shell-command some-of-joes-files -- "find /srv/samba/shared -user joe | head" # Use --paths-from-stdin with --paths-delimiter (for example, for filenames with newlines in them) $ find ~ -size -1000k -print0 | borg create \ --paths-from-stdin \ diff --git a/src/borg/archiver/create_cmd.py b/src/borg/archiver/create_cmd.py index 6114193fd..92ea929aa 100644 --- a/src/borg/archiver/create_cmd.py +++ b/src/borg/archiver/create_cmd.py @@ -91,13 +91,24 @@ class CreateMixIn: else: status = "+" # included self.print_file_status(status, path) - elif args.paths_from_command or args.paths_from_stdin: + elif args.paths_from_command or args.paths_from_shell_command or args.paths_from_stdin: paths_sep = eval_escapes(args.paths_delimiter) if args.paths_delimiter is not None else "\n" - if args.paths_from_command: + if args.paths_from_command or args.paths_from_shell_command: try: env = prepare_subprocess_env(system=True) - proc = subprocess.Popen( # nosec B603 - args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=None if is_win32 else ignore_sigint + if args.paths_from_shell_command: + # Use shell=True to support pipes, redirection, etc. + shell = True + cmd = " ".join(args.paths) + else: + shell = False + cmd = args.paths + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + env=env, + shell=shell, # nosec B602 + preexec_fn=None if is_win32 else ignore_sigint, ) except (FileNotFoundError, PermissionError) as e: raise CommandError(f"Failed to execute command: {e}") @@ -131,7 +142,7 @@ class CreateMixIn: self.print_file_status(status, path) if not dry_run and status is not None: fso.stats.files_stats[status] += 1 - if args.paths_from_command: + if args.paths_from_command or args.paths_from_shell_command: rc = proc.wait() if rc != 0: raise CommandError(f"Command {args.paths[0]!r} exited with status {rc}") @@ -762,8 +773,8 @@ class CreateMixIn: If you need more control and you want to give every single fs object path to borg (maybe implementing your own recursion or your own rules), you can use - ``--paths-from-stdin`` or ``--paths-from-command`` (with the latter, borg will - fail to create an archive should the command fail). + ``--paths-from-stdin``, ``--paths-from-command`` or ``--paths-from-shell-command`` + (with the latter two, borg will fail to create an archive should the command fail). Borg supports paths with the slashdot hack to strip path prefixes here also. So, be careful not to unintentionally trigger that. @@ -842,6 +853,11 @@ class CreateMixIn: action="store_true", help="interpret PATH as command and treat its output as ``--paths-from-stdin``", ) + subparser.add_argument( + "--paths-from-shell-command", + action="store_true", + help="interpret PATH as shell command and treat its output as ``--paths-from-stdin``", + ) subparser.add_argument( "--paths-delimiter", action=Highlander, diff --git a/src/borg/testsuite/archiver/create_cmd_test.py b/src/borg/testsuite/archiver/create_cmd_test.py index f99b187a6..5b06b7dfd 100644 --- a/src/borg/testsuite/archiver/create_cmd_test.py +++ b/src/borg/testsuite/archiver/create_cmd_test.py @@ -418,6 +418,21 @@ def test_create_paths_from_command_missing_command(archivers, request): assert output.endswith("No command given." + os.linesep) +@pytest.mark.skipif(is_win32, reason="shell patterns not supported on Windows") +def test_create_paths_from_shell_command(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + create_regular_file(archiver.input_path, "file1", size=1024 * 80) + create_regular_file(archiver.input_path, "file2", size=1024 * 80) + create_regular_file(archiver.input_path, "file3", size=1024 * 80) + input_data = "input/file1\ninput/file2\ninput/file3" + # Use a shell pipe to test that shell=True works correctly. + cmd(archiver, "create", "--paths-from-shell-command", "test", "--", f"echo '{input_data}' | head -n 2") + archive_list = cmd(archiver, "list", "test", "--json-lines") + paths = [json.loads(line)["path"] for line in archive_list.split("\n") if line] + assert paths == ["input/file1", "input/file2"] + + def test_create_without_root(archivers, request): """test create without a root""" archiver = request.getfixturevalue(archivers)