diff --git a/Confuser.CLI/Program.cs b/Confuser.CLI/Program.cs index 47da3ca8e..9d3323c02 100644 --- a/Confuser.CLI/Program.cs +++ b/Confuser.CLI/Program.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Xml; using Confuser.Core; +using Confuser.Core.Diagnostics; using Confuser.Core.Project; using Microsoft.Extensions.Logging; using NDesk.Options; @@ -25,6 +26,8 @@ static int Main(string[] args) { bool noPause = false; bool debug = false; bool quiet = false; + bool dumpRequested = false; + string dumpPath = null; int verbosity = 0; string outDir = null; string snKeyPath = null; @@ -59,6 +62,9 @@ static int Main(string[] args) { }, { "q|quiet", "only show warnings and errors.", value => { quiet = (value != null); } + }, { + "dump:", "write a diagnostic report (optionally to the given file).", + value => { dumpRequested = true; if (!string.IsNullOrEmpty(value)) dumpPath = value; } } }; @@ -141,7 +147,7 @@ static int Main(string[] args) { parameters.Project = proj; } - int retVal = RunProject(parameters, quiet, verbosity); + int retVal = RunProject(parameters, quiet, verbosity, dumpRequested, dumpPath); if (NeedPause() && !noPause) { Console.WriteLine("Press any key to continue..."); @@ -203,7 +209,7 @@ static void LoadTemplateProject(string templatePath, ConfuserProject proj, List< templateModules.Add(templateModule); } - static int RunProject(ConfuserParameters parameters, bool quiet, int verbosity) { + static int RunProject(ConfuserParameters parameters, bool quiet, int verbosity, bool dumpRequested, string dumpPath) { var levelSwitch = quiet ? LogEventLevel.Warning : verbosity >= 3 ? LogEventLevel.Verbose @@ -222,17 +228,44 @@ static int RunProject(ConfuserParameters parameters, bool quiet, int verbosity) var melLogger = loggerFactory.CreateLogger("ConfuserEx"); var progressReporter = new ConsoleProgressReporter(); - parameters.Logger = melLogger; - parameters.ProgressReporter = progressReporter; + + // When a diagnostic report is requested, wrap both the logger and the progress reporter + // with a collector so the report captures the full transcript, timing and outcome even + // when the run fails. + DiagnosticCollector collector = null; + if (dumpRequested) { + collector = new DiagnosticCollector(melLogger, progressReporter) { Project = parameters.Project }; + parameters.Logger = collector; + parameters.ProgressReporter = collector; + } + else { + parameters.Logger = melLogger; + parameters.ProgressReporter = progressReporter; + } if (OperatingSystem.IsWindows()) Console.Title = "ConfuserEx - Running..."; ConfuserEngine.Run(parameters).GetAwaiter().GetResult(); Log.CloseAndFlush(); + + if (collector != null) + WriteDiagnosticReport(collector, dumpPath); + return progressReporter.ReturnValue; } + static void WriteDiagnosticReport(DiagnosticCollector collector, string dumpPath) { + string path = string.IsNullOrEmpty(dumpPath) ? "confuser-diagnostic-report.md" : dumpPath; + try { + File.WriteAllText(path, collector.GenerateReport()); + WriteLineWithColor(ConsoleColor.Cyan, "Diagnostic report written to: " + Path.GetFullPath(path)); + } + catch (Exception ex) { + WriteLineWithColor(ConsoleColor.Red, "Failed to write diagnostic report: " + ex.Message); + } + } + static bool NeedPause() { return Debugger.IsAttached || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("PROMPT")); } @@ -250,6 +283,7 @@ static void PrintUsage() { WriteLine(" -snkeypass : specifies strong name key password."); WriteLine(" -v|verbose : increase verbosity (-v debug, -vv trace)."); WriteLine(" -q|quiet : only show warnings and errors."); + WriteLine(" -dump : write a diagnostic report (-dump= for a custom path)."); } static void WriteLineWithColor(ConsoleColor color, string txt) { diff --git a/Confuser.Core/Diagnostics/DiagnosticCollector.cs b/Confuser.Core/Diagnostics/DiagnosticCollector.cs new file mode 100644 index 000000000..3616a5e76 --- /dev/null +++ b/Confuser.Core/Diagnostics/DiagnosticCollector.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using Confuser.Core.Project; +using Microsoft.Extensions.Logging; + +namespace Confuser.Core.Diagnostics { + /// + /// A single captured log entry, rendered to text at capture time. + /// + public readonly struct DiagnosticLogEntry { + public DiagnosticLogEntry(LogLevel level, string message, string exception) { + Level = level; + Message = message; + Exception = exception; + } + + /// The severity of the entry. + public LogLevel Level { get; } + + /// The rendered message text. + public string Message { get; } + + /// The rendered exception (including stack trace), or null if none. + public string Exception { get; } + } + + /// + /// Wraps the real and used during an + /// obfuscation run, passing every call through while capturing a full-verbosity transcript, + /// timing and outcome. On completion — success or failure — it can produce a self-contained + /// markdown diagnostic report suitable for a bug report. + /// + /// + /// + /// Capture is intentionally independent of the inner logger's level: the collector reports + /// as true and keeps every entry, so a report from a default + /// (Information) run still contains the Debug detail needed to diagnose a failure. Display + /// filtering is preserved because entries are only forwarded to the inner logger when it is + /// enabled for that level. + /// + /// + /// The entry buffer is bounded (see ); once full, the oldest + /// entries are dropped and counted in so the report can note the + /// loss rather than silently mislead. + /// + /// + /// is last-wins: a packer runs a nested engine pass with the same + /// collector, so the top-level run — which finishes last — determines the reported outcome + /// and elapsed time. + /// + /// + public sealed class DiagnosticCollector : ILogger, IProgressReporter { + /// The default maximum number of log entries retained. + public const int DefaultCapacity = 2000; + + readonly ILogger inner; + readonly IProgressReporter innerReporter; + readonly int capacity; + readonly object sync = new object(); + readonly Queue entries; + readonly DateTime begin = DateTime.UtcNow; + int dropped; + bool? successful; + TimeSpan elapsed; + + /// + /// Initializes a new collector. + /// + /// The logger to forward display output to. Required. + /// The progress reporter to forward to, or null. + /// The maximum number of log entries to retain. + public DiagnosticCollector(ILogger inner, IProgressReporter innerReporter = null, int capacity = DefaultCapacity) { + this.inner = inner ?? throw new ArgumentNullException(nameof(inner)); + this.innerReporter = innerReporter; + this.capacity = capacity < 1 ? 1 : capacity; + entries = new Queue(Math.Min(this.capacity, 64)); + } + + /// + /// The project being processed, used to populate the report's configuration section. + /// + public ConfuserProject Project { get; set; } + + /// + /// The run outcome: true on success, false on failure, null if the run + /// never reported completion. + /// + public bool? Successful { + get { lock (sync) return successful; } + } + + /// The elapsed time recorded at the last call. + public TimeSpan Elapsed { + get { lock (sync) return elapsed; } + } + + /// The number of log entries dropped because the buffer was full. + public int DroppedCount { + get { lock (sync) return dropped; } + } + + /// + /// Returns an immutable copy of the currently retained log entries, oldest first. + /// + public IReadOnlyList Snapshot() { + lock (sync) return new List(entries); + } + + /// + /// Produces the markdown diagnostic report. Never throws. + /// + public string GenerateReport() => DiagnosticReport.Generate(this); + + #region ILogger + + public IDisposable BeginScope(TState state) => inner.BeginScope(state); + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) { + string message; + try { + message = formatter != null ? formatter(state, exception) : state?.ToString() ?? string.Empty; + } + catch { + message = state?.ToString() ?? string.Empty; + } + + var entry = new DiagnosticLogEntry(logLevel, message, exception?.ToString()); + lock (sync) { + entries.Enqueue(entry); + while (entries.Count > capacity) { + entries.Dequeue(); + dropped++; + } + } + + // Forward to the inner logger for display; it applies its own level filter. + if (inner.IsEnabled(logLevel)) + inner.Log(logLevel, eventId, state, exception, formatter); + } + + #endregion + + #region IProgressReporter + + public void Progress(int progress, int overall) => innerReporter?.Progress(progress, overall); + + public void EndProgress() => innerReporter?.EndProgress(); + + public void Finish(bool successful) { + lock (sync) { + this.successful = successful; + elapsed = DateTime.UtcNow - begin; + } + + innerReporter?.Finish(successful); + } + + #endregion + } +} diff --git a/Confuser.Core/Diagnostics/DiagnosticRedactor.cs b/Confuser.Core/Diagnostics/DiagnosticRedactor.cs new file mode 100644 index 000000000..9d1059099 --- /dev/null +++ b/Confuser.Core/Diagnostics/DiagnosticRedactor.cs @@ -0,0 +1,52 @@ +using System; +using System.Text; + +namespace Confuser.Core.Diagnostics { + /// + /// Scrubs sensitive information from text destined for a diagnostic report. + /// + /// + /// Diagnostic reports are meant to be pasted into public issue trackers, so any text + /// that flows into one must have the reporter's identity removed. The most common leak + /// is the user-profile path (e.g. C:\Users\alice\...) which appears in absolute + /// paths throughout log output and project configuration. + /// + public static class DiagnosticRedactor { + /// + /// The placeholder substituted for the user-profile directory. + /// + public const string UserPlaceholder = "%USER%"; + + /// + /// Replaces every occurrence of the user-profile directory in + /// with . The match is case-insensitive because Windows + /// paths are. + /// + /// The text to scrub. Returned unchanged if null or empty. + /// The user-profile directory to redact, or null to skip. + /// The scrubbed text. + public static string Redact(string text, string userProfile) { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(userProfile)) + return text; + return ReplaceCaseInsensitive(text, userProfile, UserPlaceholder); + } + + static string ReplaceCaseInsensitive(string input, string search, string replacement) { + var sb = new StringBuilder(input.Length); + int index = 0; + while (true) { + int found = input.IndexOf(search, index, StringComparison.OrdinalIgnoreCase); + if (found < 0) { + sb.Append(input, index, input.Length - index); + break; + } + + sb.Append(input, index, found - index); + sb.Append(replacement); + index = found + search.Length; + } + + return sb.ToString(); + } + } +} diff --git a/Confuser.Core/Diagnostics/DiagnosticReport.cs b/Confuser.Core/Diagnostics/DiagnosticReport.cs new file mode 100644 index 000000000..afae508bd --- /dev/null +++ b/Confuser.Core/Diagnostics/DiagnosticReport.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using Confuser.Core.Project; +using dnlib.DotNet; +using Microsoft.Extensions.Logging; + +namespace Confuser.Core.Diagnostics { + /// + /// Formats the data captured by a into a self-contained + /// markdown report suitable for pasting into a bug report. + /// + public static class DiagnosticReport { + /// + /// Generates the report for the given collector. Never throws — on any failure it returns + /// a minimal report noting the failure, because this runs precisely when things are already + /// going wrong. + /// + public static string Generate(DiagnosticCollector collector) { + if (collector == null) + return string.Empty; + + try { + return Build(collector); + } + catch (Exception ex) { + return "# ConfuserEx Diagnostic Report" + Environment.NewLine + Environment.NewLine + + "Report generation failed: " + ex.Message + Environment.NewLine; + } + } + + static string Build(DiagnosticCollector collector) { + string userProfile = SafeUserProfile(); + var sb = new StringBuilder(); + sb.AppendLine("# ConfuserEx Diagnostic Report"); + sb.AppendLine(); + AppendSystem(sb); + AppendProject(sb, collector.Project, userProfile); + AppendResult(sb, collector.Successful); + AppendLog(sb, collector, userProfile); + AppendElapsed(sb, collector.Elapsed); + return sb.ToString(); + } + + static void AppendSystem(StringBuilder sb) { + sb.AppendLine("## System"); + sb.AppendLine("- OS: " + SafeOsDescription()); + sb.AppendLine("- Runtime: " + SafeRuntimeDescription()); + sb.AppendLine("- Architecture: " + RuntimeInformation.OSArchitecture + + " (process " + RuntimeInformation.ProcessArchitecture + ")"); + sb.AppendLine("- ConfuserExx: " + ConfuserEngine.Version); + sb.AppendLine(); + } + + static void AppendProject(StringBuilder sb, ConfuserProject project, string userProfile) { + sb.AppendLine("## Project Configuration"); + if (project == null) { + sb.AppendLine("- (no project information available)"); + sb.AppendLine(); + return; + } + + sb.AppendLine("- Base Directory: " + Show(DiagnosticRedactor.Redact(project.BaseDirectory, userProfile))); + sb.AppendLine("- Output Directory: " + Show(DiagnosticRedactor.Redact(project.OutputDirectory, userProfile))); + + var modules = project.Where(m => !m.IsExternal).Select(m => m.Path) + .Where(p => !string.IsNullOrEmpty(p)).ToList(); + sb.AppendLine("- Modules: " + (modules.Count > 0 ? string.Join(", ", modules) : "(none)")); + + var targetFrameworks = modules + .Select(m => TryReadTargetFramework(ResolveModulePath(project, m))) + .Where(tfm => !string.IsNullOrEmpty(tfm)) + .Distinct() + .ToList(); + if (targetFrameworks.Count > 0) + sb.AppendLine("- Target Framework: " + string.Join(", ", targetFrameworks)); + + var externals = project.Where(m => m.IsExternal).Select(m => m.Path) + .Where(p => !string.IsNullOrEmpty(p)).ToList(); + if (externals.Count > 0) + sb.AppendLine("- External Modules: " + string.Join(", ", externals)); + + var protections = project.Rules + .SelectMany(r => r) + .Select(s => s.Id) + .Where(id => !string.IsNullOrEmpty(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + sb.AppendLine("- Protections: " + (protections.Count > 0 ? string.Join(", ", protections) : "(preset only / none)")); + + var presets = project.Rules.Where(r => r.Preset != ProtectionPreset.None) + .Select(r => r.Preset.ToString().ToLowerInvariant()) + .Distinct().ToList(); + if (presets.Count > 0) + sb.AppendLine("- Presets: " + string.Join(", ", presets)); + + sb.AppendLine("- Packer: " + (project.Packer != null && !string.IsNullOrEmpty(project.Packer.Id) + ? project.Packer.Id : "(none)")); + + var probePaths = (project.ProbePaths ?? Enumerable.Empty()) + .Select(p => DiagnosticRedactor.Redact(p, userProfile)).ToList(); + sb.AppendLine("- Probe Paths: " + (probePaths.Count > 0 ? string.Join(", ", probePaths) : "(none)")); + + var pluginPaths = (project.PluginPaths ?? Enumerable.Empty()) + .Select(p => DiagnosticRedactor.Redact(p, userProfile)).ToList(); + if (pluginPaths.Count > 0) + sb.AppendLine("- Plugins: " + string.Join(", ", pluginPaths)); + + sb.AppendLine(); + } + + static void AppendResult(StringBuilder sb, bool? successful) { + string status = successful == true ? "SUCCESS" : successful == false ? "FAILED" : "(did not complete)"; + sb.AppendLine("## Result: " + status); + sb.AppendLine(); + } + + static void AppendLog(StringBuilder sb, DiagnosticCollector collector, string userProfile) { + sb.AppendLine("## Log Output"); + + var lines = new List(); + if (collector.DroppedCount > 0) + lines.Add("... " + collector.DroppedCount + " earlier log entries truncated ..."); + + foreach (var entry in collector.Snapshot()) { + lines.Add(Prefix(entry.Level) + DiagnosticRedactor.Redact(entry.Message, userProfile)); + if (!string.IsNullOrEmpty(entry.Exception)) + foreach (var exLine in entry.Exception.Split('\n')) + lines.Add(DiagnosticRedactor.Redact(exLine.TrimEnd('\r'), userProfile)); + } + + string body = string.Join(Environment.NewLine, lines); + string fence = MakeFence(body); + sb.AppendLine(fence); + sb.AppendLine(body); + sb.AppendLine(fence); + sb.AppendLine(); + } + + static void AppendElapsed(StringBuilder sb, TimeSpan elapsed) { + sb.AppendLine("## Elapsed: " + + elapsed.TotalSeconds.ToString("F1", CultureInfo.InvariantCulture) + " s"); + } + + /// + /// Chooses a code-fence longer than the longest run of backticks in , + /// so log content containing its own ``` fences cannot break out of the block. + /// + static string MakeFence(string body) { + int max = 0, run = 0; + foreach (char c in body) { + if (c == '`') { + run++; + if (run > max) max = run; + } + else { + run = 0; + } + } + + return new string('`', Math.Max(3, max + 1)); + } + + static string Prefix(LogLevel level) { + switch (level) { + case LogLevel.Trace: + case LogLevel.Debug: + return "[DEBUG] "; + case LogLevel.Information: + return "[INFO] "; + case LogLevel.Warning: + return "[WARN] "; + case LogLevel.Error: + case LogLevel.Critical: + return "[ERROR] "; + default: + return ""; + } + } + + static string Show(string value) => string.IsNullOrEmpty(value) ? "(not set)" : value; + + static string ResolveModulePath(ConfuserProject project, string modulePath) { + try { + if (!string.IsNullOrEmpty(project.BaseDirectory)) + return Path.Combine(project.BaseDirectory, modulePath); + } + catch { + // Fall through to the bare module path. + } + + return modulePath; + } + + /// + /// Best-effort read of an assembly's target-framework moniker (e.g. + /// .NETCoreApp,Version=v8.0) from its TargetFrameworkAttribute. Returns + /// null if the file is missing, is not a valid assembly, or carries no such + /// attribute. The file is read into memory so it is never locked. + /// + public static string TryReadTargetFramework(string assemblyPath) { + try { + if (string.IsNullOrEmpty(assemblyPath) || !File.Exists(assemblyPath)) + return null; + + using (var module = ModuleDefMD.Load(File.ReadAllBytes(assemblyPath))) { + var assembly = module.Assembly; + if (assembly == null) + return null; + + foreach (var attr in assembly.CustomAttributes) { + if (attr.TypeFullName != "System.Runtime.Versioning.TargetFrameworkAttribute") + continue; + if (attr.ConstructorArguments.Count == 0) + continue; + + var moniker = attr.ConstructorArguments[0].Value?.ToString(); + if (!string.IsNullOrEmpty(moniker)) + return moniker; + } + } + } + catch { + // Diagnostic best-effort: any failure to read the framework is non-fatal. + } + + return null; + } + + static string SafeOsDescription() { + try { + return RuntimeInformation.OSDescription.Trim(); + } + catch { + return Environment.OSVersion.ToString(); + } + } + + static string SafeRuntimeDescription() { + try { + return RuntimeInformation.FrameworkDescription; + } + catch { + return ".NET " + Environment.Version; + } + } + + static string SafeUserProfile() { + try { + return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + catch { + return null; + } + } + } +} diff --git a/ConfuserEx/ViewModel/UI/ProtectTabVM.cs b/ConfuserEx/ViewModel/UI/ProtectTabVM.cs index 3f7936c41..b36356148 100644 --- a/ConfuserEx/ViewModel/UI/ProtectTabVM.cs +++ b/ConfuserEx/ViewModel/UI/ProtectTabVM.cs @@ -7,6 +7,7 @@ using System.Windows.Media; using CommunityToolkit.Mvvm.Input; using Confuser.Core; +using Confuser.Core.Diagnostics; using Confuser.Core.Project; using Microsoft.Extensions.Logging; using Serilog; @@ -15,6 +16,7 @@ namespace ConfuserEx.ViewModel { internal class ProtectTabVM : TabViewModel, IProgressReporter { readonly Paragraph documentContent; CancellationTokenSource cancelSrc; + DiagnosticCollector collector; double? progress = 0; bool? result; @@ -33,6 +35,10 @@ public ICommand CancelCmd { get { return new RelayCommand(DoCancel, () => App.NavigationDisabled); } } + public ICommand CopyReportCmd { + get { return new RelayCommand(DoCopyReport, () => Result != null && collector != null); } + } + public double? Progress { get { return progress; } set { SetProperty(ref progress, value, "Progress"); } @@ -65,8 +71,12 @@ void DoProtect() { builder.AddSerilog(serilogLogger, dispose: true)); var melLogger = loggerFactory.CreateLogger("ConfuserEx"); - parameters.Logger = melLogger; - parameters.ProgressReporter = this; + // The collector wraps the logger and this progress reporter so a diagnostic report — + // covering both successful and failed runs — can be copied afterwards. It captures the + // full transcript regardless of the display level and forwards everything through. + collector = new DiagnosticCollector(melLogger, this) { Project = parameters.Project }; + parameters.Logger = collector; + parameters.ProgressReporter = collector; cancelSrc = new CancellationTokenSource(); Result = null; @@ -89,6 +99,19 @@ void DoCancel() { cancelSrc.Cancel(); } + void DoCopyReport() { + if (collector == null) + return; + + try { + Clipboard.SetText(collector.GenerateReport()); + } + catch { + // The clipboard can be transiently locked by another process; a failed copy + // should never crash the app. The user can simply retry. + } + } + #region IProgressReporter DateTime begin; diff --git a/ConfuserEx/Views/ProtectTabView.xaml b/ConfuserEx/Views/ProtectTabView.xaml index 34fd123be..fb45034a3 100644 --- a/ConfuserEx/Views/ProtectTabView.xaml +++ b/ConfuserEx/Views/ProtectTabView.xaml @@ -10,15 +10,19 @@ + -