From f1bc6ab54256e02cfd90099cad1377c96f96a84d Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 1 Sep 2026 06:49:23 +0200 Subject: [PATCH 1/2] Fix #1688: resolve elements to the XML namespace the document declares An assembly can map one CLR namespace to several XML namespaces: PresentationFramework maps its namespaces to both the winfx/2006 and the netfx/2007 presentation namespace. The fallback used when the BAML xmlns records name no namespace for a type always preferred the winfx/2006 one, so a document that binds the default prefix to netfx/2007 ended up with a root start tag that declares one presentation namespace and needs the other for its own name, which XmlWriter rejects. Only the root carries the xmlns declaration, which is why skipping XClassRewritePass worked around it. Assisted-by: Claude:claude-opus-5:Claude Code --- ICSharpCode.BamlDecompiler/Xaml/XamlType.cs | 2 +- ICSharpCode.BamlDecompiler/XamlContext.cs | 25 +++- .../BamlTestRunner.cs | 6 + .../Cases/Issue1688.xaml | 4 + .../Cases/Issue1688.xaml.cs | 42 +++++++ .../ILSpy.BamlDecompiler.Tests.Windows.csproj | 6 + .../ILSpy.BamlDecompiler.Tests.csproj | 1 + .../XmlNamespaceResolutionTests.cs | 115 ++++++++++++++++++ 8 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 ILSpy.BamlDecompiler.Tests.Windows/Cases/Issue1688.xaml create mode 100644 ILSpy.BamlDecompiler.Tests.Windows/Cases/Issue1688.xaml.cs create mode 100644 ILSpy.BamlDecompiler.Tests/XmlNamespaceResolutionTests.cs 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/XamlContext.cs b/ICSharpCode.BamlDecompiler/XamlContext.cs index f35108c40..f65c94cbb 100644 --- a/ICSharpCode.BamlDecompiler/XamlContext.cs +++ b/ICSharpCode.BamlDecompiler/XamlContext.cs @@ -192,7 +192,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 +214,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/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..219ab9ba7 100644 --- a/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj +++ b/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj @@ -44,6 +44,7 @@ + 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)); + } + } +} From bb1d830ee61a592209ce790ba16811b967dc088a Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 1 Sep 2026 06:50:11 +0200 Subject: [PATCH 2/2] Fix #2040: escape BAML strings that XML cannot represent Obfuscators put arbitrary characters into BAML strings, and XML 1.0 has no representation for most control characters - a numeric character reference is invalid for them too. Writing such a document threw ArgumentException from XmlWriter, which loses the resource on project export and shows an exception instead of the page in the UI. The escapes are spelled the way the C# output spells them, so one convention covers both languages. Namespace URIs have to be escaped where the XNamespace is created rather than in the final pass: the URI is baked into every element name built from it, so patching only the xmlns declaration would desync the two. Characters XML can carry stay untouched, so ordinary documents decompile byte-identically. Every BAML stream of an assembly lives in one .resources container, and the recovery around resource writing sat outside the loop over its entries, so a single page that could not be written discarded every other page sharing the container with it. Assisted-by: Claude:claude-opus-5:Claude Code --- .../EscapeInvalidXmlCharactersRewritePass.cs | 62 +++++++++ ICSharpCode.BamlDecompiler/Xaml/XamlUtils.cs | 54 ++++++++ ICSharpCode.BamlDecompiler/XamlContext.cs | 7 +- ICSharpCode.BamlDecompiler/XamlDecompiler.cs | 1 + .../DecompilationErrorRecoveryTests.cs | 53 +++++++ .../Helpers/TwoStreamEntries.resources | Bin 0 -> 269 bytes .../ICSharpCode.Decompiler.Tests.csproj | 6 + .../WholeProjectDecompiler.cs | 18 ++- .../ILSpy.BamlDecompiler.Tests.csproj | 1 + .../InvalidXmlCharacterTests.cs | 131 ++++++++++++++++++ 10 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 ICSharpCode.BamlDecompiler/Rewrite/EscapeInvalidXmlCharactersRewritePass.cs create mode 100644 ICSharpCode.Decompiler.Tests/Helpers/TwoStreamEntries.resources create mode 100644 ILSpy.BamlDecompiler.Tests/InvalidXmlCharacterTests.cs 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/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 f65c94cbb..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; } 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 0000000000000000000000000000000000000000..a60561492e02cb2dff41a8014988873b405699b3 GIT binary patch literal 269 zcmX?i>is@O1_p+SK%5g?SzMBus~417oL^d$oLUTL1*ImYq!#HYR*8GxXUf^%t3Noi54ZC+|=Nl{{sjzU0bQch;FcWPxwes*e}ZIZcpqG__J znW3ezNveT`r81^vrFkWpxv4PQgHubGfQ|xT5D0K|0U@qUE+_N0F|vq4L>WE+X@Mk$ wM1~XwJq8e+%a8+>5=v*t2SQ|FKA?mmCy+==Oi@V9D=Df}V!Z literal 0 HcmV?d00001 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/ILSpy.BamlDecompiler.Tests.csproj b/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj index 219ab9ba7..298a74a03 100644 --- a/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj +++ b/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj @@ -43,6 +43,7 @@ + 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"); + }); + } + } +}