Repository: mesonbuild/meson

Issue:
Invalid XML generated in testlog.junit.xml with \x07 in test output
**Describe the bug**

I was running tests with meson and the output of one of my tests contains special character `\x07`. When I run testlog.junit.xml through xmllint, I get an error saying:

`testlog.junit.xml:11045: parser error : PCDATA invalid Char value 7`

I think we should use the right escape characters so that the output is valid XML.

**To Reproduce**

I wrote a simple meson.build file to reproduce this issue. The code in the file is:

```
project('tutorial', 'c')
test('mytest',find_program('python'), args: ['-c','print(u"Test\x07")'])
```

I then ran `meson setup`, followed by `ninja test` to run it and generate the `testlog.junit.xml` file. Then, running:

`xmllint builddir/meson-logs/testlog.junit.xml`  outputs an error message:

```
builddir/meson-logs/testlog.junit.xml:2: parser error : PCDATA invalid Char value 7
e name="mytest" classname="tutorial" time="0.06694579124450684"><system-out>Test
```

**Expected behavior**

I think the character should be correctly escaped so that the output is valid XML.

**system parameters**

OS: MacOS Monterey (12.1)

meson --version
0.61.1
ninja --version
1.10.2





Discussion:
Currently we just stuff the stdout into an `xml.etree.ElementTree.SubElement(suite, 'system-out').text`. This already escapes common markup characters.

What would be the correct way to escape ASCII character 07 "terminal bell"? Not an HTML entity... does CDATA work for this?
we might need to call `str.encode('ascii', 'xmlcharrefreplace')` here
That won't do anything, it produces identical results to `str.encode('utf-8')` in this case. That codec may not be intended to produce valid XML, just XML that has had XML *markup* encoded? i.e. does it actually do anything that ElementTree doesn't do already?
In xml 1.0, what the files claims to be, there is no valid encoding for a few control characters, including \x07. And even in XML 1.1, which corrected that \a, the null byte is still not encodeable. But could be part of test output, I think? Perhaps the easiest would be to just to intentionally double-encode such characters? Arguably that'd be better anyway, since they can't be seen, but could very well be the cause for a test failure.
I ran into the same issue for `\r` (Carriage return).
I have started working on that issue and came up with this solution:

```python
def double_encode_no_valid_chars(stream: [bytes]) -> [bytes]:
    no_valid_encoding_chars = [b'\x07']
    for char in no_valid_encoding_chars:
        stream = stream.replace(char, \
            char.decode('utf-8').encode('unicode_escape'))
    return stream

def decode(stream: T.Union[None, bytes]) -> str:
    if stream is None:
        return ''
    stream = double_encode_no_valid_chars(stream)
    try:
        return stream.decode('utf-8')
    except UnicodeDecodeError:
        return stream.decode('iso-8859-1', errors='ignore')
```

Does this solution sound correct to you or should I try something else?
An alternative to double-encoding could be to invent a fake xml namespace for encoding of non-encodable bytes, like:
```xml
<?xml version='1.0' encoding='utf-8'?>
<testsuite xmlns:b="https://mesonbuild.com/byte">
	Unencodable byte follows: <b:b>07</b:b>
</testsuite>
```
But I'm not sure that's really an improvement over double encoding.
    return stream.decode('iso-8859-1', errors='ignore')

This loses information silently. One possible option would be to use `replace` and store the original data in a separate tag as uuencoded data.
I don't think information loss is a huge issue here - it'd just be removed from testlog.junit.xml, not everywhere else. It'd be good to make it apparent that data has been replaced though. To that, it seems using � / `\ufffd` would make it clear enough?
We got a similar problem in GLib too for jobs compiled in `msvc` (and so running in windows):
 - Error: `XML Parsing Error: not well-formed`
 - CI job: https://gitlab.gnome.org/GNOME/glib/-/jobs/2345921
 - Affected artifact: https://gnome.pages.gitlab.gnome.org/-/glib/-/jobs/2345921/artifacts/_build/meson-logs/testlog.junit.xml (line 17545)

Problematic contents (`\x1b`):

```
***** C:\_R\_BUILDS\K3_3MUAB\0\GNOME\GLIB\SUBPROJECTS\PCRE2-10.40\TESTDATA\grepoutput
  607:  [0mRC=0
  608:  1:[1;31mtriple:        t1_txt  s1_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  609:  
  610:  [0m6:[1;31mtriple:    t3_txt  s2_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  611:  
  612:  [0m8:[1;31mtriple:    t4_txt  s1_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  613:  
  614:  [0m13:[1;31mtriple:   t6_txt  s2_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  615:  
  616:  [0mRC=0
  617:  triple: t1_txt  s1_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  618:  
  619:  triple: t3_txt  s2_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  620:  
  621:  triple: t4_txt  s1_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  622:  
  623:  triple: t6_txt  s2_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  624:  
  625:  RC=0
  626:  1:triple:       t1_txt  s1_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  627:  
  628:  6:triple:       t3_txt  s2_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  629:  
  630:  8:triple:       t4_txt  s1_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  631:  
  632:  13:triple:      t6_txt  s2_tag  s_txt   p_tag   p_txt   o_tag   o_txt
  633:  
  634:  RC=0
  635:  ---------------------------- Test 71 -----------------------------
***** TESTTRYGREP
  608:  [0mRC=0
  609:  ---------------------------- Test 71 -----------------------------
*****

``` 

Resolving patch:
diff --git a/mesonbuild/mtest.py b/mesonbuild/mtest.py
--- a/mesonbuild/mtest.py
+++ b/mesonbuild/mtest.py
@@ -72,6 +72,26 @@
 # Exit if 3 Ctrl-C's are received within one second
 MAX_CTRLC = 3
 
+# Define unencodable xml characters' regex for replacing them with their
+# printable representation
+UNENCODABLE_XML_UNICHRS: T.List[T.Tuple[int, int]] = [
+    (0x00, 0x08), (0x0B, 0x0C), (0x0E, 0x1F), (0x7F, 0x84),
+    (0x86, 0x9F), (0xFDD0, 0xFDEF), (0xFFFE, 0xFFFF)]
+# Not narrow build
+if sys.maxunicode >= 0x10000:
+    UNENCODABLE_XML_UNICHRS.extend([
+        (0x1FFFE, 0x1FFFF), (0x2FFFE, 0x2FFFF),
+        (0x3FFFE, 0x3FFFF), (0x4FFFE, 0x4FFFF),
+        (0x5FFFE, 0x5FFFF), (0x6FFFE, 0x6FFFF),
+        (0x7FFFE, 0x7FFFF), (0x8FFFE, 0x8FFFF),
+        (0x9FFFE, 0x9FFFF), (0xAFFFE, 0xAFFFF),
+        (0xBFFFE, 0xBFFFF), (0xCFFFE, 0xCFFFF),
+        (0xDFFFE, 0xDFFFF), (0xEFFFE, 0xEFFFF),
+        (0xFFFFE, 0xFFFFF), (0x10FFFE, 0x10FFFF)])
+UNENCODABLE_XML_CHR_RANGES = [fr'{chr(low)}-{chr(high)}' for (low, high) in UNENCODABLE_XML_UNICHRS]
+UNENCODABLE_XML_CHRS_RE = re.compile('([' + ''.join(UNENCODABLE_XML_CHR_RANGES) + '])')
+
+
 def is_windows() -> bool:
     platname = platform.system().lower()
     return platname == 'windows'
@@ -1148,14 +1168,21 @@ def parse_res(n: int, name: str, result: str) -> TAPParser.Test:
 
 TestRun.PROTOCOL_TO_CLASS[TestProtocol.RUST] = TestRunRust
 
+# Check unencodable characters in xml output and replace them with
+# their printable representation
+def replace_unencodable_xml_chars(original_str: str) -> str:
+    # [1:-1] is needed for removing `'` characters from both start and end
+    # of the string
+    replacement_lambda = lambda illegal_chr: repr(illegal_chr.group())[1:-1]
+    return UNENCODABLE_XML_CHRS_RE.sub(replacement_lambda, original_str)
 
 def decode(stream: T.Union[None, bytes]) -> str:
     if stream is None:
         return ''
     try:
-        return stream.decode('utf-8')
+        return replace_unencodable_xml_chars(stream.decode('utf-8'))
     except UnicodeDecodeError:
-        return stream.decode('iso-8859-1', errors='ignore')
+        return replace_unencodable_xml_chars(stream.decode('iso-8859-1', errors='ignore'))
 
 async def read_decode(reader: asyncio.StreamReader,
                       queue: T.Optional['asyncio.Queue[T.Optional[str]]'],
diff --git a/unittests/allplatformstests.py b/unittests/allplatformstests.py
--- a/unittests/allplatformstests.py
+++ b/unittests/allplatformstests.py
@@ -59,6 +59,7 @@
 
 from mesonbuild.dependencies.pkgconfig import PkgConfigDependency
 from mesonbuild.build import Target, ConfigurationData, Executable, SharedLibrary, StaticLibrary
+from mesonbuild import mtest
 import mesonbuild.modules.pkgconfig
 from mesonbuild.scripts import destdir_join
 
@@ -398,6 +399,56 @@ def test_static_compile_order(self):
         self.assertTrue(compdb[3]['file'].endswith("libfile4.c"))
         # FIXME: We don't have access to the linker command
 
+    def test_replace_unencodable_xml_chars(self):
+        '''
+        Test that unencodable xml chars are replaced with their
+        printable representation
+        https://github.com/mesonbuild/meson/issues/9894
+        '''
+        # Create base string(\nHello Meson\n) to see valid chars are not replaced
+        base_string_invalid = '\n\x48\x65\x6c\x6c\x6f\x20\x4d\x65\x73\x6f\x6e\n'
+        base_string_valid = '\nHello Meson\n'
+        # Create invalid input from all known unencodable chars
+        invalid_string = (
+            '\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11'
+            '\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x7f'
+            '\x80\x81\x82\x83\x84\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f'
+            '\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e'
+            '\x9f\ufdd0\ufdd1\ufdd2\ufdd3\ufdd4\ufdd5\ufdd6\ufdd7\ufdd8'
+            '\ufdd9\ufdda\ufddb\ufddc\ufddd\ufdde\ufddf\ufde0\ufde1'
+            '\ufde2\ufde3\ufde4\ufde5\ufde6\ufde7\ufde8\ufde9\ufdea'
+            '\ufdeb\ufdec\ufded\ufdee\ufdef\ufffe\uffff')
+        if sys.maxunicode >= 0x10000:
+            invalid_string = invalid_string + (
+                '\U0001fffe\U0001ffff\U0002fffe\U0002ffff'
+                '\U0003fffe\U0003ffff\U0004fffe\U0004ffff'
+                '\U0005fffe\U0005ffff\U0006fffe\U0006ffff'
+                '\U0007fffe\U0007ffff\U0008fffe\U0008ffff'
+                '\U0009fffe\U0009ffff\U000afffe\U000affff'
+                '\U000bfffe\U000bffff\U000cfffe\U000cffff'
+                '\U000dfffe\U000dffff\U000efffe\U000effff'
+                '\U000ffffe\U000fffff\U0010fffe\U0010ffff')
+
+        valid_string = base_string_valid + repr(invalid_string)[1:-1] + base_string_valid
+        invalid_string = base_string_invalid + invalid_string + base_string_invalid
+        broken_xml_stream = invalid_string.encode()
+        decoded_broken_stream = mtest.decode(broken_xml_stream)
+        self.assertEqual(decoded_broken_stream, valid_string)
+
+    def test_replace_unencodable_xml_chars_unit(self):
+        '''
+        Test that unencodable xml chars are replaced with their
+        printable representation
+        https://github.com/mesonbuild/meson/issues/9894
+        '''
+        if not shutil.which('xmllint'):
+            raise SkipTest('xmllint not installed')
+        testdir = os.path.join(self.unit_test_dir, '110 replace unencodable xml chars')
+        self.init(testdir)
+        self.run_tests()
+        junit_xml_logs = Path(self.logdir, 'testlog.junit.xml')
+        subprocess.run(['xmllint', junit_xml_logs], check=True)
+
     def test_run_target_files_path(self):
         '''
         Test that run_targets are run from the correct directory


Test patch:
diff --git a/test cases/unit/110 replace unencodable xml chars/meson.build b/test cases/unit/110 replace unencodable xml chars/meson.build
new file mode 100644
--- /dev/null
+++ b/test cases/unit/110 replace unencodable xml chars/meson.build	
@@ -0,0 +1,4 @@
+project('replace unencodable xml chars')
+
+test_script = find_program('script.py')
+test('main', test_script)
diff --git a/test cases/unit/110 replace unencodable xml chars/script.py b/test cases/unit/110 replace unencodable xml chars/script.py
new file mode 100644
--- /dev/null
+++ b/test cases/unit/110 replace unencodable xml chars/script.py	
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+
+import sys
+
+# Print base string(\nHello Meson\n) to see valid chars are not replaced
+print('\n\x48\x65\x6c\x6c\x6f\x20\x4d\x65\x73\x6f\x6e\n')
+# Print invalid input from all known unencodable chars
+print(
+    '\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11'
+    '\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x7f')
+
+# Cover for potential encoding issues
+try:
+    print(
+        '\x80\x81\x82\x83\x84\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f'
+        '\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e'
+        '\x9f\ufdd0\ufdd1\ufdd2\ufdd3\ufdd4\ufdd5\ufdd6\ufdd7\ufdd8'
+        '\ufdd9\ufdda\ufddb\ufddc\ufddd\ufdde\ufddf\ufde0\ufde1'
+        '\ufde2\ufde3\ufde4\ufde5\ufde6\ufde7\ufde8\ufde9\ufdea'
+        '\ufdeb\ufdec\ufded\ufdee\ufdef\ufffe\uffff')
+except:
+    pass
+
+# Cover for potential encoding issues
+try:
+    if sys.maxunicode >= 0x10000:
+        print(
+            '\U0001fffe\U0001ffff\U0002fffe\U0002ffff'
+            '\U0003fffe\U0003ffff\U0004fffe\U0004ffff'
+            '\U0005fffe\U0005ffff\U0006fffe\U0006ffff'
+            '\U0007fffe\U0007ffff\U0008fffe\U0008ffff'
+            '\U0009fffe\U0009ffff\U000afffe\U000affff'
+            '\U000bfffe\U000bffff\U000cfffe\U000cffff'
+            '\U000dfffe\U000dffff\U000efffe\U000effff'
+            '\U000ffffe\U000fffff\U0010fffe\U0010ffff')
+except:
+    pass