Repository: beeware/briefcase
Pull request #1604 (2024-01-14 00:23:27)

Ensure log does not contain ANSI sequences or control codes
## Changes
- ANSI escape sequences and console control codes are stripped from text before being written to the log

## Notes
- This was originally discussed as part of https://github.com/beeware/briefcase/pull/1572
  - Relatedly, I think that PR just needs a little direction to keep going; that is, should the regexes accommodate the ANSI sequences? Or could they be stripped for the purposes of Android log filtering?

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [X] All new features have been tested
- [X] All new features have been documented
- [X] I have read the **CONTRIBUTING.md** file
- [X] I will abide by the code of conduct


Patch:
diff --git a/changes/1604.bugfix.rst b/changes/1604.bugfix.rst
new file mode 100644
index 00000000..e0f9b22e
--- /dev/null
+++ b/changes/1604.bugfix.rst
@@ -0,0 +1,1 @@
+Any ANSI escape sequences or console control codes are now stripped in all output captured in the Briefcase log file.
diff --git a/src/briefcase/console.py b/src/briefcase/console.py
index 18532c98..f5a16dd5 100644
--- a/src/briefcase/console.py
+++ b/src/briefcase/console.py
@@ -13,6 +13,7 @@ from enum import IntEnum
 from pathlib import Path
 
 from rich.console import Console as RichConsole
+from rich.control import strip_control_codes
 from rich.highlighter import RegexHighlighter
 from rich.markup import escape
 from rich.progress import (
@@ -29,6 +30,9 @@ from briefcase import __version__
 # Regex to identify settings likely to contain sensitive information
 SENSITIVE_SETTING_RE = re.compile(r"API|TOKEN|KEY|SECRET|PASS|SIGNATURE", flags=re.I)
 
+# 7-bit C1 ANSI escape sequences
+ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
+
 
 class InputDisabled(Exception):
     def __init__(self):
@@ -37,6 +41,15 @@ class InputDisabled(Exception):
         )
 
 
+def sanitize_text(text: str) -> str:
+    """Remove control codes and ANSI escape sequences from a line of text.
+
+    This is useful for extracting the plain text from the output of third party tools
+    that may be including markup for display in the console.
+    """
+    return ANSI_ESCAPE_RE.sub("", strip_control_codes(text))
+
+
 class RichConsoleHighlighter(RegexHighlighter):
     """Custom Rich highlighter for printing to the console.
 
@@ -102,18 +115,18 @@ class Printer:
         cls.to_log(*messages, stack_offset=stack_offset, **kwargs)
 
     @classmethod
-    def to_console(cls, *renderables, **kwargs):
-        """Specialized print to the console only omitting the log."""
-        cls.console.print(*renderables, **kwargs)
+    def to_console(cls, *messages, **kwargs):
+        """Write only to the console and skip writing to the log."""
+        cls.console.print(*messages, **kwargs)
 
     @classmethod
-    def to_log(cls, *renderables, stack_offset=5, **kwargs):
-        """Specialized print to the log only omitting the console."""
-        cls.log.log(*renderables, _stack_offset=stack_offset, **kwargs)
+    def to_log(cls, *messages, stack_offset=5, **kwargs):
+        """Write only to the log and skip writing to the console."""
+        cls.log.log(*map(sanitize_text, messages), _stack_offset=stack_offset, **kwargs)
 
     @classmethod
     def export_log(cls):
-        """Export the text of the entire log."""
+        """Export the text of the entire log; the log is also cleared."""
         return cls.log.export_text()
 
 
@@ -399,7 +412,7 @@ class Log:
         # build log header and export buffered log from Rich
         uname = platform.uname()
         sanitized_env_vars = "\n".join(
-            f"    {env_var}={value if not SENSITIVE_SETTING_RE.search(env_var) else '********************'}"
+            f"\t{env_var}={value if not SENSITIVE_SETTING_RE.search(env_var) else '********************'}"
             for env_var, value in sorted(command.tools.os.environ.items())
         )
         return (


Test patch:
diff --git a/tests/console/test_Log.py b/tests/console/test_Log.py
index c63486dc..abb962ad 100644
--- a/tests/console/test_Log.py
+++ b/tests/console/test_Log.py
@@ -177,6 +177,7 @@ def test_save_log_to_file_no_exception(mock_now, command, tmp_path):
     logger.error("this is error output")
     logger.print("this is print output")
     logger.print.to_log("this is log output")
+    logger.print.to_log(f"{chr(7)}this is sanitized log output: \u001b[31mred")
     logger.print.to_console("this is console output")
 
     logger.info("this is [bold]info output with markup[/bold]")
@@ -206,6 +207,7 @@ def test_save_log_to_file_no_exception(mock_now, command, tmp_path):
     assert "this is error output" in log_contents
     assert "this is print output" in log_contents
     assert "this is log output" in log_contents
+    assert "this is sanitized log output: red" in log_contents
     assert "this is console output" not in log_contents
     # Environment variables are in the output
     assert "ANDROID_HOME=/androidsdk" in log_contents
diff --git a/tests/console/test_sanitize_text.py b/tests/console/test_sanitize_text.py
new file mode 100644
index 00000000..67c790ff
--- /dev/null
+++ b/tests/console/test_sanitize_text.py
@@ -0,0 +1,33 @@
+import pytest
+
+from briefcase.console import sanitize_text
+
+
+@pytest.mark.parametrize(
+    "input_text, sanitized_text",
+    [
+        (
+            "log output",
+            "log output",
+        ),
+        (
+            "ls\n\x1b[00m\x1b[01;31mexamplefile.zip\x1b[00m\n\x1b[01;31m",
+            "ls\nexamplefile.zip\n",
+        ),
+        (
+            "log output: \u001b[31mRed\u001B[0m",
+            "log output: Red",
+        ),
+        (
+            "\u001b[1mbold log output:\u001b[0m \u001b[4mUnderline\u001b[0m",
+            "bold log output: Underline",
+        ),
+        (
+            f"{chr(7)}{chr(8)}{chr(11)}{chr(12)}{chr(13)}log{chr(7)} output{chr(7)}",
+            "log output",
+        ),
+    ],
+)
+def test_sanitize_text(input_text, sanitized_text):
+    """Text is sanitized as expected."""
+    assert sanitize_text(input_text) == sanitized_text