From 5ce2a3b9332fa75e124545504aee3d21ba362080 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:27:09 -0600 Subject: [PATCH 1/2] fix: escape remote names in sync download, copy and error output --- b2sdk/_internal/sync/action.py | 8 ++++---- changelog.d/631.fixed.md | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 changelog.d/631.fixed.md diff --git a/b2sdk/_internal/sync/action.py b/b2sdk/_internal/sync/action.py index 7a7a01e00..f25734478 100644 --- a/b2sdk/_internal/sync/action.py +++ b/b2sdk/_internal/sync/action.py @@ -23,7 +23,7 @@ from ..sync.report import ProgressReport, SyncReport from ..transfer.outbound.outbound_source import OutboundTransferSource from ..transfer.outbound.upload_source import UploadSourceLocalFile -from ..utils.escape import escape_control_chars +from ..utils.escape import escape_control_chars, unprintable_to_hex from .encryption_provider import AbstractSyncEncryptionSettingsProvider from .report import SyncFileReporter @@ -57,7 +57,7 @@ def run(self, bucket: Bucket, reporter: ProgressReport, dry_run: bool = False): self.do_report(bucket, reporter) except Exception as e: logger.exception('an exception occurred in a sync action') - reporter.error(str(self) + ': ' + repr(e) + ' ' + str(e)) + reporter.error(unprintable_to_hex(f'{self}: {e!r} {e}')) raise # Re-throw so we can identify failed actions @abstractmethod @@ -340,7 +340,7 @@ def do_report(self, bucket: Bucket, reporter: ProgressReport) -> None: :param bucket: a Bucket object :param reporter: a place to report errors """ - reporter.print_completion('dnload ' + self.source_path.relative_path) + reporter.print_completion(f'dnload {escape_control_chars(self.source_path.relative_path)}') def __str__(self) -> str: return 'b2_download(%s, %s, %s, %d)' % ( @@ -430,7 +430,7 @@ def do_report(self, bucket: Bucket, reporter: ProgressReport) -> None: :param bucket: a Bucket object :param reporter: a place to report errors """ - reporter.print_completion('copy ' + self.source_path.relative_path) + reporter.print_completion(f'copy {escape_control_chars(self.source_path.relative_path)}') def __str__(self) -> str: return 'b2_copy(%s, %s, %s, %d)' % ( diff --git a/changelog.d/631.fixed.md b/changelog.d/631.fixed.md new file mode 100644 index 000000000..4e690055b --- /dev/null +++ b/changelog.d/631.fixed.md @@ -0,0 +1 @@ +Escape control characters in remote file names printed by sync download, copy, and failed-action messages. From 8b16341ae3c3e557c2658a054218b416f57781c6 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:24:16 -0600 Subject: [PATCH 2/2] test: cover escaping of remote names in sync output --- test/unit/sync/test_sync_escaping.py | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 test/unit/sync/test_sync_escaping.py diff --git a/test/unit/sync/test_sync_escaping.py b/test/unit/sync/test_sync_escaping.py new file mode 100644 index 000000000..92410f673 --- /dev/null +++ b/test/unit/sync/test_sync_escaping.py @@ -0,0 +1,62 @@ +###################################################################### +# +# File: test/unit/sync/test_sync_escaping.py +# +# Copyright 2026 Backblaze Inc. All Rights Reserved. +# +# License https://www.backblaze.com/using_b2_code.html +# +###################################################################### +from __future__ import annotations + +import io +import time + +import pytest +from apiver_deps import B2Folder, LocalFolder, Synchronizer, SyncReport +from apiver_deps_exception import IncompleteSync + +# U+009B (8-bit CSI) is legal in NTFS names, so the error test can create it on Windows too +HOSTILE_NAME = '\x9b31minvoice.pdf' +ESCAPED_HOSTILE_NAME = "'\\x9b31minvoice.pdf'" + + +@pytest.fixture +def source(b2api): + b2api.create_bucket('source', 'allPrivate').upload_bytes(b'x', HOSTILE_NAME) + return B2Folder('source', '', b2api) + + +def _sync(source, dest, out, dry_run=False): + Synchronizer(max_workers=1, dry_run=dry_run).sync_folders( + source_folder=source, + dest_folder=dest, + now_millis=int(time.time() * 1000), + reporter=SyncReport(out, True), + ) + + +def test_download_completion_escapes_remote_name(source, tmp_path): + out = io.StringIO() + _sync(source, LocalFolder(str(tmp_path)), out, dry_run=True) + + assert out.getvalue().rstrip() == f'dnload {ESCAPED_HOSTILE_NAME}' + + +def test_copy_completion_escapes_remote_name(b2api, source): + b2api.create_bucket('dest', 'allPrivate') + out = io.StringIO() + _sync(source, B2Folder('dest', '', b2api), out, dry_run=True) + + assert out.getvalue().rstrip() == f'copy {ESCAPED_HOSTILE_NAME}' + + +@pytest.mark.apiver(from_ver=1) +def test_failed_action_error_escapes_remote_name(source, tmp_path): + (tmp_path / HOSTILE_NAME).mkdir() + out = io.StringIO() + with pytest.raises(IncompleteSync): + _sync(source, LocalFolder(str(tmp_path)), out) + + assert 'b2_download(\\x9b31minvoice.pdf' in out.getvalue() + assert HOSTILE_NAME not in out.getvalue()