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. 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()