Repository navigation
Fix TRX logger mangling astral-plane (non-BMP) characters #16318
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,6 @@ | |
| using System.Linq; | ||
| using System.Reflection; | ||
| using System.Text; | ||
| using System.Text.RegularExpressions; | ||
| using System.Xml; | ||
|
|
||
| using Microsoft.TestPlatform.Extensions.TrxLogger.ObjectModel; | ||
|
|
@@ -675,20 +674,48 @@ private static void SaveSimpleData(object? value, XmlNode nodeToSaveAt, object? | |
|
|
||
| // From xml spec (http://www.w3.org/TR/xml/#charsets) valid chars: | ||
| // #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] | ||
| // | ||
| // In .NET a string is a sequence of UTF-16 code units, so characters in the | ||
| // [#x10000-#x10FFFF] (astral) range are represented by a surrogate pair: a high | ||
| // surrogate (\uD800-\uDBFF) followed by a low surrogate (\uDC00-\uDFFF). Such a | ||
| // pair is perfectly valid XML and must be preserved as-is, so we look ahead with | ||
| // char.IsSurrogatePair and copy both code units through untouched. | ||
| // | ||
| // A *lone* surrogate, on the other hand, is not a valid Unicode scalar value and | ||
| // is not valid XML - XmlWriter would throw on it - so it still has to be escaped. | ||
| // That is why we cannot simply treat \uD800-\uDFFF as valid: doing so would trade | ||
| // a mangled-string bug for a "we emit unparseable XML" bug, which is worse because | ||
| // it breaks every consumer of the trx at read time. Only properly paired | ||
| // surrogates are allowed through. | ||
| StringBuilder? builder = null; | ||
| for (int i = 0; i < str.Length; i++) | ||
| { | ||
| // IsSurrogatePair also covers the end-of-string case, so a high surrogate as | ||
| // the last character correctly falls through to the invalid branch below. | ||
| if (char.IsSurrogatePair(str, i)) | ||
| { | ||
| builder?.Append(str[i]).Append(str[i + 1]); | ||
| i++; | ||
| continue; | ||
| } | ||
|
|
||
| // we are handling only #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | ||
| // because C# support unicode character in range \u0000 to \uFFFF | ||
| MatchEvaluator evaluator = new(ReplaceInvalidCharacterWithUniCodeEscapeSequence); | ||
| string invalidChar = @"[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD]"; | ||
| return Regex.Replace(str, invalidChar, evaluator); | ||
| } | ||
| char c = str[i]; | ||
| if (IsValidXmlChar(c)) | ||
| { | ||
| builder?.Append(c); | ||
| continue; | ||
| } | ||
|
|
||
| private static string ReplaceInvalidCharacterWithUniCodeEscapeSequence(Match match) | ||
| { | ||
| char x = match.Value[0]; | ||
| return $@"\u{(ushort)x:x4}"; | ||
| builder ??= new StringBuilder(str.Length).Append(str, 0, i); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this capacity hint is guaranteed to be too small. We only ever get here because a char needs escaping, and each escaped code unit expands 1 -> 6 chars, so the builder will always have to grow at least once. testfx solved the identical problem in private static int GetInvalidXmlCharBuilderCapacity(int length)
{
int extraCapacity = length / 2;
return length <= int.MaxValue - extraCapacity ? length + extraCapacity : length;
}They also use the |
||
| builder.Append(@"\u").Append(((ushort)c).ToString("x4", CultureInfo.InvariantCulture)); | ||
| } | ||
|
|
||
| return builder?.ToString() ?? str; | ||
| } | ||
|
|
||
| private static bool IsValidXmlChar(char c) | ||
| => c is '\x09' or '\x0A' or '\x0D' or (>= '\x20' and <= '\uD7FF') or (>= '\uE000' and <= '\uFFFD'); | ||
|
|
||
| private XmlNode? EnsureLocationExists(XmlElement xml, string location, string? nameSpaceUri) | ||
| { | ||
| XmlNode? node = LocationToXmlNode(xml, location); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT license. See LICENSE file in the project root for full license information. | ||
|
|
||
| using System.Xml; | ||
|
|
||
| using Microsoft.TestPlatform.Extensions.TrxLogger.XML; | ||
| using Microsoft.VisualStudio.TestTools.UnitTesting; | ||
|
|
||
|
|
@@ -15,23 +17,138 @@ public void SaveObjectShouldReplaceInvalidCharacter() | |
| XmlPersistence xmlPersistence = new(); | ||
| var node = xmlPersistence.CreateRootElement("TestRun"); | ||
|
|
||
| // we are handling only #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | ||
| char[] invalidXmlCharacterArray = new char[7]; | ||
| // we are handling only #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] plus | ||
| // well-formed surrogate pairs, which encode [#x10000-#x10FFFF]. | ||
| char[] invalidXmlCharacterArray = new char[5]; | ||
| invalidXmlCharacterArray[0] = (char)0x5; | ||
| invalidXmlCharacterArray[1] = (char)0xb; | ||
| invalidXmlCharacterArray[2] = (char)0xf; | ||
| invalidXmlCharacterArray[3] = (char)0xd800; | ||
| invalidXmlCharacterArray[4] = (char)0xdc00; | ||
| invalidXmlCharacterArray[5] = (char)0xfffe; | ||
| invalidXmlCharacterArray[6] = (char)0x0; | ||
| invalidXmlCharacterArray[3] = (char)0xfffe; | ||
| invalidXmlCharacterArray[4] = (char)0x0; | ||
|
|
||
| string strWithInvalidCharForXml = new(invalidXmlCharacterArray); | ||
| XmlPersistence.SaveObject(strWithInvalidCharForXml, node, null, "dummy"); | ||
|
|
||
| string expectedResult = "\\u0005\\u000b\\u000f\\ud800\\udc00\\ufffe\\u0000"; | ||
| string expectedResult = "\\u0005\\u000b\\u000f\\ufffe\\u0000"; | ||
| Assert.AreEqual(0, string.Compare(expectedResult, node.InnerXml)); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void SaveObjectShouldNotReplaceAdjacentHighAndLowSurrogate() | ||
| { | ||
| // 0xd800 immediately followed by 0xdc00 is not two invalid characters - it is the | ||
| // UTF-16 encoding of U+10000, which the XML spec lists as valid. It must round-trip | ||
| // unescaped. This case used to be asserted the other way around. | ||
| XmlPersistence xmlPersistence = new(); | ||
| var node = xmlPersistence.CreateRootElement("TestRun"); | ||
|
|
||
| string validSurrogatePair = new(new[] { (char)0xd800, (char)0xdc00 }); | ||
| XmlPersistence.SaveObject(validSurrogatePair, node, null, "dummy"); | ||
|
|
||
| Assert.AreEqual(validSurrogatePair, node.InnerXml); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void SaveObjectShouldNotReplaceWellFormedSurrogatePairInElementText() | ||
| { | ||
| // A surrogate pair encodes a character in [#x10000-#x10FFFF], which the XML spec | ||
| // lists as valid, so it must survive untouched. Here U+1F389 (🎉). | ||
| XmlPersistence xmlPersistence = new(); | ||
| var node = xmlPersistence.CreateRootElement("TestRun"); | ||
|
|
||
| XmlPersistence.SaveObject("party \U0001F389 done", node, null, "dummy"); | ||
|
|
||
| Assert.AreEqual("party \U0001F389 done", node.InnerXml); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void SaveObjectShouldNotReplaceWellFormedSurrogatePairInAttributeValue() | ||
| { | ||
| XmlPersistence xmlPersistence = new(); | ||
| var node = xmlPersistence.CreateRootElement("TestRun"); | ||
|
|
||
| xmlPersistence.SaveObject("party \U0001F389 done", node, "@testName", null); | ||
|
|
||
| Assert.AreEqual("party \U0001F389 done", node.GetAttribute("testName")); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void SaveObjectShouldReplaceLoneHighSurrogate() | ||
| { | ||
| // Unlike a pair, a lone high surrogate is not a valid Unicode scalar value and is | ||
| // not valid XML (XmlWriter would throw on it), so it must still be escaped. | ||
| XmlPersistence xmlPersistence = new(); | ||
| var node = xmlPersistence.CreateRootElement("TestRun"); | ||
|
|
||
| XmlPersistence.SaveObject("a" + (char)0xd800 + "b", node, null, "dummy"); | ||
|
|
||
| Assert.AreEqual("a\\ud800b", node.InnerXml); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void SaveObjectShouldReplaceLoneLowSurrogate() | ||
| { | ||
| // A low surrogate that is not preceded by a high surrogate is equally invalid. | ||
| XmlPersistence xmlPersistence = new(); | ||
| var node = xmlPersistence.CreateRootElement("TestRun"); | ||
|
|
||
| XmlPersistence.SaveObject((char)0xdc00 + "b", node, null, "dummy"); | ||
|
|
||
| Assert.AreEqual("\\udc00b", node.InnerXml); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void SaveObjectShouldReplaceHighSurrogateAtEndOfString() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Broader point about the test strategy rather than this specific test: every new test targets
|
||
| { | ||
| // The pair-matching logic must not read past the end of the string. | ||
| XmlPersistence xmlPersistence = new(); | ||
| var node = xmlPersistence.CreateRootElement("TestRun"); | ||
|
|
||
| XmlPersistence.SaveObject("ab" + (char)0xd800, node, null, "dummy"); | ||
|
|
||
| Assert.AreEqual("ab\\ud800", node.InnerXml); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void SaveObjectShouldHandleMixOfValidPairLoneSurrogateAndInvalidCharacters() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This mixed-input test is the best one in the set — it's the one that would actually catch a regression in the backfill index. Three edges are still unpinned, and they're precisely the ones that break if someone later "simplifies" the surrogate branch:
All three behave correctly today (I checked), so this is purely about locking the behaviour down. A single |
||
| { | ||
| XmlPersistence xmlPersistence = new(); | ||
| var node = xmlPersistence.CreateRootElement("TestRun"); | ||
|
|
||
| XmlPersistence.SaveObject("\U0001F389" + (char)0xd800 + "\v" + (char)0xdc00, node, null, "dummy"); | ||
|
|
||
| // The pair is preserved; the lone high surrogate, the vertical tab and the | ||
| // trailing lone low surrogate are all escaped. | ||
| Assert.AreEqual("\U0001F389\\ud800\\u000b\\udc00", node.InnerXml); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void SaveObjectShouldNotReplaceNonAsciiBmpCharacters() | ||
| { | ||
| XmlPersistence xmlPersistence = new(); | ||
| var node = xmlPersistence.CreateRootElement("TestRun"); | ||
|
|
||
| XmlPersistence.SaveObject("Grüße 日本語 Čau", node, null, "dummy"); | ||
|
|
||
| Assert.AreEqual("Grüße 日本語 Čau", node.InnerXml); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void SaveObjectShouldProduceWellFormedXmlWithAstralCharacters() | ||
| { | ||
| XmlPersistence xmlPersistence = new(); | ||
| var node = xmlPersistence.CreateRootElement("TestRun"); | ||
|
|
||
| xmlPersistence.SaveObject("party \U0001F389 done", node, "@testName", null); | ||
| XmlPersistence.SaveObject("output \U0001F389 here", node, null, "dummy"); | ||
|
|
||
| XmlDocument document = new(); | ||
| document.LoadXml(node.OuterXml); | ||
|
|
||
| Assert.AreEqual("party \U0001F389 done", document.DocumentElement!.GetAttribute("testName")); | ||
| Assert.AreEqual("output \U0001F389 here", document.DocumentElement.InnerText); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void SaveObjectShouldNotReplaceValidCharacter() | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit:
builder?.Append(str, i, 2)says the same thing in one call and makes it obvious the two code units are copied as a unit. The chained form is also slightly subtle in that the null-conditional short-circuits the whole chain — correct here, but it's the kind of thing that looks like a bug on a fast read.