@@ -617,3 +617,150 @@ async def go():
617617
618618 self .assertEqual (mock .call_history [- 1 ]['redact_url' ], True )
619619 self .assertEqual (mock .redact_url_received , True )
620+
621+
622+ class TestRedactedExceptionPath (unittest .TestCase ):
623+ """End-to-end verification of the URL-bearing exception scrubbing in
624+ `_download_url_async`'s `except` handler.
625+
626+ Real-world trigger: aiohttp's `ClientConnectorError` (and friends) often
627+ embed the full request URL in `str(e)`. Without scrubbing, the
628+ `print(f"DownloadManager: Exception during download: {err_str}")` line
629+ leaks the secret-bearing URL to the serial / REPL log on every failed
630+ download attempt — exactly the case `redact_url=True` is meant to cover.
631+
632+ Strategy: install a fake `aiohttp` module into `sys.modules` so the
633+ function-local `import aiohttp` inside `_download_url_async` resolves to
634+ our fake. The fake's `ClientSession.get(...)` raises a `RuntimeError`
635+ whose message contains the full URL — mimicking aiohttp's behaviour
636+ closely enough to exercise the `if redact_url and url in err_str`
637+ branch deterministically (no network required).
638+ """
639+
640+ def _capture_download_with_failing_aiohttp (self , * , url , redact_url ,
641+ exc_message ):
642+ """Run `_download_url_async(url, redact_url=...)` against a fake
643+ aiohttp that raises `RuntimeError(exc_message)` from `session.get()`.
644+ Returns (captured_print_lines, raised_exception_or_None).
645+ """
646+ import asyncio
647+ import sys
648+ import builtins
649+
650+ # Minimal fake aiohttp surface — only what _download_url_async touches
651+ # before it hits the failure point.
652+ class _FakeClientSession :
653+ def get (self , request_url , ** kwargs ):
654+ # Raise synchronously from .get() — the `async with
655+ # session.get(...) as response:` line will surface this as
656+ # an exception inside the outer try/except.
657+ raise RuntimeError (exc_message )
658+
659+ async def close (self ):
660+ pass
661+
662+ # MicroPython doesn't support `type(sys)(...)` to instantiate a
663+ # fresh module object. The import machinery only checks
664+ # `sys.modules` for the name and binds whatever object it finds —
665+ # so a plain instance with the right attributes works just as
666+ # well for `import aiohttp; aiohttp.ClientSession()`.
667+ class _FakeAiohttp :
668+ pass
669+
670+ fake_aiohttp = _FakeAiohttp ()
671+ fake_aiohttp .ClientSession = _FakeClientSession
672+
673+ # Capture print output without disturbing other tests' stdout.
674+ captured = []
675+ orig_print = builtins .print
676+
677+ def _fake_print (* args , ** kwargs ):
678+ captured .append (" " .join (str (a ) for a in args ))
679+
680+ old_aiohttp = sys .modules .get ("aiohttp" )
681+ sys .modules ["aiohttp" ] = fake_aiohttp
682+ builtins .print = _fake_print
683+ try :
684+ async def _go ():
685+ await DownloadManager ._download_url_async (
686+ url , redact_url = redact_url )
687+
688+ raised = None
689+ try :
690+ asyncio .run (_go ())
691+ except Exception as e :
692+ raised = e
693+ finally :
694+ builtins .print = orig_print
695+ if old_aiohttp is None :
696+ # Don't leave a fake module behind that would mask real
697+ # aiohttp imports in subsequent tests in the same run.
698+ try :
699+ del sys .modules ["aiohttp" ]
700+ except KeyError :
701+ pass
702+ else :
703+ sys .modules ["aiohttp" ] = old_aiohttp
704+
705+ return captured , raised
706+
707+ def test_redact_url_true_scrubs_url_from_exception_print_line (self ):
708+ # The motivating case: the URL embeds a secret (zpub / API key) in
709+ # the path, the aiohttp error embeds that URL in its message, and
710+ # the framework's except-handler print would leak it.
711+ url = ("https://btc1.trezor.io/api/v2/xpub/"
712+ "zpub6qSECRETxpubABCDEFG?details=txs&tokens=derived" )
713+ exc_message = "Cannot connect to host: {} — DNS failure" .format (url )
714+
715+ captured , raised = self ._capture_download_with_failing_aiohttp (
716+ url = url , redact_url = True , exc_message = exc_message )
717+
718+ # The function must still re-raise (the framework's contract is
719+ # that scrubbing only affects logs, not control flow).
720+ self .assertIsNotNone (raised , "exception should still propagate" )
721+
722+ # Find the exception-print line specifically.
723+ exc_lines = [l for l in captured
724+ if "Exception during download:" in l ]
725+ self .assertTrue (exc_lines ,
726+ "expected an 'Exception during download:' "
727+ "print line; got: {}" .format (captured ))
728+
729+ for line in exc_lines :
730+ # The secret-bearing path component must NOT appear in the
731+ # printed exception line.
732+ self .assertFalse (
733+ "zpub6qSECRETxpubABCDEFG" in line ,
734+ "secret-bearing URL substring leaked into exception "
735+ "print: {}" .format (line ))
736+ self .assertFalse (
737+ url in line ,
738+ "full URL leaked into exception print: {}" .format (line ))
739+ # The redacted form must appear (scheme + host preserved,
740+ # path replaced with /...REDACTED...).
741+ self .assertIn ("https://btc1.trezor.io/...REDACTED..." , line )
742+
743+ def test_redact_url_false_preserves_url_in_exception_print_line (self ):
744+ # Regression guard: default behaviour MUST keep the full URL in
745+ # the exception print line so debugging public-URL downloads
746+ # (app icons, OS updates, weather) isn't degraded.
747+ url = "https://example.com/path/with/some/components"
748+ exc_message = "Cannot connect to host: {}" .format (url )
749+
750+ captured , raised = self ._capture_download_with_failing_aiohttp (
751+ url = url , redact_url = False , exc_message = exc_message )
752+
753+ self .assertIsNotNone (raised )
754+
755+ exc_lines = [l for l in captured
756+ if "Exception during download:" in l ]
757+ self .assertTrue (exc_lines )
758+ self .assertTrue (
759+ any (url in l for l in exc_lines ),
760+ "default behaviour should not scrub URL; "
761+ "got lines: {}" .format (exc_lines ))
762+ # And it must NOT redact when not asked to.
763+ self .assertFalse (
764+ any ("...REDACTED..." in l for l in exc_lines ),
765+ "default behaviour should not insert REDACTED placeholder; "
766+ "got lines: {}" .format (exc_lines ))
0 commit comments