From e659236cca995be523a7cd331603a36846bc266c Mon Sep 17 00:00:00 2001 From: Olzhas Arystanov Date: Tue, 5 May 2026 22:43:01 +0700 Subject: [PATCH 1/2] Bump sdk version to v2.12.0 --- changelog.d/+sdk-upgrade.infrastructure.md | 1 + pyproject.toml | 2 +- uv.lock | 8 ++++---- 3 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 changelog.d/+sdk-upgrade.infrastructure.md diff --git a/changelog.d/+sdk-upgrade.infrastructure.md b/changelog.d/+sdk-upgrade.infrastructure.md new file mode 100644 index 00000000..4a10ec1e --- /dev/null +++ b/changelog.d/+sdk-upgrade.infrastructure.md @@ -0,0 +1 @@ +Bump sdk version to v2.12.0. diff --git a/pyproject.toml b/pyproject.toml index ad0dc41e..923d48cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "argcomplete>=3.5.2,<4", "arrow>=1.0.2,<2.0.0", - "b2sdk>=2.10.3,<3", + "b2sdk>=2.12.0,<3", "docutils>=0.18.1,<0.22", "idna~=3.4; platform_system == 'Java'", "rst2ansi==0.1.5", diff --git a/uv.lock b/uv.lock index 9289b6ee..372ee520 100644 --- a/uv.lock +++ b/uv.lock @@ -153,7 +153,7 @@ test = [ requires-dist = [ { name = "argcomplete", specifier = ">=3.5.2,<4" }, { name = "arrow", specifier = ">=1.0.2,<2.0.0" }, - { name = "b2sdk", specifier = ">=2.10.3,<3" }, + { name = "b2sdk", specifier = ">=2.12.0,<3" }, { name = "docutils", specifier = ">=0.18.1,<0.22" }, { name = "idna", marker = "platform_system == 'Java'", specifier = "~=3.4" }, { name = "pip", marker = "extra == 'license'", specifier = ">=26.1" }, @@ -208,7 +208,7 @@ test = [ [[package]] name = "b2sdk" -version = "2.10.3" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -216,9 +216,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/de/cf0ad74801eda05b39b121acc8c1625af0aca2e9a7851b1b823942b03d5f/b2sdk-2.10.3.tar.gz", hash = "sha256:aec069860587990a88e3504ed09c7f48f20fb156f8dd3b01fb36a869fd6ed772", size = 553611, upload-time = "2026-02-22T21:23:17.863Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/d8/45bc23014df0d263d2bda904bcb993f3771f7b2dbd40f06fdcbe910271ac/b2sdk-2.12.0.tar.gz", hash = "sha256:da7b65e1af4f59c7de7d079c1e25de6b033aa45090fded4caefe52ded9b13ee6", size = 209696, upload-time = "2026-05-05T15:42:16.04Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0e/114502f1cb4a22c96cc2aaecb56e09898b51de594cd7e831bb728c9c6c5e/b2sdk-2.10.3-py3-none-any.whl", hash = "sha256:62351893bd4098d71cc703f1f4a23fe23ed8f609f509e8662297db10bffa4322", size = 300648, upload-time = "2026-02-22T21:23:16.512Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/ba5d1fa6170651197fe1eed9d70884517f4df6f264a4647f23e61bb21b46/b2sdk-2.12.0-py3-none-any.whl", hash = "sha256:58af4e925b2ed0422b4f2f26ee3f53c7045520ae6ddcbe65b5bc9e4b99725bce", size = 300909, upload-time = "2026-05-05T15:42:14.574Z" }, ] [[package]] From f8921d9d96f3f301f1b574d34ff28704b159eb33 Mon Sep 17 00:00:00 2001 From: Olzhas Arystanov Date: Fri, 1 May 2026 03:20:55 +0700 Subject: [PATCH 2/2] Reject unsafe remote filenames in the Download command --- b2/_internal/console_tool.py | 8 +++ .../+download-path-validation.fixed.md | 1 + test/unit/console_tool/test_download_file.py | 52 ++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 changelog.d/+download-path-validation.fixed.md diff --git a/b2/_internal/console_tool.py b/b2/_internal/console_tool.py index 20488d61..b3a931cf 100644 --- a/b2/_internal/console_tool.py +++ b/b2/_internal/console_tool.py @@ -106,6 +106,7 @@ points_to_fifo, substitute_control_chars, unprintable_to_hex, + validate_b2_file_name_as_path, ) from b2sdk.v3.exception import ( B2Error, @@ -1960,6 +1961,13 @@ def get_local_output_filepath( if not output_filepath.is_dir(): return output_filepath + # Make sure the remote filename is safe to interpret as a local path + try: + validate_b2_file_name_as_path(str(file_request.download_version.file_name)) + except ValueError as exc: + err_msg = unprintable_to_hex(f'{exc}: {output_filepath}') + raise CommandError(err_msg) from exc + # If the output is directory, we're expected to download the file right there. # Normally, we overwrite the target without asking any questions, but in this case # user might be oblivious of the actual mistake he's about to commit. diff --git a/changelog.d/+download-path-validation.fixed.md b/changelog.d/+download-path-validation.fixed.md new file mode 100644 index 00000000..8e897519 --- /dev/null +++ b/changelog.d/+download-path-validation.fixed.md @@ -0,0 +1 @@ +Reject unsafe remote filenames in the Download command. diff --git a/test/unit/console_tool/test_download_file.py b/test/unit/console_tool/test_download_file.py index 1ff725e6..f1d03051 100644 --- a/test/unit/console_tool/test_download_file.py +++ b/test/unit/console_tool/test_download_file.py @@ -176,7 +176,7 @@ def test_download_file_by_name__to_stdout_by_alias( def test_cat__b2_uri(b2_cli, bucket, uploaded_stdout_txt, tmp_path, capfd): b2_cli.run( - ['file', 'cat', '--no-progress', f"b2://{bucket}/{uploaded_stdout_txt['fileName']}"], + ['file', 'cat', '--no-progress', f'b2://{bucket}/{uploaded_stdout_txt["fileName"]}'], ) assert capfd.readouterr().out == uploaded_stdout_txt['content'] @@ -233,3 +233,53 @@ def test__download_file__threads(b2_cli, local_file, uploaded_file, tmp_path): assert output_path.read_text() == uploaded_file['content'] assert b2_cli.console_tool.api.services.download_manager.get_thread_pool_size() == num_threads + + +@pytest.mark.parametrize( + 'remote_name', + [ + '../escape.txt', + 'foo/../bar.txt', + ], +) +def test_download_file_by_uri__directory_rejects_unsupported_server_name( + b2_cli, api_bucket, tmp_path, remote_name +): + uploaded_file = api_bucket.upload_bytes(b'hello world', remote_name) + output_directory = tmp_path / 'downloads' + output_directory.mkdir() + + b2_cli.run( + [ + 'file', + 'download', + '--no-progress', + f'b2id://{uploaded_file.id_}', + str(output_directory), + ], + expected_status=1, + expected_stderr=None, + ) + + local_path = (output_directory / remote_name).resolve() + assert not local_path.exists() + + +def test_download_file_by_uri__explicit_file_target_allows_unsupported_server_name( + b2_cli, api_bucket, tmp_path +): + uploaded_file = api_bucket.upload_bytes(b'hello world', '../escape.txt') + output_path = tmp_path / 'output.txt' + + b2_cli.run( + [ + 'file', + 'download', + '--no-progress', + f'b2id://{uploaded_file.id_}', + str(output_path), + ], + expected_stderr=None, + ) + + assert output_path.read_text() == 'hello world'