mirror of
https://github.com/icsharpcode/ILSpy.git
synced 2026-10-12 02:15:58 +00:00
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
1095 lines
37 KiB
C#
1095 lines
37 KiB
C#
// 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.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
|
|
{
|
|
/// <summary>
|
|
/// Decompiles an assembly into a visual studio project file.
|
|
/// </summary>
|
|
public class WholeProjectDecompiler : IProjectInfoProvider
|
|
{
|
|
const int maxSegmentLength = 255;
|
|
|
|
#region Settings
|
|
/// <summary>
|
|
/// Gets the setting this instance uses for decompiling.
|
|
/// </summary>
|
|
public DecompilerSettings Settings { get; }
|
|
|
|
LanguageVersion? languageVersion;
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="DecompilerSettings.GetMinimumRequiredVersion"/> 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.
|
|
/// </summary>
|
|
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; }
|
|
|
|
/// <summary>
|
|
/// The MSBuild ProjectGuid to use for the new project.
|
|
/// </summary>
|
|
public Guid ProjectGuid { get; }
|
|
|
|
/// <summary>
|
|
/// The target directory that the decompiled files are written to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This property is set by DecompileProject() and protected so that overridden protected members
|
|
/// can access it.
|
|
/// </remarks>
|
|
public string TargetDirectory { get; protected set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Path to the snk file to use for signing.
|
|
/// <c>null</c> to not sign.
|
|
/// </summary>
|
|
public string? StrongNameKeyFile { get; set; }
|
|
|
|
public int MaxDegreeOfParallelism { get; set; } = Environment.ProcessorCount;
|
|
|
|
public IProgress<DecompilationProgress>? 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<string> directories = new HashSet<string>(Platform.FileNameComparer);
|
|
readonly List<DecompilerException> errors = new List<DecompilerException>();
|
|
readonly IProjectFileWriter projectWriter;
|
|
|
|
/// <summary>
|
|
/// Everything that went wrong during the last <see cref="DecompileProject(MetadataFile, string, CancellationToken)"/>.
|
|
/// 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.
|
|
/// </summary>
|
|
public IReadOnlyList<DecompilerException> Errors => errors;
|
|
|
|
void RecordError(DecompilerException error)
|
|
{
|
|
lock (errors)
|
|
{
|
|
errors.Add(error);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Yields the items of <paramref name="items"/> until one of them throws; the failure is
|
|
/// recorded instead of aborting the export.
|
|
/// </summary>
|
|
IEnumerable<T> RecordingErrors<T>(IEnumerable<T> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
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<PartialTypeInfo>()).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 == "<Module>" || 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<ProjectItemInfo> 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<ProjectItemInfo> WriteCodeFilesInProject(MetadataFile module, IList<PartialTypeInfo> 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<string>();
|
|
var workList = new HashSet<TypeDefinitionHandle>();
|
|
var processedTypes = new HashSet<TypeDefinitionHandle>();
|
|
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<ProjectItemInfo> 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<ProjectItemInfo>();
|
|
}
|
|
|
|
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<IGrouping<string, TypeDefinitionHandle>> files)
|
|
{
|
|
processedTypes.AddRange(files.SelectMany(f => f));
|
|
Parallel.ForEach(
|
|
Partitioner.Create(files, loadBalance: true),
|
|
new ParallelOptions {
|
|
MaxDegreeOfParallelism = this.MaxDegreeOfParallelism,
|
|
CancellationToken = cancellationToken
|
|
},
|
|
delegate (IGrouping<string, TypeDefinitionHandle> 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<DecompilerException>)Array.Empty<DecompilerException>())
|
|
{
|
|
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<ProjectItemInfo> WriteResourceFilesInProject(MetadataFile module)
|
|
{
|
|
foreach (var r in module.Resources.Where(r => r.ResourceType == ResourceType.Embedded))
|
|
{
|
|
List<ProjectItemInfo> 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<ProjectItemInfo> 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<ProjectItemInfo>();
|
|
try
|
|
{
|
|
var resourcesFile = new ResourcesFile(stream);
|
|
if (resourcesFile.AllEntriesAreStreams())
|
|
{
|
|
foreach (var (name, value) in resourcesFile)
|
|
{
|
|
string fileName = SanitizeFileName(name);
|
|
string? dirName = Path.GetDirectoryName(fileName);
|
|
if (!string.IsNullOrEmpty(dirName) && directories.Add(dirName))
|
|
{
|
|
CreateDirectory(Path.Combine(TargetDirectory, dirName));
|
|
}
|
|
Stream entryStream = (Stream)value!;
|
|
entryStream.Position = 0;
|
|
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;
|
|
}
|
|
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 = GetFileNameForResource(r.Name);
|
|
foreach (var entry in WriteResourceToFile(fileName, r.Name, stream))
|
|
{
|
|
yield return entry;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
string fileName = 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<ProjectItemInfo> 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) };
|
|
}
|
|
|
|
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<ProjectItemInfo> 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<ProjectItemInfo> TryWrite(MetadataFile module, string what, Func<ProjectItemInfo?> 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 = "<Pending>")]
|
|
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 =
|
|
"<?xmlversion=\"1.0\"encoding=\"UTF-8\"standalone=\"yes\"?><assemblyxmlns=\"urn:schemas-microsoft-com" +
|
|
":asm.v1\"manifestVersion=\"1.0\"><assemblyIdentityversion=\"1.0.0.0\"name=\"MyApplication.app\"/><tr" +
|
|
"ustInfoxmlns=\"urn:schemas-microsoft-com:asm.v2\"><security><requestedPrivilegesxmlns=\"urn:schemas-" +
|
|
"microsoft-com:asm.v3\"><requestedExecutionLevellevel=\"asInvoker\"uiAccess=\"false\"/></requestedPri" +
|
|
"vileges></security></trustInfo></assembly>";
|
|
|
|
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
|
|
|
|
/// <summary>
|
|
/// Cleans up a node name for use as a file name.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes invalid characters from file names and reduces their length,
|
|
/// but keeps file extensions and path structure intact.
|
|
/// </summary>
|
|
public static string SanitizeFileName(string fileName)
|
|
{
|
|
return CleanUpName(fileName, separateAtDots: false, treatAsFileName: true, treatAsPath: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cleans up a node name for use as a file system name. If <paramref name="separateAtDots"/> is active,
|
|
/// dots are seen as segment separators. Each segment is limited to maxSegmentLength characters.
|
|
/// If <paramref name="treatAsFileName"/> is active, we check for file a extension and try to preserve it,
|
|
/// if it's valid.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cleans up a node name for use as a directory name.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends an underscore to the segment [<paramref name="segmentStart"/>..) of
|
|
/// <paramref name="b"/> if its base name (the part before <paramref name="baseNameEnd"/>,
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
public record struct ProjectItemInfo(string ItemType, string FileName)
|
|
{
|
|
public List<PartialTypeInfo>? PartialTypes { get; set; } = null;
|
|
|
|
public Dictionary<string, string>? AdditionalProperties { get; set; } = null;
|
|
|
|
public ProjectItemInfo With(string name, string value)
|
|
{
|
|
AdditionalProperties ??= new Dictionary<string, string>();
|
|
AdditionalProperties.Add(name, value);
|
|
return this;
|
|
}
|
|
|
|
public ProjectItemInfo With(IEnumerable<KeyValuePair<string, string>> pairs)
|
|
{
|
|
AdditionalProperties ??= new Dictionary<string, string>();
|
|
AdditionalProperties.AddRange(pairs);
|
|
return this;
|
|
}
|
|
}
|
|
}
|