diff --git a/ICSharpCode.BamlDecompiler/Rewrite/EscapeInvalidXmlCharactersRewritePass.cs b/ICSharpCode.BamlDecompiler/Rewrite/EscapeInvalidXmlCharactersRewritePass.cs new file mode 100644 index 000000000..2f49e4954 --- /dev/null +++ b/ICSharpCode.BamlDecompiler/Rewrite/EscapeInvalidXmlCharactersRewritePass.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Linq; +using System.Xml.Linq; + +using ICSharpCode.BamlDecompiler.Xaml; + +namespace ICSharpCode.BamlDecompiler.Rewrite +{ + /// + /// Escapes attribute values, text and comments that carry characters XML cannot represent - + /// a string record from an obfuscated assembly may hold any byte sequence. Without this the + /// document builds fine and only throws when it is written, taking the resource with it. + /// Names and namespace URIs are escaped where they are built, so this pass only has to cover + /// the content. + /// + internal class EscapeInvalidXmlCharactersRewritePass : IRewritePass + { + public void Run(XamlContext ctx, XDocument document) + { + foreach (var element in document.Descendants()) + { + foreach (var attribute in element.Attributes()) + { + // Namespace declarations carry a URI that was escaped when the XNamespace was + // created; rewriting it here would desync it from the names using it. + if (!attribute.IsNamespaceDeclaration) + attribute.Value = XamlUtils.EscapeInvalidXmlCharacters(attribute.Value); + } + } + + foreach (var node in document.DescendantNodes().ToList()) + { + switch (node) + { + case XText text: + text.Value = XamlUtils.EscapeInvalidXmlCharacters(text.Value); + break; + case XComment comment: + comment.Value = XamlUtils.EscapeInvalidXmlCharacters(comment.Value); + break; + } + } + } + } +} diff --git a/ICSharpCode.BamlDecompiler/Xaml/XamlType.cs b/ICSharpCode.BamlDecompiler/Xaml/XamlType.cs index a0f619408..09d287c18 100644 --- a/ICSharpCode.BamlDecompiler/Xaml/XamlType.cs +++ b/ICSharpCode.BamlDecompiler/Xaml/XamlType.cs @@ -69,7 +69,7 @@ namespace ICSharpCode.BamlDecompiler.Xaml xmlNs = ctx.XmlNs.LookupXmlns(FullAssemblyName, TypeNamespace); // Sometimes there's no reference to System.Xaml even if x:Type is used if (xmlNs == null) - xmlNs = ctx.TryGetXmlNamespace(Assembly, TypeNamespace); + xmlNs = XamlContext.TryGetXmlNamespace(Assembly, TypeNamespace, elem); if (xmlNs == null) { diff --git a/ICSharpCode.BamlDecompiler/Xaml/XamlUtils.cs b/ICSharpCode.BamlDecompiler/Xaml/XamlUtils.cs index cc26dd7b3..572667f1d 100644 --- a/ICSharpCode.BamlDecompiler/Xaml/XamlUtils.cs +++ b/ICSharpCode.BamlDecompiler/Xaml/XamlUtils.cs @@ -22,6 +22,7 @@ using System.IO; using System.Text; +using System.Xml; using System.Xml.Linq; namespace ICSharpCode.BamlDecompiler.Xaml @@ -37,6 +38,59 @@ namespace ICSharpCode.BamlDecompiler.Xaml return value; } + /// + /// Escapes the characters XML cannot carry - obfuscators put them into BAML strings, and + /// XML 1.0 has no representation for them at all, not even a numeric character reference. + /// The escapes are spelled the way the C# output spells them, so one convention covers + /// both languages: the short form where C# has one, "\uXXXX" otherwise. + /// Characters XML can carry - tab, newline, astral characters - are left untouched, and a + /// literal backslash is not doubled, because XAML itself has no escape syntax to undo. + /// + public static string EscapeInvalidXmlCharacters(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + StringBuilder escaped = null; + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + escaped?.Append(c).Append(value[i + 1]); + i++; + continue; + } + if (XmlConvert.IsXmlChar(c)) + { + escaped?.Append(c); + continue; + } + escaped ??= new StringBuilder(value.Length).Append(value, 0, i); + escaped.Append(EscapeChar(c)); + } + return escaped?.ToString() ?? value; + } + + static string EscapeChar(char c) + { + switch (c) + { + case '\0': + return "\\0"; + case '\a': + return "\\a"; + case '\b': + return "\\b"; + case '\f': + return "\\f"; + case '\v': + return "\\v"; + default: + return "\\u" + ((int)c).ToString("x4"); + } + } + public static string ToString(this XamlContext ctx, XElement elem, XamlType type) { type.ResolveNamespace(elem, ctx); diff --git a/ICSharpCode.BamlDecompiler/XamlContext.cs b/ICSharpCode.BamlDecompiler/XamlContext.cs index f35108c40..5fc1752aa 100644 --- a/ICSharpCode.BamlDecompiler/XamlContext.cs +++ b/ICSharpCode.BamlDecompiler/XamlContext.cs @@ -184,7 +184,12 @@ namespace ICSharpCode.BamlDecompiler return null; if (!xmlnsMap.TryGetValue(xmlns, out var ns)) - xmlnsMap[xmlns] = ns = XNamespace.Get(xmlns); + { + // Every XNamespace is created here, so escaping the URI once keeps the xmlns + // declaration and the names that use it in sync. Doing it later is not possible: + // the URI is baked into every name built from this namespace. + xmlnsMap[xmlns] = ns = XNamespace.Get(XamlUtils.EscapeInvalidXmlCharacters(xmlns)); + } return ns; } @@ -192,7 +197,7 @@ namespace ICSharpCode.BamlDecompiler public const string KnownNamespace_Presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; public const string KnownNamespace_PresentationOptions = "http://schemas.microsoft.com/winfx/2006/xaml/presentation/options"; - public string TryGetXmlNamespace(IModule assembly, string typeNamespace) + public static string TryGetXmlNamespace(IModule assembly, string typeNamespace, XElement context = null) { if (assembly == null) return null; @@ -214,12 +219,35 @@ namespace ICSharpCode.BamlDecompiler possibleXmlNs.Add(xmlNs); } + // An assembly may map one CLR namespace to several XML namespaces; PresentationFramework + // for example maps its namespaces to both the winfx/2006 and the netfx/2007 presentation + // namespace. Whenever the document itself declares one of the candidates, that one has to + // win: picking a different candidate for an element whose start tag carries the xmlns + // declaration redefines the prefix within that tag, which is not valid XML. + var declared = possibleXmlNs.Where(ns => IsDeclaredIn(context, ns)).ToList(); + if (declared.Count > 0) + possibleXmlNs = new HashSet(declared); + if (possibleXmlNs.Contains(KnownNamespace_Presentation)) return KnownNamespace_Presentation; return possibleXmlNs.FirstOrDefault(); } + static bool IsDeclaredIn(XElement context, string xmlNamespace) + { + for (var elem = context; elem != null; elem = elem.Parent) + { + foreach (var attr in elem.Attributes()) + { + if (attr.IsNamespaceDeclaration && attr.Value == xmlNamespace) + return true; + } + } + + return false; + } + public XName GetKnownNamespace(string name, string xmlNamespace, XElement context = null) { var xNs = GetXmlNamespace(xmlNamespace); diff --git a/ICSharpCode.BamlDecompiler/XamlDecompiler.cs b/ICSharpCode.BamlDecompiler/XamlDecompiler.cs index 3aecaec2f..7a1e842c2 100644 --- a/ICSharpCode.BamlDecompiler/XamlDecompiler.cs +++ b/ICSharpCode.BamlDecompiler/XamlDecompiler.cs @@ -44,6 +44,7 @@ namespace ICSharpCode.BamlDecompiler new AttributeRewritePass(), new ConnectionIdRewritePass(), new DocumentRewritePass(), + new EscapeInvalidXmlCharactersRewritePass(), }; private BamlDecompilerTypeSystem typeSystem; diff --git a/ICSharpCode.Decompiler.Tests/DecompilationErrorRecoveryTests.cs b/ICSharpCode.Decompiler.Tests/DecompilationErrorRecoveryTests.cs index c5d28d9b9..3e42c76cc 100644 --- a/ICSharpCode.Decompiler.Tests/DecompilationErrorRecoveryTests.cs +++ b/ICSharpCode.Decompiler.Tests/DecompilationErrorRecoveryTests.cs @@ -17,11 +17,14 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection.PortableExecutable; using ICSharpCode.Decompiler.CSharp; using ICSharpCode.Decompiler.CSharp.OutputVisitor; +using ICSharpCode.Decompiler.CSharp.ProjectDecompiler; using ICSharpCode.Decompiler.CSharp.Syntax; using ICSharpCode.Decompiler.IL; using ICSharpCode.Decompiler.IL.Transforms; @@ -125,5 +128,55 @@ namespace ICSharpCode.Decompiler.Tests } } + /// + /// A .resources container holds every BAML stream of an assembly. One entry the decompiler + /// cannot write - obfuscated BAML that produces characters XML cannot carry, say - must not + /// take the entries next to it down: they are unrelated pages of an unrelated type. + /// + [Test] + public void FailingResourceEntryKeepsTheOtherEntriesOfTheContainer() + { + string location = typeof(DecompilationErrorRecoveryTests).Assembly.Location; + using var stream = new FileStream(location, FileMode.Open, FileAccess.Read); + var module = new PEFile(location, stream, streamOptions: PEStreamOptions.PrefetchEntireImage); + var decompiler = new EntryFailingProjectDecompiler( + new UniversalAssemblyResolver(location, throwOnError: false, module.DetectTargetFrameworkId())); + + var items = decompiler.WriteResources(module).ToList(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(items.Select(i => i.FileName), Does.Contain("good.baml"), + "the entry after the failing one is still written"); + Assert.That(decompiler.Errors, Has.Count.EqualTo(1), "the failure is reported to the caller"); + Assert.That(decompiler.Errors[0].ToString(), Does.Contain("bad.baml"), + "and names the entry that failed"); + } + } + + /// + /// Writes every resource entry as a project item, except the one named "bad.baml", which + /// throws the way a resource handler does when it cannot produce a file. + /// + sealed class EntryFailingProjectDecompiler : WholeProjectDecompiler + { + public EntryFailingProjectDecompiler(IAssemblyResolver assemblyResolver) + : base(assemblyResolver) + { + // Entries this fixture does not override still get written to disk. + TargetDirectory = Directory.CreateTempSubdirectory("ILSpyResourceRecovery").FullName; + } + + public IEnumerable WriteResources(MetadataFile module) + => WriteResourceFilesInProject(module); + + protected override IEnumerable WriteResourceToFile(string fileName, string resourceName, Stream entryStream) + { + if (resourceName == "bad.baml") + throw new NotSupportedException("cannot write bad.baml"); + return new[] { new ProjectItemInfo("Page", fileName) }; + } + } + } } diff --git a/ICSharpCode.Decompiler.Tests/Helpers/TwoStreamEntries.resources b/ICSharpCode.Decompiler.Tests/Helpers/TwoStreamEntries.resources new file mode 100644 index 000000000..a60561492 Binary files /dev/null and b/ICSharpCode.Decompiler.Tests/Helpers/TwoStreamEntries.resources differ diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj index c385749ce..a70de1e14 100644 --- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj +++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj @@ -93,6 +93,12 @@ + + + + + diff --git a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs index 9264e109a..01b908530 100644 --- a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs @@ -506,7 +506,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler List items; try { - items = WriteResourceFileInProject(r).ToList(); + items = WriteResourceFileInProject(module, r).ToList(); } catch (Exception ex) when (!(ex is OperationCanceledException)) { @@ -522,7 +522,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler } } - IEnumerable WriteResourceFileInProject(Resource r) + IEnumerable WriteResourceFileInProject(MetadataFile module, Resource r) { Stream? stream = r.TryOpenStream(); if (stream == null) @@ -549,8 +549,18 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler } Stream entryStream = (Stream)value!; entryStream.Position = 0; - individualResources.AddRange( - WriteResourceToFile(fileName, name, entryStream)); + try + { + individualResources.AddRange( + WriteResourceToFile(fileName, name, entryStream)); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // One entry nobody can decode - a BAML stream carrying characters XML + // cannot represent, say - costs that entry, not every other entry + // sharing the container with it. + RecordError(ex as DecompilerException ?? new DecompilerException(module, $"Error writing resource '{name}'", ex)); + } } decodedIntoIndividualFiles = true; } diff --git a/ILSpy.BamlDecompiler.Tests.Windows/BamlTestRunner.cs b/ILSpy.BamlDecompiler.Tests.Windows/BamlTestRunner.cs index 951d3c5c6..65e7abc25 100644 --- a/ILSpy.BamlDecompiler.Tests.Windows/BamlTestRunner.cs +++ b/ILSpy.BamlDecompiler.Tests.Windows/BamlTestRunner.cs @@ -135,6 +135,12 @@ namespace ILSpy.BamlDecompiler.Tests RunTest("cases/issue1547"); } + [Test] + public void Issue1688() + { + RunTest("cases/issue1688"); + } + [Test] public void Issue2052() { diff --git a/ILSpy.BamlDecompiler.Tests.Windows/Cases/Issue1688.xaml b/ILSpy.BamlDecompiler.Tests.Windows/Cases/Issue1688.xaml new file mode 100644 index 000000000..27b972b5a --- /dev/null +++ b/ILSpy.BamlDecompiler.Tests.Windows/Cases/Issue1688.xaml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ILSpy.BamlDecompiler.Tests.Windows/Cases/Issue1688.xaml.cs b/ILSpy.BamlDecompiler.Tests.Windows/Cases/Issue1688.xaml.cs new file mode 100644 index 000000000..0393a8681 --- /dev/null +++ b/ILSpy.BamlDecompiler.Tests.Windows/Cases/Issue1688.xaml.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Windows; +using System.Windows.Controls; + +namespace ILSpy.BamlDecompiler.Tests.Cases +{ + /// + /// Interaction logic for Issue1688.xaml + /// + public partial class Issue1688 : ContextMenu + { + public Issue1688() + { + InitializeComponent(); + } + + void Click_AssignPlace(object sender, RoutedEventArgs e) + { + } + + void Click_AssignMove(object sender, RoutedEventArgs e) + { + } + } +} diff --git a/ILSpy.BamlDecompiler.Tests.Windows/ILSpy.BamlDecompiler.Tests.Windows.csproj b/ILSpy.BamlDecompiler.Tests.Windows/ILSpy.BamlDecompiler.Tests.Windows.csproj index 1b7afc7dd..1fa27da82 100644 --- a/ILSpy.BamlDecompiler.Tests.Windows/ILSpy.BamlDecompiler.Tests.Windows.csproj +++ b/ILSpy.BamlDecompiler.Tests.Windows/ILSpy.BamlDecompiler.Tests.Windows.csproj @@ -66,6 +66,9 @@ + + Issue1688.xaml + @@ -102,6 +105,9 @@ MSBuild:Compile + + MSBuild:Compile + MSBuild:Compile diff --git a/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj b/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj index 92eb7131d..298a74a03 100644 --- a/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj +++ b/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj @@ -43,7 +43,9 @@ + + diff --git a/ILSpy.BamlDecompiler.Tests/InvalidXmlCharacterTests.cs b/ILSpy.BamlDecompiler.Tests/InvalidXmlCharacterTests.cs new file mode 100644 index 000000000..36a5ecc9d --- /dev/null +++ b/ILSpy.BamlDecompiler.Tests/InvalidXmlCharacterTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.IO; +using System.Reflection.PortableExecutable; + +using ICSharpCode.BamlDecompiler; +using ICSharpCode.BamlDecompiler.Baml; +using ICSharpCode.Decompiler.Metadata; + +using NUnit.Framework; + +namespace ILSpy.BamlDecompiler.Tests +{ + /// + /// Obfuscators put characters into BAML strings that XML cannot carry at all - not even as a + /// numeric character reference. They have to be escaped before they reach the XDocument; + /// otherwise writing the decompiled XAML throws and the whole resource is lost. + /// + [TestFixture] + public class InvalidXmlCharacterTests + { + static ushort TypeId(KnownTypes type) => unchecked((ushort)-(short)type); + + static ushort MemberId(KnownMembers member) => unchecked((ushort)-(short)member); + + /// + /// Builds a BAML stream out of , wrapped in the document + /// start/end records and the header the reader insists on. + /// + static MemoryStream CreateBaml(params BamlRecord[] records) + { + var version = new BamlDocument.BamlVersion { Major = 0, Minor = 0x60 }; + var document = new BamlDocument { + Signature = "MSBAML", + ReaderVersion = version, + UpdaterVersion = version, + WriterVersion = version + }; + document.Add(new DocumentStartRecord()); + document.AddRange(records); + document.Add(new DocumentEndRecord()); + + var stream = new MemoryStream(); + BamlWriter.WriteDocument(document, stream); + stream.Position = 0; + return stream; + } + + /// + /// Decompiles against this test assembly as the main module: the BAML built here refers + /// only to well-known WPF types, which the decompiler resolves without it. + /// + static string Decompile(Stream baml) + { + var location = typeof(InvalidXmlCharacterTests).Assembly.Location; + using var fileStream = new FileStream(location, FileMode.Open, FileAccess.Read); + var file = new PEFile(location, fileStream, streamOptions: PEStreamOptions.PrefetchEntireImage); + var resolver = new UniversalAssemblyResolver(location, throwOnError: false, + file.DetectTargetFrameworkId(), file.DetectRuntimePack()); + var decompiler = new XamlDecompiler(new BamlDecompilerTypeSystem(file, resolver), + new BamlDecompilerSettings()); + return decompiler.Decompile(baml).Xaml.ToString(); + } + + [Test] + public void ControlCharacterInPropertyValue_IsEscaped() + { + string xaml = Decompile(CreateBaml( + new ElementStartRecord { TypeId = TypeId(KnownTypes.Button) }, + new PropertyRecord { + AttributeId = MemberId(KnownMembers.Button_Content), + Value = "a\u0018b" + }, + new ElementEndRecord())); + + Assert.That(xaml, Does.Contain(@"Content=""a\u0018b""")); + } + + [Test] + public void ControlCharacterInNamespaceUri_IsEscaped() + { + // The URI ends up both in the xmlns declaration and in the namespace of every element + // name, so it cannot be repaired after the document has been built. + string xaml = Decompile(CreateBaml( + new ElementStartRecord { TypeId = TypeId(KnownTypes.Button) }, + new XmlnsPropertyRecord { + Prefix = "obf", + XmlNamespace = "clr-namespace:Obfuscated\u0018Namespace", + AssemblyIds = new ushort[0] + }, + new ElementEndRecord())); + + Assert.That(xaml, Does.Contain(@"xmlns:obf=""clr-namespace:Obfuscated\u0018Namespace""")); + } + + [Test] + public void CharactersXmlCanCarry_AreLeftAlone() + { + // Tab, newline and astral characters are valid XML content; escaping them would + // change the output of every ordinary document. + string xaml = Decompile(CreateBaml( + new ElementStartRecord { TypeId = TypeId(KnownTypes.Button) }, + new PropertyRecord { + AttributeId = MemberId(KnownMembers.Button_Content), + Value = "tab\tastral\U0001F600" + }, + new ElementEndRecord())); + + Assert.Multiple(() => { + Assert.That(xaml, Does.Contain("\U0001F600"), "the surrogate pair stays intact"); + Assert.That(xaml, Does.Not.Contain("\\u"), "nothing valid gets escaped"); + }); + } + } +} diff --git a/ILSpy.BamlDecompiler.Tests/XmlNamespaceResolutionTests.cs b/ILSpy.BamlDecompiler.Tests/XmlNamespaceResolutionTests.cs new file mode 100644 index 000000000..dd43459f0 --- /dev/null +++ b/ILSpy.BamlDecompiler.Tests/XmlNamespaceResolutionTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.IO; +using System.Reflection.PortableExecutable; +using System.Xml.Linq; + +using ICSharpCode.BamlDecompiler; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; + +using NUnit.Framework; + +using ILSpy.BamlDecompiler.Tests; + +// This assembly plays the role of an assembly that maps one CLR namespace to two XML namespaces, +// the way PresentationFramework maps its namespaces to both presentation namespaces. +[assembly: System.Windows.Markup.XmlnsDefinition(XmlNamespaceResolutionTests.Winfx2006Presentation, XmlNamespaceResolutionTests.TestClrNamespace)] +[assembly: System.Windows.Markup.XmlnsDefinition(XmlNamespaceResolutionTests.Netfx2007Presentation, XmlNamespaceResolutionTests.TestClrNamespace)] + +namespace System.Windows.Markup +{ + /// + /// Stand-in for the WPF attribute of the same name, which is unavailable on platforms without + /// WPF. The BAML decompiler matches it by full name in metadata, so the declaring assembly does + /// not matter. + /// + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + internal sealed class XmlnsDefinitionAttribute : Attribute + { + public XmlnsDefinitionAttribute(string xmlNamespace, string clrNamespace) + { + XmlNamespace = xmlNamespace; + ClrNamespace = clrNamespace; + } + + public string XmlNamespace { get; } + public string ClrNamespace { get; } + } +} + +namespace ILSpy.BamlDecompiler.Tests +{ + /// + /// Tests for the fallback used when neither the BAML xmlns records nor the PI mappings name an + /// XML namespace for a type: which of the assembly's XmlnsDefinition mappings is picked. + /// + [TestFixture] + public class XmlNamespaceResolutionTests + { + public const string Winfx2006Presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + public const string Netfx2007Presentation = "http://schemas.microsoft.com/netfx/2007/xaml/presentation"; + public const string TestClrNamespace = "ILSpy.BamlDecompiler.Tests"; + + static IModule GetTestAssemblyModule() + { + var location = typeof(XmlNamespaceResolutionTests).Assembly.Location; + using var stream = new FileStream(location, FileMode.Open, FileAccess.Read); + var file = new PEFile(location, stream, streamOptions: PEStreamOptions.PrefetchEntireImage); + var resolver = new UniversalAssemblyResolver(location, throwOnError: false, + file.DetectTargetFrameworkId(), file.DetectRuntimePack()); + return new BamlDecompilerTypeSystem(file, resolver).MainModule; + } + + [Test] + public void PrefersPresentationNamespace_WhenDocumentDeclaresNothing() + { + var xmlNs = XamlContext.TryGetXmlNamespace(GetTestAssemblyModule(), TestClrNamespace); + + Assert.That(xmlNs, Is.EqualTo(Winfx2006Presentation)); + } + + [Test] + public void PrefersNamespaceDeclaredByDocument_OverPresentationNamespace() + { + // Issue #1688: the document binds the default prefix to the netfx/2007 presentation + // namespace. Resolving its elements to the winfx/2006 one made the root start tag both + // declare and redefine the default prefix, which XmlWriter rejects. + var root = new XElement(XName.Get("Root", Netfx2007Presentation), + new XAttribute("xmlns", Netfx2007Presentation)); + + var xmlNs = XamlContext.TryGetXmlNamespace(GetTestAssemblyModule(), TestClrNamespace, root); + + Assert.That(xmlNs, Is.EqualTo(Netfx2007Presentation)); + } + + [Test] + public void PrefersNamespaceDeclaredByAncestor() + { + var child = new XElement(XName.Get("Child", Netfx2007Presentation)); + new XElement(XName.Get("Root", Netfx2007Presentation), + new XAttribute("xmlns", Netfx2007Presentation), child); + + var xmlNs = XamlContext.TryGetXmlNamespace(GetTestAssemblyModule(), TestClrNamespace, child); + + Assert.That(xmlNs, Is.EqualTo(Netfx2007Presentation)); + } + } +}