From b9fc47521170c3f99baa866014f20bbb48a91b90 Mon Sep 17 00:00:00 2001 From: RandomCrocodile Date: Sat, 4 Jul 2026 11:37:51 +0200 Subject: [PATCH 1/4] feature: add DiagnosticCollector and report formatter (#65) Core of the diagnostic report collector: a DiagnosticCollector that decorates both ILogger and IProgressReporter, capturing a full-verbosity transcript, timing and outcome during an obfuscation run, plus a DiagnosticReport formatter that renders a self-contained markdown report. - Full-verbosity capture independent of display level; bounded ring buffer (last 2000 entries) with dropped-entry accounting. - Last-wins Finish so a packer's nested run does not clobber the top-level result/elapsed. - Redaction: never emits strong-name passwords; scrubs the user-profile path from config and log lines. Dynamic code-fence prevents markdown break-out. - 19 unit tests, TDD. --- .../Diagnostics/DiagnosticCollector.cs | 163 ++++++++++++++ .../Diagnostics/DiagnosticRedactor.cs | 52 +++++ Confuser.Core/Diagnostics/DiagnosticReport.cs | 204 ++++++++++++++++++ .../Diagnostics/DiagnosticCollectorTest.cs | 152 +++++++++++++ .../Diagnostics/DiagnosticReportTest.cs | 97 +++++++++ 5 files changed, 668 insertions(+) create mode 100644 Confuser.Core/Diagnostics/DiagnosticCollector.cs create mode 100644 Confuser.Core/Diagnostics/DiagnosticRedactor.cs create mode 100644 Confuser.Core/Diagnostics/DiagnosticReport.cs create mode 100644 Tests/Confuser.Core.Test/Diagnostics/DiagnosticCollectorTest.cs create mode 100644 Tests/Confuser.Core.Test/Diagnostics/DiagnosticReportTest.cs 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..c5bcec839 --- /dev/null +++ b/Confuser.Core/Diagnostics/DiagnosticReport.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using Confuser.Core.Project; +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 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 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/Tests/Confuser.Core.Test/Diagnostics/DiagnosticCollectorTest.cs b/Tests/Confuser.Core.Test/Diagnostics/DiagnosticCollectorTest.cs new file mode 100644 index 000000000..8dba96d29 --- /dev/null +++ b/Tests/Confuser.Core.Test/Diagnostics/DiagnosticCollectorTest.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using Confuser.Core; +using Confuser.Core.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Confuser.Core.Test.Diagnostics { + public class DiagnosticCollectorTest { + [Fact] + public void Log_CapturesEntry_EvenWhenInnerLevelWouldFilterIt() { + // GIVEN a collector wrapping an inner logger + var collector = new DiagnosticCollector(NullLogger.Instance); + + // WHEN a debug message is logged (would be filtered from display) + collector.LogDebug("hidden detail {0}", 42); + + // THEN the collector still captures it in full + var snapshot = collector.Snapshot(); + Assert.Single(snapshot); + Assert.Equal(LogLevel.Debug, snapshot[0].Level); + Assert.Equal("hidden detail 42", snapshot[0].Message); + } + + [Fact] + public void Log_ForwardsToInnerLogger() { + var inner = new ListLogger(); + var collector = new DiagnosticCollector(inner); + + collector.LogInformation("forward me"); + + Assert.Contains("forward me", inner.Messages); + } + + [Fact] + public void Log_CapturesExceptionText() { + var collector = new DiagnosticCollector(NullLogger.Instance); + var ex = new InvalidOperationException("boom"); + + collector.LogError(ex, "failed"); + + var entry = Assert.Single(collector.Snapshot()); + Assert.Equal("failed", entry.Message); + Assert.Contains("boom", entry.Exception); + } + + [Fact] + public void IsEnabled_ReturnsTrue_ToCaptureAllLevels() { + var collector = new DiagnosticCollector(NullLogger.Instance); + Assert.True(collector.IsEnabled(LogLevel.Trace)); + } + + [Fact] + public void Finish_RecordsSuccess() { + var collector = new DiagnosticCollector(NullLogger.Instance); + ((IProgressReporter)collector).Finish(true); + Assert.True(collector.Successful); + } + + [Fact] + public void Finish_LastWins_SoNestedPackerRunDoesNotClobberTopLevelResult() { + var collector = new DiagnosticCollector(NullLogger.Instance); + var reporter = (IProgressReporter)collector; + + // nested packer stub finishes first (success), then the top-level run fails + reporter.Finish(true); + reporter.Finish(false); + + Assert.False(collector.Successful); + } + + [Fact] + public void Finish_ForwardsToInnerReporter() { + var spy = new SpyReporter(); + var collector = new DiagnosticCollector(NullLogger.Instance, spy); + + ((IProgressReporter)collector).Finish(true); + + Assert.Equal(1, spy.FinishCount); + Assert.True(spy.LastSuccessful); + } + + [Fact] + public void Progress_ForwardsToInnerReporter() { + var spy = new SpyReporter(); + var collector = new DiagnosticCollector(NullLogger.Instance, spy); + var reporter = (IProgressReporter)collector; + + reporter.Progress(3, 10); + reporter.EndProgress(); + + Assert.Equal(1, spy.ProgressCount); + Assert.Equal(1, spy.EndProgressCount); + } + + [Fact] + public void Snapshot_KeepsOnlyMostRecentEntries_AndCountsDropped() { + var collector = new DiagnosticCollector(NullLogger.Instance, capacity: 3); + + for (int i = 0; i < 5; i++) + collector.LogInformation("entry {0}", i); + + var snapshot = collector.Snapshot(); + Assert.Equal(3, snapshot.Count); + Assert.Equal(2, collector.DroppedCount); + Assert.Equal("entry 2", snapshot[0].Message); + Assert.Equal("entry 4", snapshot[2].Message); + } + + [Fact] + public void GenerateReport_NeverThrows_AndContainsCoreSections() { + var collector = new DiagnosticCollector(NullLogger.Instance); + collector.LogInformation("did a thing"); + ((IProgressReporter)collector).Finish(false); + + var report = collector.GenerateReport(); + + Assert.Contains("## System", report); + Assert.Contains("## Result", report); + Assert.Contains("did a thing", report); + } + + sealed class ListLogger : ILogger { + public List Messages { get; } = new List(); + public IDisposable BeginScope(TState state) => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) => Messages.Add(formatter(state, exception)); + + sealed class NullScope : IDisposable { + public static readonly NullScope Instance = new NullScope(); + public void Dispose() { } + } + } + + sealed class SpyReporter : IProgressReporter { + public int ProgressCount; + public int EndProgressCount; + public int FinishCount; + public bool LastSuccessful; + + public void Progress(int progress, int overall) => ProgressCount++; + public void EndProgress() => EndProgressCount++; + public void Finish(bool successful) { + FinishCount++; + LastSuccessful = successful; + } + } + } +} diff --git a/Tests/Confuser.Core.Test/Diagnostics/DiagnosticReportTest.cs b/Tests/Confuser.Core.Test/Diagnostics/DiagnosticReportTest.cs new file mode 100644 index 000000000..b19c85358 --- /dev/null +++ b/Tests/Confuser.Core.Test/Diagnostics/DiagnosticReportTest.cs @@ -0,0 +1,97 @@ +using Confuser.Core; +using Confuser.Core.Diagnostics; +using Confuser.Core.Project; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Confuser.Core.Test.Diagnostics { + public class DiagnosticReportTest { + static DiagnosticCollector CollectorFor(ConfuserProject project) { + var collector = new DiagnosticCollector(NullLogger.Instance) { Project = project }; + return collector; + } + + [Fact] + public void Generate_ResultFailed_WhenRunUnsuccessful() { + var collector = CollectorFor(new ConfuserProject()); + ((IProgressReporter)collector).Finish(false); + + Assert.Contains("## Result: FAILED", collector.GenerateReport()); + } + + [Fact] + public void Generate_ResultSuccess_WhenRunSuccessful() { + var collector = CollectorFor(new ConfuserProject()); + ((IProgressReporter)collector).Finish(true); + + Assert.Contains("## Result: SUCCESS", collector.GenerateReport()); + } + + [Fact] + public void Generate_ListsProtections_FromProjectRules() { + var project = new ConfuserProject(); + var rule = new Rule(); + rule.Add(new SettingItem("rename")); + rule.Add(new SettingItem("constants")); + project.Rules.Add(rule); + + var report = CollectorFor(project).GenerateReport(); + + Assert.Contains("rename", report); + Assert.Contains("constants", report); + } + + [Fact] + public void Generate_NeverLeaksStrongNamePassword() { + var project = new ConfuserProject { BaseDirectory = @"C:\proj", OutputDirectory = @"C:\proj\out" }; + project.Add(new ProjectModule { + Path = "App.dll", + SNKeyPath = @"C:\proj\key.snk", + SNKeyPassword = "SuperSecret123", + SNSigKeyPassword = "AlsoSecret456" + }); + + var report = CollectorFor(project).GenerateReport(); + + Assert.DoesNotContain("SuperSecret123", report); + Assert.DoesNotContain("AlsoSecret456", report); + // but the module itself is still listed for context + Assert.Contains("App.dll", report); + } + + [Fact] + public void Generate_ListsPacker_OrNoneWhenAbsent() { + var withPacker = new ConfuserProject { Packer = new SettingItem("compressor") }; + Assert.Contains("compressor", CollectorFor(withPacker).GenerateReport()); + + var noPacker = new ConfuserProject(); + Assert.Contains("Packer: (none)", CollectorFor(noPacker).GenerateReport()); + } + + [Fact] + public void Generate_UsesDynamicFence_WhenLogContainsBacktickFence() { + var collector = CollectorFor(new ConfuserProject()); + collector.LogInformation("here is a ``` fence inside a message"); + + var report = collector.GenerateReport(); + + // a plain ``` fence would be broken by the message; the report must use a longer fence + Assert.Contains("````", report); + } + + [Theory] + [InlineData(@"C:\Users\alice\proj\App.dll", @"C:\Users\alice", "%USER%")] + [InlineData(@"c:\users\ALICE\proj\App.dll", @"C:\Users\alice", "%USER%")] + public void Redact_ReplacesUserProfilePrefix(string text, string userProfile, string expectedMarker) { + var result = DiagnosticRedactor.Redact(text, userProfile); + Assert.DoesNotContain("alice", result, System.StringComparison.OrdinalIgnoreCase); + Assert.Contains(expectedMarker, result); + } + + [Fact] + public void Redact_LeavesTextWithoutProfileUntouched() { + Assert.Equal("no paths here", DiagnosticRedactor.Redact("no paths here", @"C:\Users\alice")); + } + } +} From 04b544ef91cd85f3f91882a26ee0b9fb979ea43b Mon Sep 17 00:00:00 2001 From: RandomCrocodile Date: Sat, 4 Jul 2026 11:41:31 +0200 Subject: [PATCH 2/4] feature: add --dump diagnostic report flag to CLI (#65) Wraps the logger and progress reporter with a DiagnosticCollector when --dump is passed, and writes the markdown report after the run completes (success or failure). --dump uses a default filename; --dump= writes to a custom path. The report path is printed to the console. E2E test asserts the report is written with the expected sections. --- Confuser.CLI/Program.cs | 42 +++++++++++++++++-- Tests/Confuser.CLI.Test/CliEndToEndTest.cs | 49 ++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) 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/Tests/Confuser.CLI.Test/CliEndToEndTest.cs b/Tests/Confuser.CLI.Test/CliEndToEndTest.cs index 5853bf88c..2d56bc97c 100644 --- a/Tests/Confuser.CLI.Test/CliEndToEndTest.cs +++ b/Tests/Confuser.CLI.Test/CliEndToEndTest.cs @@ -70,6 +70,55 @@ public void Obfuscate_SampleApp_ProducesRunnableOutput() { } } + [Fact] + public void Cli_DumpFlag_WritesDiagnosticReport() { + var sampleAppExe = Path.Combine(AppContext.BaseDirectory, "Fixtures", "SampleApp", "bin", "SampleApp.exe"); + Assert.True(File.Exists(sampleAppExe), $"Pre-built SampleApp.exe not found at {sampleAppExe}"); + + var cliDll = Path.Combine(AppContext.BaseDirectory, "Confuser.CLI.dll"); + Assert.True(File.Exists(cliDll), $"Confuser.CLI.dll not found at {cliDll}"); + + var testDir = Path.Combine(Path.GetTempPath(), "confuserex-cli-dump-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(testDir); + + try { + File.Copy(sampleAppExe, Path.Combine(testDir, "SampleApp.exe")); + + var crproj = Path.Combine(testDir, "SampleApp.crproj"); + File.WriteAllText(crproj, +@" + + + + +"); + + var reportPath = Path.Combine(testDir, "report.md"); + + // Act — run Confuser.CLI with the --dump flag pointing at an explicit path + var cliResult = RunProcess("dotnet", $"\"{cliDll}\" -n --dump=\"{reportPath}\" \"{crproj}\""); + output.WriteLine("=== Confuser.CLI Output ==="); + output.WriteLine(cliResult.stdout); + if (!string.IsNullOrEmpty(cliResult.stderr)) + output.WriteLine(cliResult.stderr); + Assert.Equal(0, cliResult.exitCode); + + // Assert — the report was written and announced + Assert.True(File.Exists(reportPath), $"Diagnostic report should exist at {reportPath}"); + Assert.Contains("Diagnostic report written to:", cliResult.stdout); + + var report = File.ReadAllText(reportPath); + Assert.Contains("## System", report); + Assert.Contains("## Project Configuration", report); + Assert.Contains("## Result: SUCCESS", report); + Assert.Contains("## Log Output", report); + Assert.Contains("SampleApp.exe", report); + } + finally { + try { Directory.Delete(testDir, true); } catch { } + } + } + [Fact] public void Cli_NoArgs_ReturnsNonZeroAndShowsUsage() { var cliDll = Path.Combine(AppContext.BaseDirectory, "Confuser.CLI.dll"); From 557dad9b8354a955844324e2d112aab60b96668b Mon Sep 17 00:00:00 2001 From: RandomCrocodile Date: Sat, 4 Jul 2026 11:43:24 +0200 Subject: [PATCH 3/4] feature: add Copy Report button to GUI protect tab (#65) The protect tab now wraps the logger and progress reporter with a DiagnosticCollector during each run. A 'Copy Report' button (enabled once a run completes, success or failure) copies the markdown diagnostic report to the clipboard for pasting into a bug report. Clipboard failures are swallowed so a transient lock cannot crash the app. --- ConfuserEx/ViewModel/UI/ProtectTabVM.cs | 27 +++++++++++++++++++++++-- ConfuserEx/Views/ProtectTabView.xaml | 10 ++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) 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 @@ + -