// 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)
{
var type = metadata.GetTypeDefinition(h);
string file = CleanUpFileName(metadata.GetString(type.Name), ".cs");
string ns = metadata.GetString(type.Namespace);
if (string.IsNullOrEmpty(ns))
{
return file;
}
else
{
string dir = Settings.UseNestedDirectoriesForNamespaces ? CleanUpPath(ns) : CleanUpDirectoryName(ns);
if (directories.Add(dir))
{
var path = Path.Combine(TargetDirectory, dir);
CreateDirectory(path);
}
return Path.Combine(dir, file);
}
}
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.
///
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;
}
}
}