Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions b2sdk/_internal/sync/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)' % (
Expand Down Expand Up @@ -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)' % (
Expand Down
1 change: 1 addition & 0 deletions changelog.d/631.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Escape control characters in remote file names printed by sync download, copy, and failed-action messages.
62 changes: 62 additions & 0 deletions test/unit/sync/test_sync_escaping.py
Original file line number Diff line number Diff line change
@@ -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()