// Copyright (c) 2016 Daniel Grunwald // // 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.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Reflection.Metadata; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; using ICSharpCode.Decompiler.CSharp.OutputVisitor; using ICSharpCode.Decompiler.CSharp.Syntax; using ICSharpCode.Decompiler.CSharp.Transforms; using ICSharpCode.Decompiler.DebugInfo; using ICSharpCode.Decompiler.Instrumentation; using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.Semantics; using ICSharpCode.Decompiler.Solution; using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.Decompiler.Util; using static ICSharpCode.Decompiler.Metadata.MetadataExtensions; #nullable enable namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler { /// /// Decompiles an assembly into a visual studio project file. /// public class WholeProjectDecompiler : IProjectInfoProvider { const int maxSegmentLength = 255; #region Settings /// /// Gets the setting this instance uses for decompiling. /// public DecompilerSettings Settings { get; } LanguageVersion? languageVersion; /// /// The C# language version written into the exported project file as LangVersion. /// This is an export parameter, not decompiler state: when not set explicitly, it defaults /// to of the current settings, /// and an explicit value below that minimum is rejected (here and again when the export /// starts) because the emitted code could not compile under it. /// public LanguageVersion LanguageVersion { get { return languageVersion ?? Settings.GetMinimumRequiredVersion(); } set { ValidateLanguageVersion(value); languageVersion = value; } } void ValidateLanguageVersion(LanguageVersion version) { var minVersion = Settings.GetMinimumRequiredVersion(); if (version < minVersion) { throw new InvalidOperationException($"The chosen settings require at least {minVersion}." + " Please change the DecompilerSettings accordingly."); } } bool IProjectInfoProvider.CheckForOverflowUnderflow => Settings.CheckForOverflowUnderflow; public IAssemblyResolver AssemblyResolver { get; } public IAssemblyReferenceClassifier AssemblyReferenceClassifier { get; } public IDebugInfoProvider? DebugInfoProvider { get; } /// /// The MSBuild ProjectGuid to use for the new project. /// public Guid ProjectGuid { get; } /// /// The target directory that the decompiled files are written to. /// /// /// This property is set by DecompileProject() and protected so that overridden protected members /// can access it. /// public string TargetDirectory { get; protected set; } = string.Empty; /// /// Path to the snk file to use for signing. /// null to not sign. /// public string? StrongNameKeyFile { get; set; } public int MaxDegreeOfParallelism { get; set; } = Environment.ProcessorCount; public IProgress? ProgressIndicator { get; set; } #endregion public WholeProjectDecompiler(IAssemblyResolver assemblyResolver) : this(new DecompilerSettings(), assemblyResolver, projectWriter: null, assemblyReferenceClassifier: null, debugInfoProvider: null) { } public WholeProjectDecompiler( DecompilerSettings settings, IAssemblyResolver assemblyResolver, IProjectFileWriter? projectWriter, IAssemblyReferenceClassifier? assemblyReferenceClassifier, IDebugInfoProvider? debugInfoProvider) : this(settings, Guid.NewGuid(), assemblyResolver, projectWriter ?? IProjectFileWriter.FromSettings(settings), assemblyReferenceClassifier ?? new AssemblyReferenceClassifier(), debugInfoProvider) { } protected WholeProjectDecompiler( DecompilerSettings settings, Guid projectGuid, IAssemblyResolver assemblyResolver, IProjectFileWriter projectWriter, IAssemblyReferenceClassifier assemblyReferenceClassifier, IDebugInfoProvider? debugInfoProvider) { Settings = settings ?? throw new ArgumentNullException(nameof(settings)); ProjectGuid = projectGuid; AssemblyResolver = assemblyResolver ?? throw new ArgumentNullException(nameof(assemblyResolver)); AssemblyReferenceClassifier = assemblyReferenceClassifier ?? throw new ArgumentNullException(nameof(assemblyReferenceClassifier)); DebugInfoProvider = debugInfoProvider; this.projectWriter = projectWriter ?? throw new ArgumentNullException(nameof(projectWriter)); } // per-run members HashSet directories = new HashSet(Platform.FileNameComparer); readonly Dictionary resourceFileNames = new Dictionary(Platform.FileNameComparer); readonly List errors = new List(); readonly IProjectFileWriter projectWriter; /// /// Everything that went wrong during the last . /// An export never aborts on a member, file or resource it cannot handle; it writes the /// error text where the content would have gone and continues, so a single unsupported /// method still yields a complete project. Callers should show this list to the user - /// otherwise the failures ship silently and never get reported. /// public IReadOnlyList Errors => errors; void RecordError(DecompilerException error) { lock (errors) { errors.Add(error); } } /// /// Yields the items of until one of them throws; the failure is /// recorded instead of aborting the export. /// IEnumerable RecordingErrors(IEnumerable items, MetadataFile file, string what) { using var enumerator = items.GetEnumerator(); bool lastMoveFailed = false; while (true) { T item; try { if (!enumerator.MoveNext()) yield break; item = enumerator.Current; } catch (Exception ex) when (!(ex is OperationCanceledException)) { RecordError(ex as DecompilerException ?? new DecompilerException(file, $"Error writing {what}", ex)); // Skip the item that failed and try the next one, but give up once two attempts // in a row fail: an enumerator that throws without advancing - which nothing // stops an override from being - would otherwise loop forever. if (lastMoveFailed) yield break; lastMoveFailed = true; continue; } lastMoveFailed = false; yield return item; } } /// /// Puts the error text where the file's contents would have gone. The writer itself may be /// what failed - a full disk, a stream already closed - so a second failure while reporting /// the first is dropped rather than allowed to take the export down. /// static void WriteErrorComment(TextWriter? writer, Exception error) { if (writer == null) return; try { // The failure may have interrupted the output visitor mid-line. writer.WriteLine(); foreach (string line in CSharpDecompiler.GetErrorCommentLines(error)) { writer.WriteLine("// " + line); } } catch (Exception ex) when (!(ex is OperationCanceledException)) { } } public void DecompileProject(MetadataFile file, string targetDirectory, CancellationToken cancellationToken = default(CancellationToken)) { string projectFileName = Path.Combine(targetDirectory, CleanUpFileName(file.Name, ".csproj")); using (var writer = CreateFile(projectFileName)) { DecompileProject(file, targetDirectory, writer, cancellationToken); } } public ProjectId DecompileProject(MetadataFile file, string targetDirectory, TextWriter projectFileWriter, CancellationToken cancellationToken = default(CancellationToken)) { if (string.IsNullOrEmpty(targetDirectory)) { throw new InvalidOperationException("Must set TargetDirectory"); } // The LanguageVersion setter already rejects a version below what the settings require, // but Settings is mutable and shared, so re-validate against the settings actually in // effect now - otherwise the exported project would carry a LangVersion under which the // emitted code cannot compile. if (languageVersion is { } explicitVersion) { ValidateLanguageVersion(explicitVersion); } DecompilerEventSource.Log.ProjectDecompilationStart(file.Name); int codeFileCount = 0, resourceFileCount = 0; try { TargetDirectory = targetDirectory; directories.Clear(); resourceFileNames.Clear(); errors.Clear(); var resources = RecordingErrors(WriteResourceFilesInProject(file), file, "resource files").ToList(); resourceFileCount = resources.Count; var files = WriteCodeFilesInProject(file, resources.SelectMany(r => r.PartialTypes ?? Enumerable.Empty()).ToList(), cancellationToken).ToList(); codeFileCount = files.Count; files.AddRange(resources); var module = file as PEFile; if (module != null) { files.AddRange(RecordingErrors(WriteMiscellaneousFilesInProject(module), file, "miscellaneous files")); } if (StrongNameKeyFile != null) { File.Copy(StrongNameKeyFile, Path.Combine(targetDirectory, Path.GetFileName(StrongNameKeyFile)), overwrite: true); } projectWriter.Write(projectFileWriter, this, files, file); string platformName = module != null ? TargetServices.GetPlatformName(module) : "AnyCPU"; return new ProjectId(platformName, ProjectGuid, ProjectTypeGuids.CSharpWindows); } finally { DecompilerEventSource.Log.ProjectDecompilationStop(file.Name, codeFileCount, resourceFileCount); } } #region WriteCodeFilesInProject protected virtual bool IncludeTypeWhenDecompilingProject(MetadataFile module, TypeDefinitionHandle type) { var metadata = module.Metadata; var typeDef = metadata.GetTypeDefinition(type); string name = metadata.GetString(typeDef.Name); string ns = metadata.GetString(typeDef.Namespace); if (name == "" || CSharpDecompiler.MemberIsHidden(module, type, Settings)) return false; if (ns == "XamlGeneratedNamespace" && name == "GeneratedInternalTypeHelper") return false; if (!typeDef.IsNested && RemoveEmbeddedAttributes.attributeNames.Contains(ns + "." + name)) return false; return true; } protected virtual TextWriter CreateFile(string path) { return new StreamWriter(path); } protected virtual void CreateDirectory(string path) { try { Directory.CreateDirectory(path); } catch (IOException) { File.Delete(path); Directory.CreateDirectory(path); } } protected virtual CSharpDecompiler CreateDecompiler(DecompilerTypeSystem ts) { var decompiler = new CSharpDecompiler(ts, Settings); decompiler.DebugInfoProvider = DebugInfoProvider; decompiler.AstTransforms.Add(new EscapeInvalidIdentifiers()); decompiler.AstTransforms.Add(new RemoveCLSCompliantAttribute()); return decompiler; } IEnumerable WriteAssemblyInfo(DecompilerTypeSystem ts, CancellationToken cancellationToken) { var decompiler = CreateDecompiler(ts); decompiler.CancellationToken = cancellationToken; decompiler.AstTransforms.Add(new RemoveCompilerGeneratedAssemblyAttributes()); SyntaxTree syntaxTree = decompiler.DecompileModuleAndAssemblyAttributes(); const string prop = "Properties"; if (directories.Add(prop)) CreateDirectory(Path.Combine(TargetDirectory, prop)); string assemblyInfo = Path.Combine(prop, "AssemblyInfo.cs"); using (var w = CreateFile(Path.Combine(TargetDirectory, assemblyInfo))) { syntaxTree.AcceptVisitor(new CSharpOutputVisitor(w, Settings.CSharpFormattingOptions)); } return new[] { new ProjectItemInfo("Compile", assemblyInfo) }; } IEnumerable WriteCodeFilesInProject(MetadataFile module, IList partialTypes, CancellationToken cancellationToken) { var metadata = module.Metadata; var files = module.Metadata.GetTopLevelTypeDefinitions().Where(td => IncludeTypeWhenDecompilingProject(module, td)) .GroupBy(GetFileFileNameForHandle, StringComparer.OrdinalIgnoreCase).ToList(); var progressReporter = ProgressIndicator; var progress = new DecompilationProgress { TotalUnits = files.Count, Title = "Exporting project..." }; DecompilerTypeSystem ts = new DecompilerTypeSystem(module, AssemblyResolver, Settings); var missingFiles = new ConcurrentBag(); var workList = new HashSet(); var processedTypes = new HashSet(); ProcessFiles(files); while (workList.Count > 0) { var additionalFiles = workList .GroupBy(GetFileFileNameForHandle, StringComparer.OrdinalIgnoreCase).ToList(); workList.Clear(); ProcessFiles(additionalFiles); files.AddRange(additionalFiles); progress.TotalUnits = files.Count; } // The assembly-level attributes are a single file like any other: failing to decompile // them costs that file, not the export. IEnumerable assemblyInfo; try { assemblyInfo = WriteAssemblyInfo(ts, cancellationToken); } catch (Exception ex) when (!(ex is OperationCanceledException)) { RecordError(ex as DecompilerException ?? new DecompilerException(module, "Error decompiling the module and assembly attributes", ex)); assemblyInfo = Enumerable.Empty(); } return files.Select(f => f.Key).Except(missingFiles, Platform.FileNameComparer) .Select(f => new ProjectItemInfo("Compile", f)).Concat(assemblyInfo); string GetFileFileNameForHandle(TypeDefinitionHandle h) { // A code-behind class belongs to the document it completes: WPF tooling expects // MainWindow.xaml.cs beside MainWindow.xaml, and treats a stray MainWindow.cs // elsewhere in the tree as an unrelated file. foreach (var partialType in partialTypes) { if (partialType.DeclaringTypeDefinitionHandle == h && partialType.CompanionFileName != null) { string companionDirectory = Path.GetDirectoryName(partialType.CompanionFileName)!; if (!string.IsNullOrEmpty(companionDirectory) && directories.Add(companionDirectory)) CreateDirectory(Path.Combine(TargetDirectory, companionDirectory)); return partialType.CompanionFileName + ".cs"; } } var type = metadata.GetTypeDefinition(h); string fileName = GetFileNameForType(metadata.GetString(type.Namespace), metadata.GetString(type.Name), ".cs"); string directory = Path.GetDirectoryName(fileName)!; if (!string.IsNullOrEmpty(directory) && directories.Add(directory)) CreateDirectory(Path.Combine(TargetDirectory, directory)); return fileName; } void ProcessFiles(List> files) { processedTypes.AddRange(files.SelectMany(f => f)); Parallel.ForEach( Partitioner.Create(files, loadBalance: true), new ParallelOptions { MaxDegreeOfParallelism = this.MaxDegreeOfParallelism, CancellationToken = cancellationToken }, delegate (IGrouping file) { var declaredTypes = file.ToArray(); DecompilerEventSource.Log.ProjectFileStart(file.Key, declaredTypes.Length); // Everything that can fail for this one file - creating it included, which is // where a path too long for the file system surfaces - belongs inside the try. TextWriter? w = null; CSharpDecompiler? decompiler = null; try { w = CreateFile(Path.Combine(TargetDirectory, file.Key)); decompiler = CreateDecompiler(ts); foreach (var partialType in partialTypes) { decompiler.AddPartialTypeDefinition(partialType); } decompiler.CancellationToken = cancellationToken; var syntaxTree = decompiler.DecompileTypes(declaredTypes); foreach (var node in syntaxTree.Descendants) { var td = (node.GetResolveResult() as TypeResolveResult)?.Type.GetDefinition(); if (td?.ParentModule != ts.MainModule) continue; while (td?.DeclaringTypeDefinition != null) { td = td.DeclaringTypeDefinition; } if (td != null && td.MetadataToken is { IsNil: false } token && !processedTypes.Contains((TypeDefinitionHandle)token)) { lock (workList) { workList.Add((TypeDefinitionHandle)token); } } } // A member the output visitor cannot write is replaced by the error text // rather than truncating the file where it failed. var outputVisitor = new ErrorTolerantOutputVisitor(w, Settings.CSharpFormattingOptions); syntaxTree.AcceptVisitor(outputVisitor); foreach (var outputError in outputVisitor.Errors) { RecordError(new DecompilerException(module, $"Error writing '{file.Key}'", outputError)); } } catch (Exception innerException) when (!(innerException is OperationCanceledException)) { // Whatever the decompiler could not cope with here, the remaining files // are unaffected and the user still gets a complete project; the error // takes the place of the file's contents. RecordError(innerException as DecompilerException ?? new DecompilerException(module, $"Error decompiling for '{file.Key}'", innerException)); if (w == null) { // Nothing was written, so nothing can carry the error text - and the // project must not claim a file that is not there. missingFiles.Add(file.Key); } WriteErrorComment(w, innerException); } finally { foreach (var error in decompiler?.Errors ?? (IReadOnlyList)Array.Empty()) { RecordError(error); } try { w?.Dispose(); } catch (Exception ex) when (!(ex is OperationCanceledException)) { // Dispose flushes: on a full disk this is where the write actually fails. RecordError(new DecompilerException(module, $"Error writing '{file.Key}'", ex)); } DecompilerEventSource.Log.ProjectFileStop(file.Key); } progress.Status = file.Key; Interlocked.Increment(ref progress.UnitsCompleted); progressReporter?.Report(progress); }); } } #endregion #region WriteResourceFilesInProject protected virtual IEnumerable WriteResourceFilesInProject(MetadataFile module) { foreach (var r in module.Resources.Where(r => r.ResourceType == ResourceType.Embedded)) { List items; try { items = WriteResourceFileInProject(module, r).ToList(); } catch (Exception ex) when (!(ex is OperationCanceledException)) { // One resource nobody can decode - a mangled .resources blob, a BAML stream the // decompiler chokes on - costs that resource, not the ones behind it. RecordError(ex as DecompilerException ?? new DecompilerException(module, $"Error writing resource '{r.Name}'", ex)); continue; } foreach (var item in items) { yield return item; } } } IEnumerable WriteResourceFileInProject(MetadataFile module, Resource r) { Stream? stream = r.TryOpenStream(); if (stream == null) yield break; stream.Position = 0; if (r.Name.EndsWith(".resources", StringComparison.OrdinalIgnoreCase)) { bool decodedIntoIndividualFiles; var individualResources = new List(); try { var resourcesFile = new ResourcesFile(stream); if (resourcesFile.AllEntriesAreStreams()) { bool entryNamesAreEscaped = IsWpfGeneratedResourceContainer(r.Name); foreach (var (name, value) in resourcesFile) { string fileName = ReserveResourceFileName(SanitizeFileName(entryNamesAreEscaped ? Uri.UnescapeDataString(name) : name)); string? dirName = Path.GetDirectoryName(fileName); Stream entryStream = (Stream)value!; entryStream.Position = 0; try { // Inside the recovery, because an entry named after a directory another // entry needs makes this throw, and that must cost the one entry rather // than every entry left in the container. if (!string.IsNullOrEmpty(dirName) && !directories.Contains(dirName)) { CreateDirectory(Path.Combine(TargetDirectory, dirName)); directories.Add(dirName); } foreach (var item in WriteResourceToFile(fileName, name, entryStream)) { individualResources.Add(entryNamesAreEscaped ? ToWpfProjectItem(item, name) : item); } } 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; } else { decodedIntoIndividualFiles = false; } } catch (BadImageFormatException) { decodedIntoIndividualFiles = false; } catch (EndOfStreamException) { decodedIntoIndividualFiles = false; } if (decodedIntoIndividualFiles) { foreach (var entry in individualResources) { yield return entry; } } else { stream.Position = 0; string fileName = ReserveResourceFileName(GetFileNameForResource(r.Name)); foreach (var entry in WriteResourceToFile(fileName, r.Name, stream)) { yield return entry; } } } else { string fileName = ReserveResourceFileName(GetFileNameForResource(r.Name)); using (FileStream fs = new FileStream(Path.Combine(TargetDirectory, fileName), FileMode.Create, FileAccess.Write)) { stream.Position = 0; stream.CopyTo(fs); } yield return new ProjectItemInfo("EmbeddedResource", fileName).With("LogicalName", r.Name); } } protected virtual IEnumerable WriteResourceToFile(string fileName, string resourceName, Stream entryStream) { if (fileName.EndsWith(".resources", StringComparison.OrdinalIgnoreCase)) { string resx = Path.ChangeExtension(fileName, ".resx"); try { using (FileStream fs = new FileStream(Path.Combine(TargetDirectory, resx), FileMode.Create, FileAccess.Write)) using (ResXResourceWriter writer = new ResXResourceWriter(fs)) { foreach (var entry in new ResourcesFile(entryStream)) { writer.AddResource(entry.Key, entry.Value); } } return new[] { new ProjectItemInfo("EmbeddedResource", resx).With("LogicalName", resourceName) }; } catch (BadImageFormatException) { // if the .resources can't be decoded, just save them as-is } catch (EndOfStreamException) { // if the .resources can't be decoded, just save them as-is } } using (FileStream fs = new FileStream(Path.Combine(TargetDirectory, fileName), FileMode.Create, FileAccess.Write)) { entryStream.CopyTo(fs); } return new[] { new ProjectItemInfo("EmbeddedResource", fileName).With("LogicalName", resourceName) }; } /// /// Adjusts a project item produced from an entry of a WPF-generated ".g.resources" container /// so that rebuilding the exported project puts the entry back under its original resource /// ID. The file on disk cannot carry that ID - it is sanitized, and the ID is escaped - so /// the item pins it with a LogicalName holding the decoded name: the WPF build tasks /// lower-case and escape the LogicalName again, arriving back at the original ID. Entries /// that no handler turned into some other item type are WPF Resource items; leaving them as /// EmbeddedResource would rebuild them into a manifest resource of their own instead of /// putting them into ".g.resources". /// static ProjectItemInfo ToWpfProjectItem(ProjectItemInfo item, string escapedEntryName) { string logicalName = Uri.UnescapeDataString(escapedEntryName); if (item.FileName.EndsWith(".xaml", StringComparison.OrdinalIgnoreCase)) { // A decompiled BAML page is written as .xaml, and the build derives the .baml // extension of the resource ID from the Page item type, not from the LogicalName. logicalName = Path.ChangeExtension(logicalName, ".xaml"); } var result = item with { ItemType = item.ItemType == "EmbeddedResource" ? "Resource" : item.ItemType }; result.AdditionalProperties ??= new Dictionary(); result.AdditionalProperties["LogicalName"] = logicalName; return result; } /// /// Claims for one resource, appending "_2", "_3", ... until the /// name is free. Sanitizing is not injective - "a+b/logo.png" and "a&b/logo.png" both come out /// as "a-b/logo.png" - and the writers create files with FileMode.Create, so without this the /// entries after the first are lost silently, and an assembly can be built to make that happen /// to as many of them as it likes. The exported project keeps the true name in the item's /// LogicalName, so the file on disk only has to be unique, not faithful. /// string ReserveResourceFileName(string fileName) { if (!resourceFileNames.TryGetValue(fileName, out int lastSuffix)) { resourceFileNames.Add(fileName, 1); return fileName; } // Resuming the count where the last collision on this name left off keeps the export // linear in the number of colliding entries; restarting at 2 each time would make it // quadratic, which is worth something when the count is the assembly's to choose. string candidate; do { candidate = AppendFileNameSuffix(fileName, ++lastSuffix); } while (resourceFileNames.ContainsKey(candidate)); resourceFileNames[fileName] = lastSuffix; resourceFileNames.Add(candidate, 1); return candidate; } /// /// Inserts "_" before the extension, trimming the name if the segment /// would otherwise outgrow what the file system takes - a name already at the limit is an /// ordinary thing to find in an assembly, and the write would throw. /// static string AppendFileNameSuffix(string fileName, int suffix) { string directory = Path.GetDirectoryName(fileName) ?? string.Empty; string name = Path.GetFileNameWithoutExtension(fileName); string extension = Path.GetExtension(fileName); string marker = "_" + suffix.ToString(CultureInfo.InvariantCulture); // Trimming whole characters removes at least as many bytes as it has to, so measuring the // overflow the way CleanUpName measures a segment cannot leave the result over the limit. int overflow = SegmentLength(name) + SegmentLength(marker) + SegmentLength(extension) - maxSegmentLength; if (overflow > 0) name = name.Substring(0, Math.Max(0, name.Length - overflow)); return Path.Combine(directory, name + marker + extension); } static int SegmentLength(string text) { return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? text.Length : Encoding.UTF8.GetByteCount(text); } /// /// WPF's build tasks put every Page and Resource item into "<AssemblyName>.g.resources" /// (and into "<AssemblyName>.g.<culture>.resources" in satellite assemblies), keyed by /// the item's relative path, lower-cased and URI-escaped: a folder named "My Images" becomes /// "my%20images". Those escapes are not part of the name and have to be decoded before the /// name is turned into a file name, otherwise the percent sign is sanitized away and /// "my%20images/logo.png" lands in a directory called "my-20images". /// static bool IsWpfGeneratedResourceContainer(string resourceName) { const string extension = ".resources"; if (!resourceName.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) return false; string name = resourceName.Substring(0, resourceName.Length - extension.Length); return name.EndsWith(".g", StringComparison.OrdinalIgnoreCase) || name.Substring(0, Math.Max(0, name.LastIndexOf('.'))).EndsWith(".g", StringComparison.OrdinalIgnoreCase); } string GetFileNameForResource(string fullName) { // Clean up the name first and ensure the length does not exceed the maximum length // supported by the OS. fullName = SanitizeFileName(fullName); // The purpose of the below algorithm is to "maximize" the directory name and "minimize" the file name. // That is, a full name of the form "Namespace1.Namespace2{...}.NamespaceN.ResourceName" is split such that // the directory part Namespace1\Namespace2\... reuses as many existing directories as // possible, and only the remaining name parts are used as prefix for the filename. // This is not affected by the UseNestedDirectoriesForNamespaces setting. string[] splitName = fullName.Split('\\', '/'); string fileName = string.Join(".", splitName); string separator = Path.DirectorySeparatorChar.ToString(); for (int i = splitName.Length - 1; i > 0; i--) { string ns = string.Join(separator, splitName, 0, i); if (directories.Contains(ns)) { string name = string.Join(".", splitName, i, splitName.Length - i); fileName = Path.Combine(ns, name); break; } } return fileName; } #endregion #region WriteMiscellaneousFilesInProject protected virtual IEnumerable WriteMiscellaneousFilesInProject(PEFile module) { var resources = module.Reader.ReadWin32Resources(); if (resources == null) yield break; // Each file is written on its own, so the one that fails is the only one lost. foreach (var item in TryWrite(module, "app.ico", () => { byte[]? appIcon = CreateApplicationIcon(resources); if (appIcon == null) return null; File.WriteAllBytes(Path.Combine(TargetDirectory, "app.ico"), appIcon); return new ProjectItemInfo("ApplicationIcon", "app.ico"); })) { yield return item; } foreach (var item in TryWrite(module, "app.manifest", () => { byte[]? appManifest = CreateApplicationManifest(resources); if (appManifest == null || IsDefaultApplicationManifest(appManifest)) return null; File.WriteAllBytes(Path.Combine(TargetDirectory, "app.manifest"), appManifest); return new ProjectItemInfo("ApplicationManifest", "app.manifest"); })) { yield return item; } foreach (var item in TryWrite(module, "app.config", () => { var appConfig = module.FileName + ".config"; if (!File.Exists(appConfig)) return null; File.Copy(appConfig, Path.Combine(TargetDirectory, "app.config"), overwrite: true); return new ProjectItemInfo("ApplicationConfig", Path.GetFileName(appConfig)); })) { yield return item; } } IEnumerable TryWrite(MetadataFile module, string what, Func write) { ProjectItemInfo? item; try { item = write(); } catch (Exception ex) when (!(ex is OperationCanceledException)) { RecordError(ex as DecompilerException ?? new DecompilerException(module, $"Error writing '{what}'", ex)); yield break; } if (item.HasValue) { yield return item.Value; } } const int RT_ICON = 3; const int RT_GROUP_ICON = 14; unsafe static byte[]? CreateApplicationIcon(Win32ResourceDirectory resources) { var iconGroup = resources.Find(new Win32ResourceName(RT_GROUP_ICON))?.FirstDirectory()?.FirstData()?.Data; if (iconGroup == null) return null; var iconDir = resources.Find(new Win32ResourceName(RT_ICON)); if (iconDir == null) return null; using var outStream = new MemoryStream(); using var writer = new BinaryWriter(outStream); fixed (byte* pIconGroupData = iconGroup) { var pIconGroup = (GRPICONDIR*)pIconGroupData; writer.Write(pIconGroup->idReserved); writer.Write(pIconGroup->idType); writer.Write(pIconGroup->idCount); int iconCount = pIconGroup->idCount; uint offset = (2 * 3) + ((uint)iconCount * 0x10); for (int i = 0; i < iconCount; i++) { var pIconEntry = pIconGroup->idEntries + i; writer.Write(pIconEntry->bWidth); writer.Write(pIconEntry->bHeight); writer.Write(pIconEntry->bColorCount); writer.Write(pIconEntry->bReserved); writer.Write(pIconEntry->wPlanes); writer.Write(pIconEntry->wBitCount); writer.Write(pIconEntry->dwBytesInRes); writer.Write(offset); offset += pIconEntry->dwBytesInRes; } for (int i = 0; i < iconCount; i++) { var icon = iconDir.FindDirectory(new Win32ResourceName(pIconGroup->idEntries[i].nID))?.FirstData()?.Data; if (icon == null) return null; writer.Write(icon); } } return outStream.ToArray(); } [StructLayout(LayoutKind.Sequential, Pack = 2)] unsafe struct GRPICONDIR { public ushort idReserved; public ushort idType; public ushort idCount; private fixed byte _idEntries[1]; [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "")] public GRPICONDIRENTRY* idEntries { get { fixed (byte* p = _idEntries) return (GRPICONDIRENTRY*)p; } } }; [StructLayout(LayoutKind.Sequential, Pack = 2)] struct GRPICONDIRENTRY { public byte bWidth; public byte bHeight; public byte bColorCount; public byte bReserved; public ushort wPlanes; public ushort wBitCount; public uint dwBytesInRes; public short nID; }; const int RT_MANIFEST = 24; unsafe static byte[]? CreateApplicationManifest(Win32ResourceDirectory resources) { return resources.Find(new Win32ResourceName(RT_MANIFEST))?.FirstDirectory()?.FirstData()?.Data; } static bool IsDefaultApplicationManifest(byte[] appManifest) { const string DEFAULT_APPMANIFEST = ""; string s = CleanUpApplicationManifest(appManifest); return s == DEFAULT_APPMANIFEST; } static string CleanUpApplicationManifest(byte[] appManifest) { bool bom = appManifest.Length >= 3 && appManifest[0] == 0xEF && appManifest[1] == 0xBB && appManifest[2] == 0xBF; string s = Encoding.UTF8.GetString(appManifest, bom ? 3 : 0, appManifest.Length - (bom ? 3 : 0)); var sb = new StringBuilder(s.Length); for (int i = 0; i < s.Length; i++) { char c = s[i]; switch (c) { case '\t': case '\n': case '\r': case ' ': continue; } sb.Append(c); } return sb.ToString(); } #endregion /// /// Cleans up a node name for use as a file name. /// public static string CleanUpFileName(string text, string? extension) { if (string.IsNullOrEmpty(extension) || extension.StartsWith(".")) text = $"{text}{extension}"; else text = $"{text}.{extension}"; return CleanUpName(text, separateAtDots: false, treatAsFileName: !string.IsNullOrEmpty(extension), treatAsPath: false); } /// /// Removes invalid characters from file names and reduces their length, /// but keeps file extensions and path structure intact. /// /// /// The path of a file belonging to a type: the namespace becomes directories or one /// flattened directory name, depending on /// . Everything a type /// owns - its C# file and the XAML document it is the code-behind of - goes here, so the /// two end up next to each other. /// public static string GetFileNameForType(string @namespace, string typeName, string extension, bool useNestedDirectoriesForNamespaces) { string file = CleanUpFileName(typeName, extension); if (string.IsNullOrEmpty(@namespace)) return file; string directory = useNestedDirectoriesForNamespaces ? CleanUpPath(@namespace) : CleanUpDirectoryName(@namespace); return Path.Combine(directory, file); } protected string GetFileNameForType(string @namespace, string typeName, string extension) { return GetFileNameForType(@namespace, typeName, extension, Settings.UseNestedDirectoriesForNamespaces); } public static string SanitizeFileName(string fileName) { return CleanUpName(fileName, separateAtDots: false, treatAsFileName: true, treatAsPath: true); } /// /// Cleans up a node name for use as a file system name. If is active, /// dots are seen as segment separators. Each segment is limited to maxSegmentLength characters. /// If is active, we check for file a extension and try to preserve it, /// if it's valid. /// static string CleanUpName(string text, bool separateAtDots, bool treatAsFileName, bool treatAsPath) { string? extension = null; int currentSegmentLength = 0; // Extract extension from the end of the name, if valid if (treatAsFileName) { // Check if input is a file name, i.e., has a valid extension // If yes, preserve extension and append it at the end. // But only, if the extension length does not exceed maxSegmentLength, // if that's the case we just give up and treat the extension no different // from the file name. int lastDot = text.LastIndexOf('.'); if (lastDot >= 0 && text.Length - lastDot < maxSegmentLength) { string originalText = text; extension = text.Substring(lastDot); text = text.Remove(lastDot); foreach (var c in extension) { if (!(char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.')) { // extension contains an invalid character, therefore cannot be a valid extension. extension = null; text = originalText; break; } } } } // Remove anything that could be confused with a rooted path. int pos = text.IndexOf(':'); if (pos > 0) text = text.Substring(0, pos); text = text.Trim(); // Remove generics pos = text.IndexOf('`'); if (pos > 0) { text = text.Substring(0, pos).Trim(); } // Whitelist allowed characters, replace everything else: StringBuilder b = new StringBuilder(text.Length + (extension?.Length ?? 0)); bool countBytes = !RuntimeInformation.IsOSPlatform(OSPlatform.Windows); int segmentStart = 0; int baseNameEnd = -1; foreach (var c in text) { if (char.IsLetterOrDigit(c) || c == '-' || c == '_') { unsafe { currentSegmentLength += countBytes ? Encoding.UTF8.GetByteCount(&c, 1) : 1; } // if the current segment exceeds maxSegmentLength characters, // skip until the end of the segment. if (currentSegmentLength <= maxSegmentLength) b.Append(c); } else if (c == '.' && b.Length > 0 && b[^1] != '.') { currentSegmentLength++; if (separateAtDots) { // The dot ends the current segment. EscapeReservedFileSystemName(b, segmentStart, baseNameEnd); b.Append('.'); segmentStart = b.Length; baseNameEnd = -1; // Reset length at end of segment. currentSegmentLength = 0; } else if (currentSegmentLength <= maxSegmentLength) { // The first dot ends the segment's base name, the part Windows // device-name parsing looks at. if (baseNameEnd < 0) baseNameEnd = b.Length; b.Append('.'); // allow dot, but never two in a row } } else if (treatAsPath && (c is '/' or '\\') && currentSegmentLength > 0) { // if we treat this as a file name, we've started a new segment EscapeReservedFileSystemName(b, segmentStart, baseNameEnd); b.Append(Path.DirectorySeparatorChar); segmentStart = b.Length; baseNameEnd = -1; currentSegmentLength = 0; } else { if (char.IsHighSurrogate(c)) { // only add one replacement character for surrogate pairs continue; } currentSegmentLength++; // if the current segment exceeds maxSegmentLength characters, // skip until the end of the segment. if (currentSegmentLength <= maxSegmentLength) b.Append('-'); } } if (b.Length == 0) b.Append('-'); EscapeReservedFileSystemName(b, segmentStart, baseNameEnd); string name = b.ToString(); if (extension != null) { // make sure that adding the extension to the filename // does not exceed maxSegmentLength. // trim the name, if necessary. if (name.Length + extension.Length > maxSegmentLength) name = name.Remove(name.Length - extension.Length); name += extension; } if (name == ".") return "_"; else return name; } /// /// Cleans up a node name for use as a directory name. /// public static string CleanUpDirectoryName(string text) { return CleanUpName(text, separateAtDots: false, treatAsFileName: false, treatAsPath: false); } public static string CleanUpPath(string text) { return CleanUpName(text, separateAtDots: true, treatAsFileName: false, treatAsPath: true) .Replace('.', Path.DirectorySeparatorChar); } /// /// Appends an underscore to the segment [..) of /// if its base name (the part before , /// or the whole segment when there was no dot) is a reserved Windows device name /// (CON, PRN, AUX, NUL, COM1-9, LPT1-9): "con" becomes "con_" and "con.txt" becomes /// "con_.txt". Windows device-name parsing ignores everything after the first dot, so the /// underscore must be inserted before it, not appended at the end. /// static void EscapeReservedFileSystemName(StringBuilder b, int segmentStart, int baseNameEnd) { if (baseNameEnd < 0) baseNameEnd = b.Length; int baseNameLength = baseNameEnd - segmentStart; // All reserved names are 3 or 4 characters long; checking the length first avoids // allocating a substring for segments that cannot be reserved anyway. if (baseNameLength is 3 or 4 && IsReservedFileSystemName(b.ToString(segmentStart, baseNameLength))) b.Insert(baseNameEnd, '_'); } static bool IsReservedFileSystemName(string name) { switch (name.ToUpperInvariant()) { case "AUX": case "COM1": case "COM2": case "COM3": case "COM4": case "COM5": case "COM6": case "COM7": case "COM8": case "COM9": case "CON": case "LPT1": case "LPT2": case "LPT3": case "LPT4": case "LPT5": case "LPT6": case "LPT7": case "LPT8": case "LPT9": case "NUL": case "PRN": return true; default: return false; } } public static bool CanUseSdkStyleProjectFormat(MetadataFile module) { return TargetServices.DetectTargetFramework(module).Moniker != null; } /// /// Determines whether the XAML file whose root object is belongs /// into an MSBuild <ApplicationDefinition> item instead of a <Page> item. /// The WPF markup compiler generates the program entry point from the application definition, /// so a module that has no entry point of its own - a library that happens to contain an /// Application subclass - must not get one. /// public static bool IsApplicationDefinition(ITypeDefinition? rootType, MetadataFile? module) { if (rootType == null) return false; if (module is not PEFile { Reader.PEHeaders.CorHeader.EntryPointTokenOrRelativeVirtualAddress: not 0 }) return false; foreach (var baseType in rootType.GetNonInterfaceBaseTypes()) { if (baseType.FullName == "System.Windows.Application") return true; } return false; } } public record struct ProjectItemInfo(string ItemType, string FileName) { public List? PartialTypes { get; set; } = null; public Dictionary? AdditionalProperties { get; set; } = null; public ProjectItemInfo With(string name, string value) { AdditionalProperties ??= new Dictionary(); AdditionalProperties.Add(name, value); return this; } public ProjectItemInfo With(IEnumerable> pairs) { AdditionalProperties ??= new Dictionary(); AdditionalProperties.AddRange(pairs); return this; } } }