Skip to content

Commit a3c177f

Browse files
mcpolo99RandomCrocodile
andauthored
feature: diagnostic report collector for issue reporting (#65) (#88)
* 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. * 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=<file> writes to a custom path. The report path is printed to the console. E2E test asserts the report is written with the expected sections. * 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. * feature: add best-effort target framework to diagnostic report (#65) Reads each input module's TargetFrameworkAttribute (via dnlib, from an in-memory copy so the file is never locked) and adds a 'Target Framework' line to the report's configuration section. Best-effort: silently omitted when a module is missing, not a valid assembly, or predates the attribute (net2.0-3.5). Verified end-to-end — a net8 library reports '.NETCoreApp,Version=v8.0'; a failing run on an invalid assembly still produces a clean FAILED report. 4 new tests. --------- Co-authored-by: RandomCrocodile <mawi@polosab.com>
1 parent 9844573 commit a3c177f

9 files changed

Lines changed: 879 additions & 9 deletions

File tree

Confuser.CLI/Program.cs

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Linq;
66
using System.Xml;
77
using Confuser.Core;
8+
using Confuser.Core.Diagnostics;
89
using Confuser.Core.Project;
910
using Microsoft.Extensions.Logging;
1011
using NDesk.Options;
@@ -25,6 +26,8 @@ static int Main(string[] args) {
2526
bool noPause = false;
2627
bool debug = false;
2728
bool quiet = false;
29+
bool dumpRequested = false;
30+
string dumpPath = null;
2831
int verbosity = 0;
2932
string outDir = null;
3033
string snKeyPath = null;
@@ -59,6 +62,9 @@ static int Main(string[] args) {
5962
}, {
6063
"q|quiet", "only show warnings and errors.",
6164
value => { quiet = (value != null); }
65+
}, {
66+
"dump:", "write a diagnostic report (optionally to the given file).",
67+
value => { dumpRequested = true; if (!string.IsNullOrEmpty(value)) dumpPath = value; }
6268
}
6369
};
6470

@@ -141,7 +147,7 @@ static int Main(string[] args) {
141147
parameters.Project = proj;
142148
}
143149

144-
int retVal = RunProject(parameters, quiet, verbosity);
150+
int retVal = RunProject(parameters, quiet, verbosity, dumpRequested, dumpPath);
145151

146152
if (NeedPause() && !noPause) {
147153
Console.WriteLine("Press any key to continue...");
@@ -203,7 +209,7 @@ static void LoadTemplateProject(string templatePath, ConfuserProject proj, List<
203209
templateModules.Add(templateModule);
204210
}
205211

206-
static int RunProject(ConfuserParameters parameters, bool quiet, int verbosity) {
212+
static int RunProject(ConfuserParameters parameters, bool quiet, int verbosity, bool dumpRequested, string dumpPath) {
207213
var levelSwitch = quiet
208214
? LogEventLevel.Warning
209215
: verbosity >= 3 ? LogEventLevel.Verbose
@@ -222,17 +228,44 @@ static int RunProject(ConfuserParameters parameters, bool quiet, int verbosity)
222228
var melLogger = loggerFactory.CreateLogger("ConfuserEx");
223229

224230
var progressReporter = new ConsoleProgressReporter();
225-
parameters.Logger = melLogger;
226-
parameters.ProgressReporter = progressReporter;
231+
232+
// When a diagnostic report is requested, wrap both the logger and the progress reporter
233+
// with a collector so the report captures the full transcript, timing and outcome even
234+
// when the run fails.
235+
DiagnosticCollector collector = null;
236+
if (dumpRequested) {
237+
collector = new DiagnosticCollector(melLogger, progressReporter) { Project = parameters.Project };
238+
parameters.Logger = collector;
239+
parameters.ProgressReporter = collector;
240+
}
241+
else {
242+
parameters.Logger = melLogger;
243+
parameters.ProgressReporter = progressReporter;
244+
}
227245

228246
if (OperatingSystem.IsWindows())
229247
Console.Title = "ConfuserEx - Running...";
230248
ConfuserEngine.Run(parameters).GetAwaiter().GetResult();
231249

232250
Log.CloseAndFlush();
251+
252+
if (collector != null)
253+
WriteDiagnosticReport(collector, dumpPath);
254+
233255
return progressReporter.ReturnValue;
234256
}
235257

258+
static void WriteDiagnosticReport(DiagnosticCollector collector, string dumpPath) {
259+
string path = string.IsNullOrEmpty(dumpPath) ? "confuser-diagnostic-report.md" : dumpPath;
260+
try {
261+
File.WriteAllText(path, collector.GenerateReport());
262+
WriteLineWithColor(ConsoleColor.Cyan, "Diagnostic report written to: " + Path.GetFullPath(path));
263+
}
264+
catch (Exception ex) {
265+
WriteLineWithColor(ConsoleColor.Red, "Failed to write diagnostic report: " + ex.Message);
266+
}
267+
}
268+
236269
static bool NeedPause() {
237270
return Debugger.IsAttached || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("PROMPT"));
238271
}
@@ -250,6 +283,7 @@ static void PrintUsage() {
250283
WriteLine(" -snkeypass : specifies strong name key password.");
251284
WriteLine(" -v|verbose : increase verbosity (-v debug, -vv trace).");
252285
WriteLine(" -q|quiet : only show warnings and errors.");
286+
WriteLine(" -dump : write a diagnostic report (-dump=<file> for a custom path).");
253287
}
254288

255289
static void WriteLineWithColor(ConsoleColor color, string txt) {
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Confuser.Core.Project;
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace Confuser.Core.Diagnostics {
7+
/// <summary>
8+
/// A single captured log entry, rendered to text at capture time.
9+
/// </summary>
10+
public readonly struct DiagnosticLogEntry {
11+
public DiagnosticLogEntry(LogLevel level, string message, string exception) {
12+
Level = level;
13+
Message = message;
14+
Exception = exception;
15+
}
16+
17+
/// <summary>The severity of the entry.</summary>
18+
public LogLevel Level { get; }
19+
20+
/// <summary>The rendered message text.</summary>
21+
public string Message { get; }
22+
23+
/// <summary>The rendered exception (including stack trace), or <c>null</c> if none.</summary>
24+
public string Exception { get; }
25+
}
26+
27+
/// <summary>
28+
/// Wraps the real <see cref="ILogger" /> and <see cref="IProgressReporter" /> used during an
29+
/// obfuscation run, passing every call through while capturing a full-verbosity transcript,
30+
/// timing and outcome. On completion — success or failure — it can produce a self-contained
31+
/// markdown diagnostic report suitable for a bug report.
32+
/// </summary>
33+
/// <remarks>
34+
/// <para>
35+
/// Capture is intentionally independent of the inner logger's level: the collector reports
36+
/// <see cref="IsEnabled" /> as <c>true</c> and keeps every entry, so a report from a default
37+
/// (Information) run still contains the Debug detail needed to diagnose a failure. Display
38+
/// filtering is preserved because entries are only forwarded to the inner logger when it is
39+
/// enabled for that level.
40+
/// </para>
41+
/// <para>
42+
/// The entry buffer is bounded (see <see cref="DefaultCapacity" />); once full, the oldest
43+
/// entries are dropped and counted in <see cref="DroppedCount" /> so the report can note the
44+
/// loss rather than silently mislead.
45+
/// </para>
46+
/// <para>
47+
/// <see cref="Finish" /> is last-wins: a packer runs a nested engine pass with the same
48+
/// collector, so the top-level run — which finishes last — determines the reported outcome
49+
/// and elapsed time.
50+
/// </para>
51+
/// </remarks>
52+
public sealed class DiagnosticCollector : ILogger, IProgressReporter {
53+
/// <summary>The default maximum number of log entries retained.</summary>
54+
public const int DefaultCapacity = 2000;
55+
56+
readonly ILogger inner;
57+
readonly IProgressReporter innerReporter;
58+
readonly int capacity;
59+
readonly object sync = new object();
60+
readonly Queue<DiagnosticLogEntry> entries;
61+
readonly DateTime begin = DateTime.UtcNow;
62+
int dropped;
63+
bool? successful;
64+
TimeSpan elapsed;
65+
66+
/// <summary>
67+
/// Initializes a new collector.
68+
/// </summary>
69+
/// <param name="inner">The logger to forward display output to. Required.</param>
70+
/// <param name="innerReporter">The progress reporter to forward to, or <c>null</c>.</param>
71+
/// <param name="capacity">The maximum number of log entries to retain.</param>
72+
public DiagnosticCollector(ILogger inner, IProgressReporter innerReporter = null, int capacity = DefaultCapacity) {
73+
this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
74+
this.innerReporter = innerReporter;
75+
this.capacity = capacity < 1 ? 1 : capacity;
76+
entries = new Queue<DiagnosticLogEntry>(Math.Min(this.capacity, 64));
77+
}
78+
79+
/// <summary>
80+
/// The project being processed, used to populate the report's configuration section.
81+
/// </summary>
82+
public ConfuserProject Project { get; set; }
83+
84+
/// <summary>
85+
/// The run outcome: <c>true</c> on success, <c>false</c> on failure, <c>null</c> if the run
86+
/// never reported completion.
87+
/// </summary>
88+
public bool? Successful {
89+
get { lock (sync) return successful; }
90+
}
91+
92+
/// <summary>The elapsed time recorded at the last <see cref="Finish" /> call.</summary>
93+
public TimeSpan Elapsed {
94+
get { lock (sync) return elapsed; }
95+
}
96+
97+
/// <summary>The number of log entries dropped because the buffer was full.</summary>
98+
public int DroppedCount {
99+
get { lock (sync) return dropped; }
100+
}
101+
102+
/// <summary>
103+
/// Returns an immutable copy of the currently retained log entries, oldest first.
104+
/// </summary>
105+
public IReadOnlyList<DiagnosticLogEntry> Snapshot() {
106+
lock (sync) return new List<DiagnosticLogEntry>(entries);
107+
}
108+
109+
/// <summary>
110+
/// Produces the markdown diagnostic report. Never throws.
111+
/// </summary>
112+
public string GenerateReport() => DiagnosticReport.Generate(this);
113+
114+
#region ILogger
115+
116+
public IDisposable BeginScope<TState>(TState state) => inner.BeginScope(state);
117+
118+
public bool IsEnabled(LogLevel logLevel) => true;
119+
120+
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception,
121+
Func<TState, Exception, string> formatter) {
122+
string message;
123+
try {
124+
message = formatter != null ? formatter(state, exception) : state?.ToString() ?? string.Empty;
125+
}
126+
catch {
127+
message = state?.ToString() ?? string.Empty;
128+
}
129+
130+
var entry = new DiagnosticLogEntry(logLevel, message, exception?.ToString());
131+
lock (sync) {
132+
entries.Enqueue(entry);
133+
while (entries.Count > capacity) {
134+
entries.Dequeue();
135+
dropped++;
136+
}
137+
}
138+
139+
// Forward to the inner logger for display; it applies its own level filter.
140+
if (inner.IsEnabled(logLevel))
141+
inner.Log(logLevel, eventId, state, exception, formatter);
142+
}
143+
144+
#endregion
145+
146+
#region IProgressReporter
147+
148+
public void Progress(int progress, int overall) => innerReporter?.Progress(progress, overall);
149+
150+
public void EndProgress() => innerReporter?.EndProgress();
151+
152+
public void Finish(bool successful) {
153+
lock (sync) {
154+
this.successful = successful;
155+
elapsed = DateTime.UtcNow - begin;
156+
}
157+
158+
innerReporter?.Finish(successful);
159+
}
160+
161+
#endregion
162+
}
163+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System;
2+
using System.Text;
3+
4+
namespace Confuser.Core.Diagnostics {
5+
/// <summary>
6+
/// Scrubs sensitive information from text destined for a diagnostic report.
7+
/// </summary>
8+
/// <remarks>
9+
/// Diagnostic reports are meant to be pasted into public issue trackers, so any text
10+
/// that flows into one must have the reporter's identity removed. The most common leak
11+
/// is the user-profile path (e.g. <c>C:\Users\alice\...</c>) which appears in absolute
12+
/// paths throughout log output and project configuration.
13+
/// </remarks>
14+
public static class DiagnosticRedactor {
15+
/// <summary>
16+
/// The placeholder substituted for the user-profile directory.
17+
/// </summary>
18+
public const string UserPlaceholder = "%USER%";
19+
20+
/// <summary>
21+
/// Replaces every occurrence of the user-profile directory in <paramref name="text" />
22+
/// with <see cref="UserPlaceholder" />. The match is case-insensitive because Windows
23+
/// paths are.
24+
/// </summary>
25+
/// <param name="text">The text to scrub. Returned unchanged if <c>null</c> or empty.</param>
26+
/// <param name="userProfile">The user-profile directory to redact, or <c>null</c> to skip.</param>
27+
/// <returns>The scrubbed text.</returns>
28+
public static string Redact(string text, string userProfile) {
29+
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(userProfile))
30+
return text;
31+
return ReplaceCaseInsensitive(text, userProfile, UserPlaceholder);
32+
}
33+
34+
static string ReplaceCaseInsensitive(string input, string search, string replacement) {
35+
var sb = new StringBuilder(input.Length);
36+
int index = 0;
37+
while (true) {
38+
int found = input.IndexOf(search, index, StringComparison.OrdinalIgnoreCase);
39+
if (found < 0) {
40+
sb.Append(input, index, input.Length - index);
41+
break;
42+
}
43+
44+
sb.Append(input, index, found - index);
45+
sb.Append(replacement);
46+
index = found + search.Length;
47+
}
48+
49+
return sb.ToString();
50+
}
51+
}
52+
}

0 commit comments

Comments
 (0)