From f3280d94e36c4ac7d90a7a3997f48e358df24e1d Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Tue, 15 Sep 2026 09:17:42 -1000 Subject: [PATCH 1/7] docs: describe auto-tunnel when a server blocks the proxy. --- README.md | 2 +- website/docs/features.md | 2 +- website/docs/getting-started.md | 2 +- website/index.md | 4 ++-- wiki/Features.md | 2 +- wiki/Home.md | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 832865f7d..9171682a5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Requires .NET 10 or later for the library. CLI and Inspector downloads are self- ## What you can do - Decrypt and inspect HTTPS in a native desktop Inspector on Windows, macOS, and Linux — AutoResponder, Map Local/Remote, breakpoints, Composer, HAR, curl/fetch -- Keep sign-in working: SSO hosts stay on OS bypass; hosts that reject MITM auto-tunnel +- When a server blocks the proxy (TLS fingerprinting or other reasons), we detect it and automatically tunnel that traffic - Speak modern HTTP — HTTP/2 by default, optional HTTP/3, WebSocket, gRPC, SSE, GraphQL rules in the same grid - Run the same engine as a reverse / edge proxy from the CLI (YAML, load balancing, ACME, live reload) - Embed in .NET via NuGet (MIT); Inspector is free for personal and education use diff --git a/website/docs/features.md b/website/docs/features.md index 740985891..dabb46b92 100644 --- a/website/docs/features.md +++ b/website/docs/features.md @@ -13,7 +13,7 @@ Desktop HTTP(S) debugger on Windows, macOS, and Linux. [Inspector guide](/docs/i | Decrypt HTTPS | MITM decrypt on machines you control; install a local root CA | | System proxy | One toggle for OS proxy on Windows, macOS, and Linux | | Trust that works | Firefox / NSS, Linux Chromium Snap/Flatpak, Windows Store apps; export CA for phones | -| Login-safe decrypt | SSO hosts stay on OS bypass; auto-tunnel when MITM fails or the origin returns 403/429 | +| Automatic tunnel | When a server blocks the proxy (TLS fingerprinting or other reasons), we detect it and automatically tunnel that traffic | | Session grid | Method, status, host, URL, protocol, duration, TTFB, size, process | | Inspect panes | Headers, Pretty/Raw body, Hex, WebSocket frames, SSE, Protobuf wire dump | | Rewrite toolkit | AutoResponder, Map Local, Map Remote, breakpoints, Composer | diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md index 8f0b7599a..6af91423d 100644 --- a/website/docs/getting-started.md +++ b/website/docs/getting-started.md @@ -10,7 +10,7 @@ Optional **Plus** adds a dashboard and ops features on top of the CLI. Everythin ## Highlights -Native HTTPS Inspector with AutoResponder, Map Local/Remote, breakpoints, Composer, and HAR; login-safe decrypt (SSO bypass + auto-tunnel); HTTP/2 / HTTP/3 with WebSocket, gRPC, and GraphQL in the grid; the same engine as a reverse/edge CLI; embed via NuGet. Full catalog: [Features](/docs/features). +Native HTTPS Inspector with AutoResponder, Map Local/Remote, breakpoints, Composer, and HAR; when a server blocks the proxy we detect it and tunnel automatically; HTTP/2 / HTTP/3 with WebSocket, gRPC, and GraphQL in the grid; the same engine as a reverse/edge CLI; embed via NuGet. Full catalog: [Features](/docs/features). ## Choose a path diff --git a/website/index.md b/website/index.md index 1d7476774..cedbaf6bb 100644 --- a/website/index.md +++ b/website/index.md @@ -23,8 +23,8 @@ features: details: Native desktop Inspector on Windows, macOS, and Linux — decrypt HTTPS, turn on system proxy, and inspect sessions, headers, and bodies. Only on machines you control. - title: Rewrite and replay details: AutoResponder, Map Local, Map Remote, breakpoints, and Composer. Export or import HAR; copy sessions as curl or fetch. - - title: Login-safe decrypt - details: SSO hosts stay on OS bypass by default. When a site rejects MITM, Inspector auto-tunnels so sign-in and hostile pages keep working. + - title: Automatic tunnel + details: When a server blocks the proxy (TLS fingerprinting or other reasons), we detect it and automatically tunnel that traffic. - title: HTTP/1 · HTTP/2 · HTTP/3 details: HTTP/2 is on by default; HTTP/3 (QUIC) is optional. Bridge when client and backend differ. Inspect WebSocket, gRPC, SSE, and GraphQL in the same grid. - title: Reverse proxy from the CLI diff --git a/wiki/Features.md b/wiki/Features.md index ddaa270e0..118e479e7 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -5,7 +5,7 @@ Short catalog of what Titanium offers. Product how-tos live on the **[website Fe ## Highlights - **Inspector** — native HTTPS debugger on Windows, macOS, and Linux: decrypt, system proxy, AutoResponder / Map Local / Map Remote, breakpoints, Composer, HAR, curl/fetch -- **Login-safe decrypt** — SSO hosts stay on OS bypass; auto-tunnel when MITM fails so sign-in keeps working +- When a server blocks the proxy (TLS fingerprinting or other reasons), we detect it and automatically tunnel that traffic - **Modern protocols** — HTTP/2 by default, optional HTTP/3, WebSocket, SSE, gRPC/Protobuf, GraphQL `operationName` rules - **CLI reverse / edge proxy** — YAML routes, load balancing, ACME, live reload, OS service ([CLI](https://titaniumproxy.com/docs/cli)) - **Library** — embed the same engine in .NET ([Library](https://titaniumproxy.com/docs/library)); wiki Home below covers the API in depth diff --git a/wiki/Home.md b/wiki/Home.md index a40a4bb97..7b35c7db2 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -9,7 +9,7 @@ A lightweight, high-performance HTTP(S) proxy for Windows, Linux, and macOS — Product guides (Inspector, CLI, Plus) live on the **[website](https://titaniumproxy.com/docs/features)**. This wiki focuses on the library API, protocol matrix, and performance tables. - Native HTTPS **Inspector** on Windows, macOS, and Linux — decrypt, AutoResponder / Map Local / Map Remote, breakpoints, Composer, HAR -- **Login-safe decrypt** — SSO bypass and auto-tunnel when sites reject MITM +- When a server blocks the proxy (TLS fingerprinting or other reasons), we detect it and automatically tunnel that traffic - **HTTP/2** by default, optional **HTTP/3**, WebSocket / SSE / gRPC / GraphQL in the session grid - Same engine as a **reverse / edge CLI** (YAML, load balancing, ACME, live reload) - Embed via the **.NET library** (MIT); Inspector free for personal / education use (PolyForm Noncommercial) From 21cb365da39fa0b50aacabcbf6d682c67d60fabb Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Tue, 15 Sep 2026 13:45:50 -0700 Subject: [PATCH 2/7] fix(sonar): clear develop code smells without Core RPS regressions. PE review: SessionEventArgs helper extract is control-flow only; H2 S107 and TCP S108 use justified suppress/comments (no heap param objects, no dispose logging); ResponseHandler drops a redundant !. Inspector/CLI/tests absorb complexity and dead-code cleanup. HKCU Firefox registry assert remains an environmental flake unrelated to this diff. --- src/Titanium.Cli/Config/RunCommand.cs | 14 +- .../Updates/VersionAndUpdateCommands.cs | 186 +++++++++------- .../Services/AvaloniaStatusNotifier.cs | 2 +- .../Services/InspectorBodyLimits.cs | 107 +++++---- .../Services/InterceptionService.cs | 111 +++++----- .../Services/ReplayService.cs | 38 ++-- .../Services/SessionStore.cs | 4 +- .../Services/UpdateService.cs | 2 +- .../MainWindowViewModel.BodyInspect.cs | 158 +++++++++----- .../ViewModels/MainWindowViewModel.Trust.cs | 206 +++++++++--------- .../ViewModels/MainWindowViewModel.Updates.cs | 16 +- .../ViewModels/MainWindowViewModel.cs | 171 ++++++++------- src/Titanium.Inspector/Views/MainWindow.axaml | 2 +- .../Certificates/FirefoxCertificateTrust.cs | 25 ++- .../EventArguments/SessionEventArgs.cs | 79 +++---- .../Handlers/Http2NegotiationHandler.cs | 6 + .../Handlers/ResponseHandler.cs | 2 +- .../TcpConnection/TcpClientConnection.cs | 4 + .../ConfigReloadGateTests.cs | 13 +- .../Harness/CliProcessHarness.cs | 2 +- .../CaptureSettingsParityTests.cs | 13 -- .../InspectorCommandCoverageTests.cs | 4 - ...ptionBodyCaptureAndTrustBgCoverageTests.cs | 3 +- .../InterceptionCaptureCoverageTests.cs | 20 +- .../SonarPrCoverageGapTests.cs | 6 +- .../TrustCommandCoverageTests.cs | 1 - .../TrustDecisionTableTests.cs | 10 +- .../TrustUxStressTests.cs | 7 +- .../Http2ServerHelloProbeBudgetTests.cs | 2 +- 29 files changed, 682 insertions(+), 532 deletions(-) diff --git a/src/Titanium.Cli/Config/RunCommand.cs b/src/Titanium.Cli/Config/RunCommand.cs index ed6d7e644..5563ea326 100644 --- a/src/Titanium.Cli/Config/RunCommand.cs +++ b/src/Titanium.Cli/Config/RunCommand.cs @@ -558,7 +558,12 @@ private static async Task WaitForShutdownOrReloadAsync( // NOSONAR CA1068 -- Tok watchDebounce = new CancellationTokenSource(); void OnWatch(object sender, FileSystemEventArgs e) { - var prev = watchDebounce!; + var prev = watchDebounce; + if (prev is null) + { + return; + } + prev.Cancel(); prev.Dispose(); var next = new CancellationTokenSource(); @@ -600,8 +605,11 @@ void OnWatch(object sender, FileSystemEventArgs e) winReloadWait?.Unregister(null); winReload?.Dispose(); watcher?.Dispose(); - watchDebounce?.Cancel(); - watchDebounce?.Dispose(); + if (watchDebounce is not null) + { + await watchDebounce.CancelAsync().ConfigureAwait(false); + watchDebounce.Dispose(); + } } } #pragma warning restore CA1068 diff --git a/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs b/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs index b5cfcec25..b3d14c0d6 100644 --- a/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs +++ b/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs @@ -529,26 +529,7 @@ private static async Task UpdateCliAsync(ReleaseManifest manifest, string c if (!VersionCommand.ShouldInstallCliRelease( local, remoteText, channelDisplay, installedTag, installedChannel, localInfo)) { - if (ReleaseVersion.CompareReleaseTags(localLabel, remoteText) > 0 - && !ReleaseVersion.IsPrereleaseTag(remoteText)) - { - AsyncConsole.WriteLine( - $"Local Cli {localDisplay} is newer than {channelDisplay} {remoteText}. No changes."); - } - else if (channelDisplay.Equals("beta", StringComparison.OrdinalIgnoreCase) - && ReleaseVersion.IsPrereleaseTag(remoteText) - && !ReleaseVersion.IsPrereleaseTag(localLabel) - && ReleaseVersion.ParseComparable(localLabel) - == ReleaseVersion.ParseComparable(remoteText)) - { - AsyncConsole.WriteLine( - $"No newer Beta than your current build ({localLabel}). Latest Beta is {remoteText}."); - } - else - { - AsyncConsole.WriteLine($"Titanium CLI is up to date ({remoteText}, {channelDisplay})."); - } - + WriteCliAlreadyCurrentMessage(localDisplay, localLabel, remoteText, channelDisplay); return 0; } @@ -572,6 +553,69 @@ private static async Task UpdateCliAsync(ReleaseManifest manifest, string c Directory.CreateDirectory(workDir); var destZip = Path.Combine(workDir, $"Titanium.Cli-{rid}-{remoteText}.zip"); + var downloadRc = await DownloadVerifiedAssetAsync(asset, destZip, "SHA256 mismatch — aborting update."); + if (downloadRc != 0) + return downloadRc; + + var installDir = AppContext.BaseDirectory.TrimEnd( + Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var relaunch = ResolveCliRelaunchPath(installDir); + + AsyncConsole.WriteLine("Restarting updater…"); + CliUpdateApplyHelper.StartDetached( + Environment.ProcessId, + destZip, + installDir, + relaunch, + remoteText, + channelDisplay); + + WriteCliIdentity(remoteText, channelDisplay); + AsyncConsole.WriteLine( + $"Installing {remoteText} ({channelDisplay}) in the background. When finished, run: titanium version"); + return 0; + } + + private static void WriteCliAlreadyCurrentMessage( + string localDisplay, string localLabel, string remoteText, string channelDisplay) + { + if (ReleaseVersion.CompareReleaseTags(localLabel, remoteText) > 0 + && !ReleaseVersion.IsPrereleaseTag(remoteText)) + { + AsyncConsole.WriteLine( + $"Local Cli {localDisplay} is newer than {channelDisplay} {remoteText}. No changes."); + return; + } + + if (channelDisplay.Equals("beta", StringComparison.OrdinalIgnoreCase) + && ReleaseVersion.IsPrereleaseTag(remoteText) + && !ReleaseVersion.IsPrereleaseTag(localLabel) + && ReleaseVersion.ParseComparable(localLabel) + == ReleaseVersion.ParseComparable(remoteText)) + { + AsyncConsole.WriteLine( + $"No newer Beta than your current build ({localLabel}). Latest Beta is {remoteText}."); + return; + } + + AsyncConsole.WriteLine($"Titanium CLI is up to date ({remoteText}, {channelDisplay})."); + } + + private static string ResolveCliRelaunchPath(string installDir) + { + var exeName = OperatingSystem.IsWindows() ? "titanium.exe" : "titanium"; + var relaunch = Path.Combine(installDir, exeName); + if (!File.Exists(relaunch)) + { + relaunch = Path.Combine(installDir, OperatingSystem.IsWindows() ? "twp.exe" : "twp"); + } + + return relaunch; + } + + private static async Task DownloadVerifiedAssetAsync( + AssetBlock asset, string destPath, string shaMismatchMessage) + { try { using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; @@ -583,41 +627,19 @@ private static async Task UpdateCliAsync(ReleaseManifest manifest, string c var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); if (!hash.Equals(asset.Sha256, StringComparison.OrdinalIgnoreCase)) { - AsyncConsole.WriteError("SHA256 mismatch — aborting update."); + AsyncConsole.WriteError(shaMismatchMessage); return 1; } } - await File.WriteAllBytesAsync(destZip, bytes); + await File.WriteAllBytesAsync(destPath, bytes); + return 0; } catch (Exception ex) { AsyncConsole.WriteError($"Download failed: {ex.Message}"); return 1; } - - var installDir = AppContext.BaseDirectory.TrimEnd( - Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var exeName = OperatingSystem.IsWindows() ? "titanium.exe" : "titanium"; - var relaunch = Path.Combine(installDir, exeName); - if (!File.Exists(relaunch)) - { - relaunch = Path.Combine(installDir, OperatingSystem.IsWindows() ? "twp.exe" : "twp"); - } - - AsyncConsole.WriteLine("Restarting updater…"); - CliUpdateApplyHelper.StartDetached( - Environment.ProcessId, - destZip, - installDir, - relaunch, - remoteText, - channelDisplay); - - WriteCliIdentity(remoteText, channelDisplay); - AsyncConsole.WriteLine( - $"Installing {remoteText} ({channelDisplay}) in the background. When finished, run: titanium version"); - return 0; } private static async Task UpdatePlusAsync(ReleaseManifest manifest, string channelDisplay) @@ -637,38 +659,10 @@ private static async Task UpdatePlusAsync(ReleaseManifest manifest, string var plusInfo = VersionCommand.TryGetLocalPlusInformationalVersion(); var installing = plusLocal is null; - if (plusLocal is not null) + if (plusLocal is not null + && TryWritePlusAlreadyCurrent(asset, dest, plusLocal, remoteLabel, channelDisplay, plusInfo)) { - if (!string.IsNullOrEmpty(asset.Sha256) && File.Exists(dest) && FileSha256Matches(dest, asset.Sha256)) - { - AsyncConsole.WriteLine( - $"Plus is already at {remoteLabel} ({channelDisplay})."); - return 0; - } - - if (!VersionCommand.ShouldInstallPlusRelease(plusLocal, remoteLabel, plusInfo)) - { - var localLabel = ReleaseVersion.ResolveLocalReleaseLabel(plusLocal, plusInfo, null); - if (ReleaseVersion.CompareReleaseTags(localLabel, remoteLabel) > 0) - { - AsyncConsole.WriteLine( - $"Local Plus {localLabel} is newer than {channelDisplay} {remoteLabel}. No changes."); - } - else if (channelDisplay.Equals("beta", StringComparison.OrdinalIgnoreCase) - && ReleaseVersion.IsPrereleaseTag(remoteLabel) - && !ReleaseVersion.IsPrereleaseTag(localLabel)) - { - AsyncConsole.WriteLine( - $"No newer Beta Plus than your current build ({localLabel}). Latest Beta is {remoteLabel}."); - } - else - { - AsyncConsole.WriteLine( - $"Plus is already at {remoteLabel} ({channelDisplay})."); - } - - return 0; - } + return 0; } AsyncConsole.WriteLine(installing @@ -721,6 +715,46 @@ private static async Task UpdatePlusAsync(ReleaseManifest manifest, string } } + private static bool TryWritePlusAlreadyCurrent( + AssetBlock asset, + string dest, + Version plusLocal, + string remoteLabel, + string channelDisplay, + string? plusInfo) + { + if (!string.IsNullOrEmpty(asset.Sha256) && File.Exists(dest) && FileSha256Matches(dest, asset.Sha256)) + { + AsyncConsole.WriteLine( + $"Plus is already at {remoteLabel} ({channelDisplay})."); + return true; + } + + if (VersionCommand.ShouldInstallPlusRelease(plusLocal, remoteLabel, plusInfo)) + return false; + + var localLabel = ReleaseVersion.ResolveLocalReleaseLabel(plusLocal, plusInfo, null); + if (ReleaseVersion.CompareReleaseTags(localLabel, remoteLabel) > 0) + { + AsyncConsole.WriteLine( + $"Local Plus {localLabel} is newer than {channelDisplay} {remoteLabel}. No changes."); + } + else if (channelDisplay.Equals("beta", StringComparison.OrdinalIgnoreCase) + && ReleaseVersion.IsPrereleaseTag(remoteLabel) + && !ReleaseVersion.IsPrereleaseTag(localLabel)) + { + AsyncConsole.WriteLine( + $"No newer Beta Plus than your current build ({localLabel}). Latest Beta is {remoteLabel}."); + } + else + { + AsyncConsole.WriteLine( + $"Plus is already at {remoteLabel} ({channelDisplay})."); + } + + return true; + } + private static bool FileSha256Matches(string path, string expectedHex) { try diff --git a/src/Titanium.Inspector/Services/AvaloniaStatusNotifier.cs b/src/Titanium.Inspector/Services/AvaloniaStatusNotifier.cs index 09cb0e165..f97ab5938 100644 --- a/src/Titanium.Inspector/Services/AvaloniaStatusNotifier.cs +++ b/src/Titanium.Inspector/Services/AvaloniaStatusNotifier.cs @@ -40,7 +40,7 @@ public void Show(string message, StatusSeverity severity) _ => "Inspector", }; - // Errors/warnings stay long enough to read (SmartScreen, msiexec, update failures); + // Errors/warnings stay long enough to read (SmartScreen, msiexec, update failures). // Avalonia's notification chrome still lets the user dismiss early. var duration = severity is StatusSeverity.Error or StatusSeverity.Warning ? TimeSpan.FromSeconds(15) diff --git a/src/Titanium.Inspector/Services/InspectorBodyLimits.cs b/src/Titanium.Inspector/Services/InspectorBodyLimits.cs index 0721bd721..538d61e41 100644 --- a/src/Titanium.Inspector/Services/InspectorBodyLimits.cs +++ b/src/Titanium.Inspector/Services/InspectorBodyLimits.cs @@ -26,6 +26,11 @@ public enum BodyCaptureState /// Shared Inspector body/preview limits (capture UI, Composer, AutoResponder). public static class InspectorBodyLimits { + private static readonly System.Text.Json.JsonSerializerOptions PrettyJsonOptions = new() + { + WriteIndented = true, + }; + public const int MaxBodyBytes = 2 * 1024 * 1024; public const int MaxBodyTextChars = 256 * 1024; public const int MaxHexBytes = 4096; @@ -159,7 +164,7 @@ public static BodyCaptureState InferFromBytes(byte[]? bytes, long? originalSize) using var doc = System.Text.Json.JsonDocument.Parse(text); var pretty = System.Text.Json.JsonSerializer.Serialize( doc.RootElement, - new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + PrettyJsonOptions); return TruncateText(pretty); } @@ -217,62 +222,72 @@ private static string IndentMarkupSource(string text) { if (text[i] == '<') { - var end = text.IndexOf('>', i); - if (end < 0) - { - sb.Append(text.AsSpan(i)); - break; - } + i = AppendMarkupTag(sb, text, i, ref depth); + continue; + } - var tag = text.AsSpan(i, end - i + 1); - var isClosing = tag.Length > 1 && tag[1] == '/'; - var isSelfClosing = tag.EndsWith("/>", StringComparison.Ordinal) - || tag.StartsWith(" 0 && sb[^1] != '\n') - { - sb.AppendLine(); - } + return sb.ToString(); + } - sb.Append(' ', depth * 2); - sb.Append(tag); - if (!isClosing && !isSelfClosing) - { - depth++; - } + private static int AppendMarkupTag(StringBuilder sb, string text, int i, ref int depth) + { + var end = text.IndexOf('>', i); + if (end < 0) + { + sb.Append(text.AsSpan(i)); + return text.Length; + } - i = end + 1; - continue; - } + var tag = text.AsSpan(i, end - i + 1); + var isClosing = tag.Length > 1 && tag[1] == '/'; + var isSelfClosing = tag.EndsWith("/>", StringComparison.Ordinal) + || tag.StartsWith(" 0 && sb[^1] != '\n') + { + sb.AppendLine(); + } - var slice = text.AsSpan(i, next - i).Trim(); - if (slice.Length > 0) - { - if (sb.Length > 0 && sb[^1] != '\n') - { - sb.AppendLine(); - } + sb.Append(' ', depth * 2); + sb.Append(tag); + if (!isClosing && !isSelfClosing) + { + depth++; + } + + return end + 1; + } + + private static int AppendMarkupText(StringBuilder sb, string text, int i, int depth) + { + var next = text.IndexOf('<', i); + if (next < 0) + { + sb.Append(text.AsSpan(i).Trim()); + return text.Length; + } - sb.Append(' ', depth * 2); - sb.Append(slice); + var slice = text.AsSpan(i, next - i).Trim(); + if (slice.Length > 0) + { + if (sb.Length > 0 && sb[^1] != '\n') + { + sb.AppendLine(); } - i = next; + sb.Append(' ', depth * 2); + sb.Append(slice); } - return sb.ToString(); + return next; } public static string SuggestBodyFileName(string? url, string? contentDisposition, string? contentType, bool isRequest) diff --git a/src/Titanium.Inspector/Services/InterceptionService.cs b/src/Titanium.Inspector/Services/InterceptionService.cs index 64ce2fb2a..1dfc13af4 100644 --- a/src/Titanium.Inspector/Services/InterceptionService.cs +++ b/src/Titanium.Inspector/Services/InterceptionService.cs @@ -537,34 +537,9 @@ public bool SetSystemProxy(bool enable, InspectorSettings? settings = null, Func try { - if (enable) - { - var effective = settings ?? SystemProxySettings ?? new InspectorSettings(); - SystemProxySettings = effective; - var result = _systemProxy.SetAsSystemProxy(_proxy, _endPoint!, effective); - if (!result.Succeeded) - { - LastSystemProxyError = result.Message; - _proxy.Logger.LogWarning("System proxy enable failed: {Message}", result.Message); - return false; - } - - _systemProxyEnabled = true; - } - else - { - var result = _systemProxy.RestoreOriginalProxySettings(_proxy); - if (!result.Succeeded) - { - LastSystemProxyError = result.Message; - _proxy.Logger.LogWarning("System proxy disable failed: {Message}", result.Message); - return false; - } - - _systemProxyEnabled = false; - } - - return true; + return enable + ? TryEnableSystemProxyLocked(settings) + : TryDisableSystemProxyLocked(); } catch (Exception ex) { @@ -583,6 +558,36 @@ public bool SetSystemProxy(bool enable, InspectorSettings? settings = null, Func } } + private bool TryEnableSystemProxyLocked(InspectorSettings? settings) + { + var effective = settings ?? SystemProxySettings ?? new InspectorSettings(); + SystemProxySettings = effective; + var result = _systemProxy.SetAsSystemProxy(_proxy!, _endPoint!, effective); + if (!result.Succeeded) + { + LastSystemProxyError = result.Message; + _proxy!.Logger.LogWarning("System proxy enable failed: {Message}", result.Message); + return false; + } + + _systemProxyEnabled = true; + return true; + } + + private bool TryDisableSystemProxyLocked() + { + var result = _systemProxy.RestoreOriginalProxySettings(_proxy!); + if (!result.Succeeded) + { + LastSystemProxyError = result.Message; + _proxy!.Logger.LogWarning("System proxy disable failed: {Message}", result.Message); + return false; + } + + _systemProxyEnabled = false; + return true; + } + /// Re-applies system proxy bypass rules when already enabled (after settings change). public bool ReapplySystemProxyIfEnabled() { @@ -777,7 +782,7 @@ public bool FinalizeTrustAfterStoreMutation(bool machineStore, bool? rootStoreAd LastOsTrustResult?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm); } - private bool CompleteRootTrustInstall(bool installed) + private static bool CompleteRootTrustInstall(bool installed) { // Do not write Firefox prefs/policies here - schedule via RunOffUiAsync after success. return installed; @@ -968,7 +973,10 @@ private void LogTrustFirefoxOutcome(CertificateOsTrustResult result) if (_proxy?.Logger is null) return; if (result.Succeeded) - _proxy.Logger.LogInformation("TrustFirefox: {Message}", result.Message); + { + if (_proxy.Logger.IsEnabled(LogLevel.Information)) + _proxy.Logger.LogInformation("TrustFirefox: {Message}", result.Message); + } else _proxy.Logger.LogWarning("TrustFirefox failed ({Kind}): {Message}", result.Kind, result.Message); } @@ -1242,7 +1250,6 @@ public void RemoveOsRootStoreOnly(bool machineStore) } PendingFirefoxRootClearName = RootCertificateName; - var location = machineStore ? StoreLocation.LocalMachine : StoreLocation.CurrentUser; // Combined path for tests: full CN sweep (may CryptUI). UI callers use List + RemoveByThumb. _proxy.CertificateManager.PruneOrphanedSameCommonNameCertificates( machineStore, keepCurrentThumbprint: false); @@ -1572,11 +1579,11 @@ private Task OnBeforeTunnelConnect(object sender, TunnelConnectSessionEventArgs userOnlyHosts: null); var learnedBypass = !disableDecrypt && DecryptHttps && IsLearnedDecryptBypass(host); e.DecryptSsl = DecryptHttps && !disableDecrypt && !learnedBypass; - var opaqueReason = learnedBypass - ? OpaqueTunnelReason.LearnedFailure - : disableDecrypt || !DecryptHttps - ? MitmBypass.ResolveOpaqueReason(host, DecryptHttps, DecryptSkipHosts, userOnlyHosts: null) - : OpaqueTunnelReason.None; + var opaqueReason = OpaqueTunnelReason.None; + if (learnedBypass) + opaqueReason = OpaqueTunnelReason.LearnedFailure; + else if (disableDecrypt || !DecryptHttps) + opaqueReason = MitmBypass.ResolveOpaqueReason(host, DecryptHttps, DecryptSkipHosts, userOnlyHosts: null); if (!Capturing) { @@ -2280,12 +2287,7 @@ private static void ApplyResponseBodyCapture( if (originalBody is not null && resp.IsBodyRead) { - snap.ResponseBodyOriginalSize = originalBody.LongLength; - snap.ResponseBodyCapture = originalBody.Length > MaxBodyBytes - ? BodyCaptureState.Truncated - : BodyCaptureState.Complete; - snap.BodySize = originalBody.LongLength; - snap.ResponseBodyStreamOpen = false; + ApplyBufferedResponseBody(snap, originalBody); return; } @@ -2298,9 +2300,7 @@ private static void ApplyResponseBodyCapture( if (isSse) { - snap.ResponseBodyCapture = BodyCaptureState.Streaming; - snap.ResponseBodyStreamOpen = true; - snap.BodySize = snap.ResponseBytesSeen > 0 ? snap.ResponseBytesSeen : null; + ApplyStreamingResponseBody(snap); return; } @@ -2325,6 +2325,23 @@ private static void ApplyResponseBodyCapture( snap.BodySize ??= resp.ContentLength >= 0 ? resp.ContentLength : null; } + private static void ApplyBufferedResponseBody(SessionSnapshot snap, byte[] originalBody) + { + snap.ResponseBodyOriginalSize = originalBody.LongLength; + snap.ResponseBodyCapture = originalBody.Length > MaxBodyBytes + ? BodyCaptureState.Truncated + : BodyCaptureState.Complete; + snap.BodySize = originalBody.LongLength; + snap.ResponseBodyStreamOpen = false; + } + + private static void ApplyStreamingResponseBody(SessionSnapshot snap) + { + snap.ResponseBodyCapture = BodyCaptureState.Streaming; + snap.ResponseBodyStreamOpen = true; + snap.BodySize = snap.ResponseBytesSeen > 0 ? snap.ResponseBytesSeen : null; + } + private static string? TryHost(Request req) { try @@ -2437,9 +2454,5 @@ private static bool LooksLikeEndlessStream(RequestResponseBase message, SessionE return false; } - private static byte[]? TruncateBytes(byte[]? body) => InspectorBodyLimits.TruncateBytes(body); - - private static string TruncateText(string text) => InspectorBodyLimits.TruncateText(text); - public void Dispose() => EnsureShutdown(); } diff --git a/src/Titanium.Inspector/Services/ReplayService.cs b/src/Titanium.Inspector/Services/ReplayService.cs index fedc51d2d..9e372456b 100644 --- a/src/Titanium.Inspector/Services/ReplayService.cs +++ b/src/Titanium.Inspector/Services/ReplayService.cs @@ -3,20 +3,29 @@ namespace Titanium.Inspector.Services; +/// Optional edits applied when replaying a captured session. +public readonly record struct ReplayRequestOptions( + string? EditedUrl = null, + string? EditedMethod = null, + string? EditedBody = null, + string? EditedHeaders = null, + string? BodyFilePath = null, + bool IgnoreServerCertificateErrors = false); + /// Replays a captured session with optional header/body edits. public static class ReplayService { + public static Task ReplayAsync( + SessionSnapshot session, + CancellationToken cancellationToken = default) => + ReplayAsync(session, default, cancellationToken); + public static async Task ReplayAsync( SessionSnapshot session, - string? editedUrl = null, - string? editedMethod = null, - string? editedBody = null, - string? editedHeaders = null, - string? bodyFilePath = null, - bool ignoreServerCertificateErrors = false, + ReplayRequestOptions options, CancellationToken cancellationToken = default) { - var url = editedUrl ?? session.Url; + var url = options.EditedUrl ?? session.Url; if (string.IsNullOrWhiteSpace(url) || session.IsTunnel) { return new ReplayResult(false, 0, "Cannot replay CONNECT/tunnel or empty URL."); @@ -26,7 +35,7 @@ public static async Task ReplayAsync( { AllowAutoRedirect = false, }; - if (ignoreServerCertificateErrors) + if (options.IgnoreServerCertificateErrors) { // Opt-in only when Inspector setting "ignore server certificate errors" is enabled (MITM lab hosts). #pragma warning disable S4830 @@ -34,14 +43,14 @@ public static async Task ReplayAsync( #pragma warning restore S4830 } - var timeout = string.IsNullOrWhiteSpace(bodyFilePath) + var timeout = string.IsNullOrWhiteSpace(options.BodyFilePath) ? TimeSpan.FromSeconds(60) : TimeSpan.FromMinutes(10); using var http = new HttpClient(handler) { Timeout = timeout }; - using var request = new HttpRequestMessage(new HttpMethod(editedMethod ?? session.Method), url); + using var request = new HttpRequestMessage(new HttpMethod(options.EditedMethod ?? session.Method), url); - ApplyEditedHeaders(request, editedHeaders ?? session.RequestHeadersText ?? ""); - await using var fileStream = await AttachBodyAsync(request, session, editedBody, bodyFilePath, cancellationToken) + ApplyEditedHeaders(request, options.EditedHeaders ?? session.RequestHeadersText ?? ""); + await using var fileStream = await AttachBodyAsync(request, session, options.EditedBody, options.BodyFilePath) .ConfigureAwait(false); using var response = await http.SendAsync( @@ -108,8 +117,7 @@ private static void ApplyEditedHeaders(HttpRequestMessage request, string header HttpRequestMessage request, SessionSnapshot session, string? editedBody, - string? bodyFilePath, - CancellationToken cancellationToken) + string? bodyFilePath) { if (!string.IsNullOrWhiteSpace(bodyFilePath)) { @@ -170,7 +178,7 @@ private static void ApplyEditedHeaders(HttpRequestMessage request, string header if (ms.Length < InspectorBodyLimits.MaxBodyBytes) { var toWrite = (int)Math.Min(read, InspectorBodyLimits.MaxBodyBytes - ms.Length); - ms.Write(buffer, 0, toWrite); + await ms.WriteAsync(buffer.AsMemory(0, toWrite), cancellationToken).ConfigureAwait(false); } } diff --git a/src/Titanium.Inspector/Services/SessionStore.cs b/src/Titanium.Inspector/Services/SessionStore.cs index 4a18a1860..698f54e10 100644 --- a/src/Titanium.Inspector/Services/SessionStore.cs +++ b/src/Titanium.Inspector/Services/SessionStore.cs @@ -70,8 +70,8 @@ public void ApplyOptions(SessionStoreOptions options) ? options.DiskCacheMaxBytes : 2L * 1024 * 1024 * 1024; _options.DiskCacheMaxAgeDays = options.DiskCacheMaxAgeDays > 0 ? options.DiskCacheMaxAgeDays : 7; - // SpillBodiesToDisk cannot be turned on mid-flight without constructing a disk cache; - // turning it off stops new spills while leaving the existing cache readable. + // SpillBodiesToDisk cannot flip on mid-flight without a disk cache. + // Turning it off stops new spills while the existing cache stays readable. if (_disk is not null) { _options.SpillBodiesToDisk = options.SpillBodiesToDisk; diff --git a/src/Titanium.Inspector/Services/UpdateService.cs b/src/Titanium.Inspector/Services/UpdateService.cs index 9fbffa8d7..5eaa8dfc3 100644 --- a/src/Titanium.Inspector/Services/UpdateService.cs +++ b/src/Titanium.Inspector/Services/UpdateService.cs @@ -330,7 +330,7 @@ public static UpdateOfferKind ClassifyOfferKind( return UpdateOfferKind.Downgrade; } - // Same core: Stable over beta is a channel switch (or upgrade-ish promotion). + // Same core version: Stable over beta is a channel switch (or upgrade-ish promotion). return UpdateOfferKind.ChannelSwitch; } diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.BodyInspect.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.BodyInspect.cs index 0d1f566d4..0e2eb53a4 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.BodyInspect.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.BodyInspect.cs @@ -8,6 +8,9 @@ namespace Titanium.Inspector.ViewModels; public sealed partial class MainWindowViewModel { + private const string ContentTypeHeaderName = "Content-Type"; + private const string EmptyBodyPlaceholder = "(empty)"; + private bool _bodyPrettyMode = true; private string _bodyCaptureHint = ""; private string _hexCaptureHint = ""; @@ -167,22 +170,16 @@ private async Task SaveBodyAsync(bool isRequest) } await _store.EnsureBodiesLoadedAsync(_selected, StatusCancelToken).ConfigureAwait(false); - var bytes = isRequest ? _selected.RequestBodyBytes : _selected.ResponseBodyBytes; - var text = isRequest ? _selected.RequestBodyText : _selected.ResponseBodyText; - var capture = isRequest ? _selected.RequestBodyCapture : _selected.ResponseBodyCapture; - var original = isRequest ? _selected.RequestBodyOriginalSize : _selected.ResponseBodyOriginalSize; - - if (capture == BodyCaptureState.NotCaptured || (bytes is null or { Length: 0 } && string.IsNullOrEmpty(text))) + if (!TryGetSaveableBody(_selected, isRequest, out var bytes, out var capture, out var original)) { SetGuardStatus("Body not captured — nothing to save"); return; } - bytes ??= Encoding.UTF8.GetBytes(text ?? ""); var headers = SessionInspectors.ParseHeaderBlock( isRequest ? _selected.RequestHeadersText : _selected.ResponseHeadersText); headers.TryGetValue("Content-Disposition", out var disposition); - headers.TryGetValue("Content-Type", out var contentType); + headers.TryGetValue(ContentTypeHeaderName, out var contentType); var suggested = InspectorBodyLimits.SuggestBodyFileName( _selected.Url, disposition, contentType ?? _selected.ContentType, isRequest); @@ -197,9 +194,37 @@ private async Task SaveBodyAsync(bool isRequest) } await File.WriteAllBytesAsync(path, bytes, StatusCancelToken).ConfigureAwait(false); + await ReportBodySavedAsync(bytes, capture, original).ConfigureAwait(false); + } + + private static bool TryGetSaveableBody( + SessionSnapshot selected, + bool isRequest, + out byte[] bytes, + out BodyCaptureState capture, + out long? original) + { + var rawBytes = isRequest ? selected.RequestBodyBytes : selected.ResponseBodyBytes; + var text = isRequest ? selected.RequestBodyText : selected.ResponseBodyText; + capture = isRequest ? selected.RequestBodyCapture : selected.ResponseBodyCapture; + original = isRequest ? selected.RequestBodyOriginalSize : selected.ResponseBodyOriginalSize; + + if (capture == BodyCaptureState.NotCaptured + || (rawBytes is null or { Length: 0 } && string.IsNullOrEmpty(text))) + { + bytes = []; + return false; + } + + bytes = rawBytes ?? Encoding.UTF8.GetBytes(text ?? ""); + return true; + } + + private Task ReportBodySavedAsync(byte[] bytes, BodyCaptureState capture, long? original) + { var incomplete = capture is BodyCaptureState.Truncated or BodyCaptureState.Streaming || (original is long o && o > bytes.Length); - await MarshalToUiAsync(() => + return MarshalToUiAsync(() => { SetOutcomeStatus( incomplete @@ -207,7 +232,7 @@ await MarshalToUiAsync(() => : $"Saved body ({SessionDisplayFormat.FormatByteSize(bytes.Length)})", incomplete ? StatusSeverity.Warning : StatusSeverity.Success, toastImportant: incomplete); - }, StatusCancelToken).ConfigureAwait(false); + }, StatusCancelToken); } private async Task LoadComposerBodyFileAsync() @@ -337,31 +362,8 @@ private static string BuildHexCaptureHint(SessionSnapshot selected) private void UpdateBodyPreviewImage(SessionSnapshot selected) { - byte[]? bytes = null; - string? contentType = null; - if (InspectorBodyLimits.IsImageContentType(selected.ContentType) - || LooksLikeImageHeaders(selected.ResponseHeadersText)) - { - bytes = selected.ResponseBodyBytes; - contentType = selected.ContentType; - if (SessionInspectors.ParseHeaderBlock(selected.ResponseHeadersText) - .TryGetValue("Content-Type", out var responseType)) - { - contentType = responseType; - } - } - - if (bytes is null or { Length: 0 } - && LooksLikeImageHeaders(selected.RequestHeadersText)) - { - bytes = selected.RequestBodyBytes; - contentType = SessionInspectors.ParseHeaderBlock(selected.RequestHeadersText) - .TryGetValue("Content-Type", out var requestType) - ? requestType - : contentType; - } - - if (bytes is null or { Length: 0 } || !InspectorBodyLimits.IsImageContentType(contentType)) + if (!TryResolvePreviewImage(selected, out var bytes, out var contentType) + || !InspectorBodyLimits.IsImageContentType(contentType)) { BodyPreviewBitmap = null; return; @@ -393,10 +395,46 @@ private void UpdateBodyPreviewImage(SessionSnapshot selected) } } + private static bool TryResolvePreviewImage( + SessionSnapshot selected, out byte[] bytes, out string? contentType) + { + bytes = []; + contentType = null; + if (InspectorBodyLimits.IsImageContentType(selected.ContentType) + || LooksLikeImageHeaders(selected.ResponseHeadersText)) + { + if (selected.ResponseBodyBytes is { Length: > 0 } responseBytes) + { + bytes = responseBytes; + contentType = selected.ContentType; + if (SessionInspectors.ParseHeaderBlock(selected.ResponseHeadersText) + .TryGetValue(ContentTypeHeaderName, out var responseType)) + { + contentType = responseType; + } + + return true; + } + } + + if (LooksLikeImageHeaders(selected.RequestHeadersText) + && selected.RequestBodyBytes is { Length: > 0 } requestBytes) + { + bytes = requestBytes; + contentType = SessionInspectors.ParseHeaderBlock(selected.RequestHeadersText) + .TryGetValue(ContentTypeHeaderName, out var requestType) + ? requestType + : contentType; + return true; + } + + return false; + } + private static bool LooksLikeImageHeaders(string? headersText) { var headers = SessionInspectors.ParseHeaderBlock(headersText); - return headers.TryGetValue("Content-Type", out var ct) + return headers.TryGetValue(ContentTypeHeaderName, out var ct) && InspectorBodyLimits.IsImageContentType(ct); } @@ -406,17 +444,7 @@ private static string BuildSelectedBodyTextCore(SessionSnapshot selected, bool p || LooksLikeImageHeaders(selected.ResponseHeadersText) || LooksLikeImageHeaders(selected.RequestHeadersText)) { - var sb = new StringBuilder(); - sb.AppendLine("=== Request ==="); - sb.AppendLine(selected.RequestBodyBytes is { Length: > 0 } - ? $"(image · {SessionDisplayFormat.FormatByteSize(selected.RequestBodyBytes.Length)})" - : "(empty)"); - sb.AppendLine(); - sb.AppendLine("=== Response ==="); - sb.Append(selected.ResponseBodyBytes is { Length: > 0 } - ? $"(image · {SessionDisplayFormat.FormatByteSize(selected.ResponseBodyBytes.Length)} — see preview above)" - : "(empty)"); - return sb.ToString(); + return FormatImageBodyInspectText(selected); } var raw = SessionInspectors.FormatLabeledBody( @@ -432,16 +460,36 @@ private static string BuildSelectedBodyTextCore(SessionSnapshot selected, bool p return raw; } + return TryFormatPrettyBodyText(selected, raw); + } + + private static string FormatImageBodyInspectText(SessionSnapshot selected) + { + var sb = new StringBuilder(); + sb.AppendLine("=== Request ==="); + sb.AppendLine(selected.RequestBodyBytes is { Length: > 0 } + ? $"(image · {SessionDisplayFormat.FormatByteSize(selected.RequestBodyBytes.Length)})" + : EmptyBodyPlaceholder); + sb.AppendLine(); + sb.AppendLine("=== Response ==="); + sb.Append(selected.ResponseBodyBytes is { Length: > 0 } + ? $"(image · {SessionDisplayFormat.FormatByteSize(selected.ResponseBodyBytes.Length)} — see preview above)" + : EmptyBodyPlaceholder); + return sb.ToString(); + } + + private static string TryFormatPrettyBodyText(SessionSnapshot selected, string raw) + { var reqCt = selected.ContentType; if (SessionInspectors.ParseHeaderBlock(selected.RequestHeadersText) - .TryGetValue("Content-Type", out var requestCt)) + .TryGetValue(ContentTypeHeaderName, out var requestCt)) { reqCt = requestCt; } string? respCt = selected.ContentType; if (SessionInspectors.ParseHeaderBlock(selected.ResponseHeadersText) - .TryGetValue("Content-Type", out var responseCt)) + .TryGetValue(ContentTypeHeaderName, out var responseCt)) { respCt = responseCt; } @@ -450,23 +498,15 @@ private static string BuildSelectedBodyTextCore(SessionSnapshot selected, bool p var respPretty = InspectorBodyLimits.TryPrettyPrint(selected.ResponseBodyText, respCt); if (reqPretty is null && respPretty is null) { - if ((selected.RequestBodyCapture is BodyCaptureState.Truncated - || selected.ResponseBodyCapture is BodyCaptureState.Truncated) - && (InspectorBodyLimits.IsPrettyPrintableContentType(reqCt) - || InspectorBodyLimits.IsPrettyPrintableContentType(respCt))) - { - return raw; // caller may set banner for pretty failure - } - return raw; } var prettySb = new StringBuilder(); prettySb.AppendLine("=== Request ==="); - prettySb.AppendLine(reqPretty ?? selected.RequestBodyText ?? "(empty)"); + prettySb.AppendLine(reqPretty ?? selected.RequestBodyText ?? EmptyBodyPlaceholder); prettySb.AppendLine(); prettySb.AppendLine("=== Response ==="); - prettySb.Append(respPretty ?? selected.ResponseBodyText ?? "(empty)"); + prettySb.Append(respPretty ?? selected.ResponseBodyText ?? EmptyBodyPlaceholder); return prettySb.ToString(); } } diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs index 0b9d3b773..2984ef1b4 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs @@ -577,10 +577,6 @@ private static string FormatUntrustRemovedStatus() return "Root CA removed from the user certificate store; Decrypt HTTPS is off until you install the CA again"; return "Root CA removed from current user store; Decrypt HTTPS is off until you install the CA again"; } - private static string FormatRotateCaTrustedStatus(bool changed) => - changed - ? "Root CA cleared and trusted — ready to enable Decrypt HTTPS" - : "Root CA trusted — ready to enable Decrypt HTTPS"; private async Task UntrustCaAsync() { if (!_interception.IsRunning) @@ -733,59 +729,7 @@ private async Task RotateCaAsync() { rotateConfirmed = true; InspectorUxTrace.Event("RotateCa.ConfirmRotate", "accepted=true"); - - // Mark Busy before any PersistSettings / decrypt-off work so waiters that key on - // RotateRootCaCalls (incremented at confirm) cannot observe a non-busy window while - // _trustCommandBusy is still true (macOS stress: Untrust then Rejected → timeout). - SetStatus("Preparing clear and reinstall…", StatusSeverity.Busy); - - // New root is untrusted until Install — MITM must not stay on across rotate. - // Skip ForceDecryptHttpsOffAsync/Snap (dispatcher bounce during CryptUI). Core + - // finally SetDecryptHttpsCore keep the glyph in sync without flipping the field. - Interlocked.Increment(ref _decryptEnableGeneration); - Interlocked.Increment(ref _decryptTrustVerifyGeneration); - _decryptHttpsBusy = false; - if (_decryptHttps) - SetDecryptHttpsCore(false); - - // Await TrustBg only before Remove — never between Mint and CryptUI. - await AwaitPriorFirefoxTrustBackgroundAsync(); - - SetStatus("Clearing root CA…", StatusSeverity.Busy); - await Task.Yield(); - var oldThumb = _interception.RootCertificate?.Thumbprint; - await RemoveOsRootInteractiveAsync(machineStore: false); - - SetStatus("Recreating root CA…", StatusSeverity.Busy); - bool ok; - using (InspectorUxTrace.Scope("MintNewRootCertificateCore")) - { - ok = await RunOffUiAsync( - () => _interception.MintNewRootCertificateCore(clearFirefox: false), - StatusCancelToken); - } - - if (!ok) - { - rotateFailure = "Clear and reinstall root CA failed — see logs"; - } - else - { - var newThumb = _interception.RootCertificate?.Thumbprint; - var changed = !string.IsNullOrEmpty(newThumb) && - !string.Equals(oldThumb, newThumb, StringComparison.OrdinalIgnoreCase); - - // User already confirmed Clear+Install — skip a second ConfirmInstall and go - // straight to OS CryptUI/Keychain (avoids “two install dialogs then stuck”). - InspectorUxTrace.Event("RotateCa.ConfirmInstall", "accepted=true skippedDuplicate=true"); - SetBusyTrustingRootCa(); - // Skip initial Root Find — we just minted; opening Crypt32 before CryptUI stalls. - var trusted = await EnsureRootCaTrustedAsync(promptIfNeeded: true, skipInitialRefresh: true); - InspectorUxTrace.Event("RotateCa.InstallResult", $"trusted={trusted} changed={changed}"); - rotateTrusted = trusted; - if (!trusted) - rotateFailure = FormatOsTrustFailureStatus(_interception.LastOsTrustResult); - } + (rotateTrusted, rotateFailure) = await ExecuteRotateCaConfirmedAsync(); } } finally @@ -816,8 +760,60 @@ private async Task RotateCaAsync() SetOutcomeStatus(rotateFailure, StatusSeverity.Error, toastImportant: true); } } - private static string FormatRotateCaDeferredTrustStatus(bool changed) => - changed ? "Root CA cleared — Install root CA (or enable Decrypt HTTPS) to trust the new certificate" : "Root CA recreate completed — Install root CA to trust"; + + private async Task<(bool? Trusted, string? Failure)> ExecuteRotateCaConfirmedAsync() + { + // Mark Busy before any PersistSettings / decrypt-off work so waiters that key on + // RotateRootCaCalls (incremented at confirm) cannot observe a non-busy window while + // _trustCommandBusy is still true (macOS stress: Untrust then Rejected → timeout). + SetStatus("Preparing clear and reinstall…", StatusSeverity.Busy); + + // New root is untrusted until Install — MITM must not stay on across rotate. + // Skip ForceDecryptHttpsOffAsync/Snap (dispatcher bounce during CryptUI). Core + + // finally SetDecryptHttpsCore keep the glyph in sync without flipping the field. + Interlocked.Increment(ref _decryptEnableGeneration); + Interlocked.Increment(ref _decryptTrustVerifyGeneration); + _decryptHttpsBusy = false; + if (_decryptHttps) + SetDecryptHttpsCore(false); + + // Await TrustBg only before Remove — never between Mint and CryptUI. + await AwaitPriorFirefoxTrustBackgroundAsync(); + + SetStatus("Clearing root CA…", StatusSeverity.Busy); + await Task.Yield(); + var oldThumb = _interception.RootCertificate?.Thumbprint; + await RemoveOsRootInteractiveAsync(machineStore: false); + + SetStatus("Recreating root CA…", StatusSeverity.Busy); + bool ok; + using (InspectorUxTrace.Scope("MintNewRootCertificateCore")) + { + ok = await RunOffUiAsync( + () => _interception.MintNewRootCertificateCore(clearFirefox: false), + StatusCancelToken); + } + + if (!ok) + return (null, "Clear and reinstall root CA failed — see logs"); + + var newThumb = _interception.RootCertificate?.Thumbprint; + var changed = !string.IsNullOrEmpty(newThumb) && + !string.Equals(oldThumb, newThumb, StringComparison.OrdinalIgnoreCase); + + // User already confirmed Clear+Install — skip a second ConfirmInstall and go + // straight to OS CryptUI/Keychain (avoids “two install dialogs then stuck”). + InspectorUxTrace.Event("RotateCa.ConfirmInstall", "accepted=true skippedDuplicate=true"); + SetBusyTrustingRootCa(); + // Skip initial Root Find — we just minted; opening Crypt32 before CryptUI stalls. + var trusted = await EnsureRootCaTrustedAsync(promptIfNeeded: true, skipInitialRefresh: true); + InspectorUxTrace.Event("RotateCa.InstallResult", $"trusted={trusted} changed={changed}"); + if (!trusted) + return (false, FormatOsTrustFailureStatus(_interception.LastOsTrustResult)); + + return (true, null); + } + private async Task ExportCaAsync() { if (_interception.RootCertificate is null) @@ -993,9 +989,6 @@ private async Task ForceDecryptHttpsOffAsync(bool cancelInFlightEnable = true) /// private Task RejectDecryptHttpsEnableAsync() => ForceDecryptHttpsOffAsync(cancelInFlightEnable: false); - private void NotifyDecryptHttpsUnchanged() => - _ = ForceDecryptHttpsOffAsync(cancelInFlightEnable: false); - private async Task TryStartProxyForDecryptAsync() { if (_interception.IsRunning) @@ -1085,54 +1078,50 @@ private async Task TryCompleteMacSslTrustForDecryptAsync() // Linux: never fabricate Mac Keychain Always Trust - use NSS/certutil recovery instead. if (OperatingSystem.IsLinux()) - { - var linuxIncomplete = _interception.LastOsTrustResult - ?? CertificateOsTrustResult.Fail( - CertificateOsTrustKind.CertutilMissing, - "Root CA is not trusted yet. Install NSS certutil tools, or Export CA and trust it for your browser."); - if (linuxIncomplete.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm) - { - linuxIncomplete = CertificateOsTrustResult.Fail( - CertificateOsTrustKind.CertutilMissing, - string.IsNullOrWhiteSpace(linuxIncomplete.Message) - ? "Root CA is not trusted yet. Install NSS certutil tools, or Export CA and trust it for your browser." - : linuxIncomplete.Message); - } + return await TryCompleteLinuxSslTrustForDecryptAsync(); - InspectorUxTrace.Event("Decrypt.LinuxTrustIncomplete", $"kind={linuxIncomplete.Kind}"); - if (await ResolveTerminalTrustFailureAsync(linuxIncomplete)) - { - trusted = await RunOffUiAsync( - () => _interception.VerifyOsUserSslTrust(), - StatusCancelToken); - if (trusted) - { - _interception.ScheduleFirefoxEnterpriseRootsBestEffort(); - return true; - } - } + return await TryCompleteMacKeychainTrustForDecryptAsync(); + } - SetOutcomeStatus( - OsTrustUxCopy.FormatStatus(linuxIncomplete), - StatusSeverity.Error, - toastImportant: true); - await RejectDecryptHttpsEnableAsync(); - return false; + private async Task TryCompleteLinuxSslTrustForDecryptAsync() + { + var linuxIncomplete = _interception.LastOsTrustResult + ?? CertificateOsTrustResult.Fail( + CertificateOsTrustKind.CertutilMissing, + "Root CA is not trusted yet. Install NSS certutil tools, or Export CA and trust it for your browser."); + if (linuxIncomplete.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm) + { + linuxIncomplete = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.CertutilMissing, + string.IsNullOrWhiteSpace(linuxIncomplete.Message) + ? "Root CA is not trusted yet. Install NSS certutil tools, or Export CA and trust it for your browser." + : linuxIncomplete.Message); + } + + InspectorUxTrace.Event("Decrypt.LinuxTrustIncomplete", $"kind={linuxIncomplete.Kind}"); + if (await ResolveTerminalTrustFailureAsync(linuxIncomplete) + && await VerifyOsUserSslTrustAndScheduleFirefoxAsync()) + { + return true; } + SetOutcomeStatus( + OsTrustUxCopy.FormatStatus(linuxIncomplete), + StatusSeverity.Error, + toastImportant: true); + await RejectDecryptHttpsEnableAsync(); + return false; + } + + private async Task TryCompleteMacKeychainTrustForDecryptAsync() + { var incomplete = CertificateOsTrustResult.Fail( CertificateOsTrustKind.MacNeedsManualTrustConfirm, "Root CA needs Always Trust in Keychain Access before Decrypt HTTPS"); - if (await ResolveTerminalTrustFailureAsync(incomplete)) + if (await ResolveTerminalTrustFailureAsync(incomplete) + && await VerifyOsUserSslTrustAndScheduleFirefoxAsync()) { - trusted = await RunOffUiAsync( - () => _interception.VerifyOsUserSslTrust(), - StatusCancelToken); - if (trusted) - { - _interception.ScheduleFirefoxEnterpriseRootsBestEffort(); - return true; - } + return true; } SetOutcomeStatus( @@ -1142,6 +1131,19 @@ private async Task TryCompleteMacSslTrustForDecryptAsync() await RejectDecryptHttpsEnableAsync(); return false; } + + private async Task VerifyOsUserSslTrustAndScheduleFirefoxAsync() + { + var trusted = await RunOffUiAsync( + () => _interception.VerifyOsUserSslTrust(), + StatusCancelToken); + if (!trusted) + return false; + + _interception.ScheduleFirefoxEnterpriseRootsBestEffort(); + return true; + } + /// /// Terminal trust-failure modal: retry / Keychain / Export. Returns true when trust is established. /// diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Updates.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Updates.cs index 6b845c7f3..547b4156e 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Updates.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Updates.cs @@ -100,7 +100,8 @@ private async Task ReplaySelectedAsync() await _store.EnsureBodiesLoadedAsync(SelectedSession, _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); var result = await ReplayService.ReplayAsync( SelectedSession, - ignoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors, + new ReplayRequestOptions( + IgnoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors), cancellationToken: _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); await MarshalToUiAsync(() => { @@ -144,12 +145,13 @@ private async Task SendComposerAsync() { result = await ReplayService.ReplayAsync( template, - editedUrl: ComposerUrl, - editedMethod: ComposerMethod, - editedBody: HasComposerBodyFile ? null : ComposerBody, - editedHeaders: ComposerHeaders, - bodyFilePath: ComposerBodyFilePath, - ignoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors, + new ReplayRequestOptions( + EditedUrl: ComposerUrl, + EditedMethod: ComposerMethod, + EditedBody: HasComposerBodyFile ? null : ComposerBody, + EditedHeaders: ComposerHeaders, + BodyFilePath: ComposerBodyFilePath, + IgnoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors), cancellationToken: _statusRevertCts?.Token ?? CancellationToken.None); } catch (Exception ex) diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index ec4c40aef..252f710cf 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -940,7 +940,6 @@ private async Task TryToggleSystemProxyAsync() /// ShowDialog as a silent OperationCanceledException (no toast, no Confirm* ux-trace) /// — the "Nth Clear+Install did nothing" failure mode. /// - private static Task AwaitDialogAsync(Task task) => task; private static Task AwaitDialogAsync(Task task) => task; @@ -1683,60 +1682,73 @@ private async Task ApplySystemProxyAsync(bool enable) return; } - await MarshalToUiAsync(() => - { - if (generation != Volatile.Read(ref _systemProxyApplyGeneration)) - { - return; - } + await MarshalToUiAsync( + () => ApplySystemProxyUiResult(enable, ok, generation), + StatusCancelToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // status revert / shutdown + } + } - if (ok) - { - // Stop / uncheck may have cleared the UI intent while WinINET still reported success. - if (enable && (!_systemProxy || !_interception.IsRunning)) - { - return; - } - - if (enable) - { - SetOutcomeStatus( - SystemProxyEnabledStatusMessage(), - StatusSeverity.Success, - toastImportant: OperatingSystem.IsWindows()); - } - else - { - SetOutcomeStatus(SystemProxyRestoredStatus, StatusSeverity.Success); - } + private void ApplySystemProxyUiResult(bool enable, bool ok, int generation) + { + if (generation != Volatile.Read(ref _systemProxyApplyGeneration)) + { + return; + } - return; - } + if (ok) + { + // Stop / uncheck may have cleared the UI intent while WinINET still reported success. + if (enable && (!_systemProxy || !_interception.IsRunning)) + { + return; + } - // Cancelled by stillWanted (superseded) — do not treat as user-visible failure. - if (string.IsNullOrEmpty(_interception.LastSystemProxyError)) - { - return; - } + if (enable) + { + SetOutcomeStatus( + SystemProxyEnabledStatusMessage(), + StatusSeverity.Success, + toastImportant: OperatingSystem.IsWindows()); + } + else + { + SetOutcomeStatus(SystemProxyRestoredStatus, StatusSeverity.Success); + } - // Revert optimistic checkbox to match OS state. - SetSystemProxyCore(!enable); - _ = SnapSystemProxyUiAsync(); - var detail = _interception.LastSystemProxyError; - var text = enable - ? (string.IsNullOrWhiteSpace(detail) - ? "Failed to enable system proxy (permissions, cancelled admin prompt, or unsupported desktop environment)" - : "Failed to enable system proxy: " + Truncate(detail, 180)) - : (string.IsNullOrWhiteSpace(detail) - ? "Failed to restore system proxy settings" - : "Failed to restore system proxy: " + Truncate(detail, 180)); - SetOutcomeStatus(text, StatusSeverity.Error, toastImportant: true); - }, StatusCancelToken).ConfigureAwait(false); + return; } - catch (OperationCanceledException) + + // Cancelled by stillWanted (superseded) — do not treat as user-visible failure. + if (string.IsNullOrEmpty(_interception.LastSystemProxyError)) { - // status revert / shutdown + return; + } + + // Revert optimistic checkbox to match OS state. + SetSystemProxyCore(!enable); + _ = SnapSystemProxyUiAsync(); + SetOutcomeStatus( + FormatSystemProxyFailureStatus(enable, _interception.LastSystemProxyError), + StatusSeverity.Error, + toastImportant: true); + } + + private static string FormatSystemProxyFailureStatus(bool enable, string? detail) + { + if (enable) + { + return string.IsNullOrWhiteSpace(detail) + ? "Failed to enable system proxy (permissions, cancelled admin prompt, or unsupported desktop environment)" + : "Failed to enable system proxy: " + Truncate(detail, 180); } + + return string.IsNullOrWhiteSpace(detail) + ? "Failed to restore system proxy settings" + : "Failed to restore system proxy: " + Truncate(detail, 180); } /// When true, localhost uses the system proxy (WinINET <-loopback> / Unix NO_PROXY parity). @@ -1817,7 +1829,7 @@ await MarshalToUiAsync(() => } } - public string ProxyLocalhostTip => OsTrustUxCopy.ProxyLocalhostTip(); + public static string ProxyLocalhostTip => OsTrustUxCopy.ProxyLocalhostTip(); public bool AutoStartCapture { @@ -2727,33 +2739,11 @@ private string BuildSelectedBodyText(SessionSnapshot selected) return AppendTranscodePrefix(selected, _cachedPrettyBody); } - var body = BuildSelectedBodyTextCore(selected, _bodyPrettyMode && SelectedInspectTabIndex == 1); - if (_bodyPrettyMode && SelectedInspectTabIndex == 1) - { - var reqCt = SessionInspectors.ParseHeaderBlock(selected.RequestHeadersText) - .TryGetValue("Content-Type", out var rct) ? rct : null; - var respCt = selected.ContentType - ?? (SessionInspectors.ParseHeaderBlock(selected.ResponseHeadersText) - .TryGetValue("Content-Type", out var sct) ? sct : null); - if ((InspectorBodyLimits.IsPrettyPrintableContentType(reqCt) - || InspectorBodyLimits.IsPrettyPrintableContentType(respCt)) - && InspectorBodyLimits.TryPrettyPrint(selected.RequestBodyText, reqCt) is null - && InspectorBodyLimits.TryPrettyPrint(selected.ResponseBodyText, respCt) is null - && (selected.RequestBodyCapture is BodyCaptureState.Truncated - || selected.ResponseBodyCapture is BodyCaptureState.Truncated - || !string.IsNullOrWhiteSpace(selected.RequestBodyText) - || !string.IsNullOrWhiteSpace(selected.ResponseBodyText))) - { - if (string.IsNullOrEmpty(BodyCaptureHint)) - { - BodyCaptureHint = "Cannot pretty-print (body truncated or invalid)"; - } - else if (!BodyCaptureHint.Contains("pretty-print", StringComparison.OrdinalIgnoreCase)) - { - BodyCaptureHint += " · Cannot pretty-print (body truncated or invalid)"; - } - } - + var prettyInspect = _bodyPrettyMode && SelectedInspectTabIndex == 1; + var body = BuildSelectedBodyTextCore(selected, prettyInspect); + if (prettyInspect) + { + MaybeSetPrettyPrintFailureHint(selected); _cachedPrettySessionId = selected.Id; _cachedPrettyBody = body; } @@ -2761,6 +2751,35 @@ private string BuildSelectedBodyText(SessionSnapshot selected) return AppendTranscodePrefix(selected, body); } + private void MaybeSetPrettyPrintFailureHint(SessionSnapshot selected) + { + var reqCt = SessionInspectors.ParseHeaderBlock(selected.RequestHeadersText) + .TryGetValue("Content-Type", out var rct) ? rct : null; + var respCt = selected.ContentType + ?? (SessionInspectors.ParseHeaderBlock(selected.ResponseHeadersText) + .TryGetValue("Content-Type", out var sct) ? sct : null); + if (!(InspectorBodyLimits.IsPrettyPrintableContentType(reqCt) + || InspectorBodyLimits.IsPrettyPrintableContentType(respCt)) + || InspectorBodyLimits.TryPrettyPrint(selected.RequestBodyText, reqCt) is not null + || InspectorBodyLimits.TryPrettyPrint(selected.ResponseBodyText, respCt) is not null + || !(selected.RequestBodyCapture is BodyCaptureState.Truncated + || selected.ResponseBodyCapture is BodyCaptureState.Truncated + || !string.IsNullOrWhiteSpace(selected.RequestBodyText) + || !string.IsNullOrWhiteSpace(selected.ResponseBodyText))) + { + return; + } + + if (string.IsNullOrEmpty(BodyCaptureHint)) + { + BodyCaptureHint = "Cannot pretty-print (body truncated or invalid)"; + } + else if (!BodyCaptureHint.Contains("pretty-print", StringComparison.OrdinalIgnoreCase)) + { + BodyCaptureHint += " · Cannot pretty-print (body truncated or invalid)"; + } + } + private static string AppendTranscodePrefix(SessionSnapshot selected, string body) { if (!selected.IsTranscoded) diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml b/src/Titanium.Inspector/Views/MainWindow.axaml index 2b5b916ad..72f1fbc9d 100644 --- a/src/Titanium.Inspector/Views/MainWindow.axaml +++ b/src/Titanium.Inspector/Views/MainWindow.axaml @@ -50,7 +50,7 @@ GetLinuxFirefoxPoliciesJsonPaths(string home) } internal static void EnsureEnterpriseRootsUserPref(string profileDirectory) => - EnsureEnterpriseRootsPrefFile(Path.Combine(profileDirectory, "user.js")); + EnsureEnterpriseRootsPrefFile(Path.Combine(profileDirectory, UserJsFileName)); /// /// Split pref-file text into logical lines without the CRLF pitfall: @@ -548,7 +550,7 @@ internal static void EnsureEnterpriseRootsPrefFile(string prefFile) internal static bool VerifyEnterpriseRootsUserPref(string profileDirectory) { - var userJs = Path.Combine(profileDirectory, "user.js"); + var userJs = Path.Combine(profileDirectory, UserJsFileName); if (!File.Exists(userJs)) return false; foreach (var line in File.ReadLines(userJs)) { @@ -560,9 +562,14 @@ internal static bool VerifyEnterpriseRootsUserPref(string profileDirectory) return false; } + // Invoked via reflection from unit tests (BootstrapAndFirefoxHelperCoverageTests). + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S1144:Unused private types or members should be removed", + Justification = "Called via reflection from BootstrapAndFirefoxHelperCoverageTests.")] private static bool ClearEnterpriseRootsUserPref(string profileDirectory) { - var cleared = ClearEnterpriseRootsPrefFile(Path.Combine(profileDirectory, "user.js")); + var cleared = ClearEnterpriseRootsPrefFile(Path.Combine(profileDirectory, UserJsFileName)); if (!IsFirefoxProcessRunning()) cleared = ClearEnterpriseRootsPrefFile(Path.Combine(profileDirectory, "prefs.js")) || cleared; return cleared; diff --git a/src/Titanium.Web.Proxy/EventArguments/SessionEventArgs.cs b/src/Titanium.Web.Proxy/EventArguments/SessionEventArgs.cs index 11b2b27af..8c9a23d23 100644 --- a/src/Titanium.Web.Proxy/EventArguments/SessionEventArgs.cs +++ b/src/Titanium.Web.Proxy/EventArguments/SessionEventArgs.cs @@ -377,56 +377,57 @@ private async Task ReadResponseBodyAsync(CancellationToken cancellationToken) "The stream is an established WebSocket tunnel; subscribe to OnDataReceived instead."); var response = HttpClient.Response; - if (!response.HasBody) return; + if (!response.HasBody || response.IsBodyRead) + return; - // If not already read (not cached yet) - if (!response.IsBodyRead) + // Synthetic Ok/Respond may already have Body bytes without going through the wire. + if (response.BodyAvailable) { - // Synthetic Ok/Respond may already have Body bytes without going through the wire. - if (response.BodyAvailable) - { - response.IsBodyRead = true; - response.IsBodyReceived = true; - return; - } + MarkResponseBodyRead(response); + return; + } - if (response.IsBodyReceived) throw new InvalidOperationException("Response body was already received."); + if (response.IsBodyReceived) throw new InvalidOperationException("Response body was already received."); - if (response.HttpVersion == HttpHeader.Version20) - { - // do not send to the remote endpoint - response.Http2IgnoreBodyFrames = true; + if (response.HttpVersion == HttpHeader.Version20) + await ReadHttp2ResponseBodyAsync(response).ConfigureAwait(false); + else + await ReadHttp1ResponseBodyAsync(response, cancellationToken).ConfigureAwait(false); + } + + private static void MarkResponseBodyRead(Response response) + { + response.IsBodyRead = true; + response.IsBodyReceived = true; + } - response.Http2BodyData = new MemoryStream(); + private static async Task ReadHttp2ResponseBodyAsync(Response response) + { + // do not send to the remote endpoint + response.Http2IgnoreBodyFrames = true; - var tcs = new TaskCompletionSource(); - response.ReadHttp2BodyTaskCompletionSource = tcs; + response.Http2BodyData = new MemoryStream(); - // signal to HTTP/2 copy frame method to continue - response.ReadHttp2BeforeHandlerTaskCompletionSource!.SetResult(true); + var tcs = new TaskCompletionSource(); + response.ReadHttp2BodyTaskCompletionSource = tcs; - await tcs.Task; + // signal to HTTP/2 copy frame method to continue + response.ReadHttp2BeforeHandlerTaskCompletionSource!.SetResult(true); - // Now set the flag to true - // So that next time we can deliver body from cache - response.IsBodyRead = true; - response.IsBodyReceived = true; - } - else - { - var body = await ReadBodyAsync(false, cancellationToken); - if (!response.BodyAvailable) - { - response.Body = body; - response.BodyIsWireEncoded = false; // Uncompress transformation - } + await tcs.Task.ConfigureAwait(false); + MarkResponseBodyRead(response); + } - // Now set the flag to true - // So that next time we can deliver body from cache - response.IsBodyRead = true; - response.IsBodyReceived = true; - } + private async Task ReadHttp1ResponseBodyAsync(Response response, CancellationToken cancellationToken) + { + var body = await ReadBodyAsync(false, cancellationToken).ConfigureAwait(false); + if (!response.BodyAvailable) + { + response.Body = body; + response.BodyIsWireEncoded = false; // Uncompress transformation } + + MarkResponseBodyRead(response); } private async Task ReadBodyAsync(bool isRequest, CancellationToken cancellationToken) diff --git a/src/Titanium.Web.Proxy/Handlers/Http2NegotiationHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http2NegotiationHandler.cs index 2603a290b..074236a0e 100644 --- a/src/Titanium.Web.Proxy/Handlers/Http2NegotiationHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/Http2NegotiationHandler.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Security; using System.Net.Sockets; @@ -126,6 +127,9 @@ await CoalesceHttp2CapabilityProbeAsync(capabilityCacheKey, sessionArgs, remoteH /// receives the discovery connection for session adoption; waiters get /// and must open their own connection. /// + // Parameter count mirrors GetServerConnection; packing into a heap type would allocate per probe. + [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", + Justification = "Hot-path probe: keep stack args; do not allocate a parameter object per CONNECT.")] private async Task<(bool Supported, bool LearnableFailure, TcpServerConnection? AdoptedProbe)> CoalesceHttp2CapabilityProbeAsync( string capabilityCacheKey, SessionEventArgsBase sessionArgs, string remoteHostName, int remotePort, @@ -167,6 +171,8 @@ await CoalesceHttp2CapabilityProbeAsync(capabilityCacheKey, sessionArgs, remoteH } } + [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", + Justification = "Hot-path probe: keep stack args; do not allocate a parameter object per CONNECT.")] private async Task<(bool Supported, bool LearnableFailure, TcpServerConnection? AdoptedProbe)> ProbeHttp2CapabilityOnceAsync( string capabilityCacheKey, SessionEventArgsBase sessionArgs, string remoteHostName, int remotePort, diff --git a/src/Titanium.Web.Proxy/Handlers/ResponseHandler.cs b/src/Titanium.Web.Proxy/Handlers/ResponseHandler.cs index 7bd5ec3c7..a347b1731 100644 --- a/src/Titanium.Web.Proxy/Handlers/ResponseHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/ResponseHandler.cs @@ -364,7 +364,7 @@ internal bool TrySeamlessDecryptBypassRetry(SessionEventArgs args) if (!Network.Tcp.DecryptFailureLearning.IsSafeMetaRefreshUrl(url)) return false; - var html = Network.Tcp.DecryptFailureLearning.BuildMetaRefreshHtml(url!); + var html = Network.Tcp.DecryptFailureLearning.BuildMetaRefreshHtml(url); // Connection: close is H1-only; H2 strips hop-by-hop headers and we cancel/dispose the client // connection after the response so the forged MITM multiplex is not reused. HttpHeader[] headers = request.HttpVersion >= HttpHeader.Version20 diff --git a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpClientConnection.cs b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpClientConnection.cs index 05b3d2cc0..ed6132f03 100644 --- a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpClientConnection.cs +++ b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpClientConnection.cs @@ -183,12 +183,16 @@ internal virtual void CloseGracefully() tcpClientSocket.Close(); } +#pragma warning disable S108 // Empty catches: dispose races / peer reset while closing client socket catch (ObjectDisposedException) { + // Socket already disposed by NetworkStream(ownsSocket:true) or concurrent close. } catch (SocketException) { + // Peer reset / already half-closed during Close(). } +#pragma warning restore S108 catch (Exception ex) { Logging.ProxyDiagnostics.ReportBenign(ProxyServer.Logger, diff --git a/tests/Titanium.Cli.Tests/ConfigReloadGateTests.cs b/tests/Titanium.Cli.Tests/ConfigReloadGateTests.cs index c2f98075e..9cb04a63a 100644 --- a/tests/Titanium.Cli.Tests/ConfigReloadGateTests.cs +++ b/tests/Titanium.Cli.Tests/ConfigReloadGateTests.cs @@ -196,9 +196,14 @@ public void TryParsePid_RejectsInvalidValues() { var flags = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static; var parse = typeof(ReloadCommand).GetMethod("TryParsePid", flags)!; - Assert.IsNull(parse.Invoke(null, [new[] { "reload", "--pid", "0" }])); - Assert.IsNull(parse.Invoke(null, [new[] { "reload", "--pid" }])); - Assert.IsNull(parse.Invoke(null, [new[] { "reload", "--pid", "abc" }])); - Assert.AreEqual(42, (int)parse.Invoke(null, [new[] { "reload", "--pid", "42" }])!); + Assert.IsNull(parse.Invoke(null, [PidArgZero])); + Assert.IsNull(parse.Invoke(null, [PidArgMissingValue])); + Assert.IsNull(parse.Invoke(null, [PidArgNonNumeric])); + Assert.AreEqual(42, (int)parse.Invoke(null, [PidArgValid])!); } + + private static readonly string[] PidArgZero = ["reload", "--pid", "0"]; + private static readonly string[] PidArgMissingValue = ["reload", "--pid"]; + private static readonly string[] PidArgNonNumeric = ["reload", "--pid", "abc"]; + private static readonly string[] PidArgValid = ["reload", "--pid", "42"]; } diff --git a/tests/Titanium.E2E.Tests/Harness/CliProcessHarness.cs b/tests/Titanium.E2E.Tests/Harness/CliProcessHarness.cs index fe52fe61c..31bdb2d91 100644 --- a/tests/Titanium.E2E.Tests/Harness/CliProcessHarness.cs +++ b/tests/Titanium.E2E.Tests/Harness/CliProcessHarness.cs @@ -274,7 +274,7 @@ public void EnsurePlusDllBesideCli(bool copy) /// /// Ensure DOTNET_ROOT is set so a framework-dependent apphost can start under sudo. /// - private static void EnsureDotnetRootForApphost(IDictionary env) + private static void EnsureDotnetRootForApphost(Dictionary env) { if (env.TryGetValue("DOTNET_ROOT", out var existing) && !string.IsNullOrWhiteSpace(existing)) return; diff --git a/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs b/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs index 569141714..ae7abbdc0 100644 --- a/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs +++ b/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs @@ -432,17 +432,4 @@ public void DiskCache_ClearAll_OnEmptyDirectory_IsNoOp() try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } catch { } } } -[TestMethod] - public void FormatRotateCaStatusHelpers_CoverChangedAndTrustedBranches() - { - var trusted = typeof(ViewModels.MainWindowViewModel).GetMethod("FormatRotateCaTrustedStatus", - System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!; - var deferred = typeof(ViewModels.MainWindowViewModel).GetMethod("FormatRotateCaDeferredTrustStatus", - System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!; - - Assert.IsTrue(((string)trusted.Invoke(null, [true])!).Contains("cleared and trusted")); - Assert.IsTrue(((string)trusted.Invoke(null, [false])!).Contains("Root CA trusted")); - Assert.IsTrue(((string)deferred.Invoke(null, [true])!).Contains("Install root CA")); - Assert.IsTrue(((string)deferred.Invoke(null, [false])!).Contains("recreate completed")); - } } \ No newline at end of file diff --git a/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs b/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs index f876dca65..95c2d0092 100644 --- a/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs +++ b/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs @@ -208,10 +208,6 @@ public async Task RemainingCommands_CopyDiffComposerAutoResponderMapRemoteAndTog _ = trustType.GetMethod("FormatOsTrustFailureStatus", flags)!.Invoke(null, [null]); _ = trustType.GetMethod("FormatUntrustStillPresentStatus", flags)!.Invoke(null, null); _ = trustType.GetMethod("FormatUntrustRemovedStatus", flags)!.Invoke(null, null); - _ = trustType.GetMethod("FormatRotateCaTrustedStatus", flags)!.Invoke(null, [true]); - _ = trustType.GetMethod("FormatRotateCaTrustedStatus", flags)!.Invoke(null, [false]); - _ = trustType.GetMethod("FormatRotateCaDeferredTrustStatus", flags)!.Invoke(null, [true]); - _ = trustType.GetMethod("FormatRotateCaDeferredTrustStatus", flags)!.Invoke(null, [false]); _ = trustType.GetMethod("FormatFirefoxTrustOutcome", flags)!.Invoke(null, [CertificateOsTrustResult.Ok("trusted")]); _ = trustType.GetMethod("FormatFirefoxTrustOutcome", flags)!.Invoke(null, diff --git a/tests/Titanium.Inspector.Tests/InterceptionBodyCaptureAndTrustBgCoverageTests.cs b/tests/Titanium.Inspector.Tests/InterceptionBodyCaptureAndTrustBgCoverageTests.cs index a99a2efc7..7a46b6c67 100644 --- a/tests/Titanium.Inspector.Tests/InterceptionBodyCaptureAndTrustBgCoverageTests.cs +++ b/tests/Titanium.Inspector.Tests/InterceptionBodyCaptureAndTrustBgCoverageTests.cs @@ -195,7 +195,8 @@ public async Task FirefoxTrustBackground_ScheduleCoalesceDropWaitAndTimeout() enqueue.Invoke(interception, [clearKind, (Action)(() => { started.Set(); - Thread.Sleep(4000); // exceeds 3s Clear/Enable timeout + using var hold = new ManualResetEventSlim(false); + hold.Wait(TimeSpan.FromMilliseconds(4000)); // exceeds 3s Clear/Enable timeout })]); Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(2))); enqueue.Invoke(interception, [enableKind, (Action)(() => { })]); diff --git a/tests/Titanium.Inspector.Tests/InterceptionCaptureCoverageTests.cs b/tests/Titanium.Inspector.Tests/InterceptionCaptureCoverageTests.cs index 0dec871cc..09a518bf9 100644 --- a/tests/Titanium.Inspector.Tests/InterceptionCaptureCoverageTests.cs +++ b/tests/Titanium.Inspector.Tests/InterceptionCaptureCoverageTests.cs @@ -157,20 +157,18 @@ public void HelperReflection_CoversTruncateFormatShouldBufferAndTiming() { using var interception = new InterceptionService(new RecordingSystemProxyController()); var flags = BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; - var truncateBytes = typeof(InterceptionService).GetMethod("TruncateBytes", flags)!; - var truncateText = typeof(InterceptionService).GetMethod("TruncateText", flags)!; var formatHeaders = typeof(InterceptionService).GetMethod("FormatHeaders", flags)!; var applyTiming = typeof(InterceptionService).GetMethod("ApplyTiming", flags)!; var addTunnel = typeof(InterceptionService).GetMethod("AddTunnelBytes", flags)!; var tryHost = typeof(InterceptionService).GetMethod("TryHost", flags)!; - Assert.IsNull(truncateBytes.Invoke(null, new object?[] { null })); - Assert.AreEqual(0, ((byte[])truncateBytes.Invoke(null, [Array.Empty()])!).Length); + Assert.IsNull(InspectorBodyLimits.TruncateBytes(null)); + Assert.AreEqual(0, InspectorBodyLimits.TruncateBytes(Array.Empty())!.Length); var big = new byte[InterceptionService.MaxBodyBytes + 8]; - Assert.AreEqual(InterceptionService.MaxBodyBytes, ((byte[])truncateBytes.Invoke(null, [big])!).Length); - Assert.AreEqual("hi", (string)truncateText.Invoke(null, ["hi"])!); + Assert.AreEqual(InterceptionService.MaxBodyBytes, InspectorBodyLimits.TruncateBytes(big)!.Length); + Assert.AreEqual("hi", InspectorBodyLimits.TruncateText("hi")); var longText = new string('x', InterceptionService.MaxBodyTextChars + 3); - StringAssert.EndsWith((string)truncateText.Invoke(null, [longText])!, "…"); + StringAssert.EndsWith(InspectorBodyLimits.TruncateText(longText), "…"); var headers = new HeaderCollection(); headers.AddHeader("X-A", "1"); @@ -768,9 +766,9 @@ public async Task Interception_CompleteRootTrustAndLegacyCrtsSeams() OverrideRootPfx(interception, Path.Combine(dir, "rootCert.pfx")); var complete = typeof(InterceptionService).GetMethod("CompleteRootTrustInstall", - BindingFlags.NonPublic | BindingFlags.Instance)!; - Assert.IsFalse((bool)complete.Invoke(interception, [false])!); - Assert.IsTrue((bool)complete.Invoke(interception, [true])!); + BindingFlags.NonPublic | BindingFlags.Static)!; + Assert.IsFalse((bool)complete.Invoke(null, [false])!); + Assert.IsTrue((bool)complete.Invoke(null, [true])!); await interception.StartAsync(IPAddress.Loopback, 0); try @@ -778,7 +776,7 @@ public async Task Interception_CompleteRootTrustAndLegacyCrtsSeams() Assert.IsTrue(interception.InstallRootCertificate(false)); Assert.IsTrue(interception.IsRootTrusted); // CompleteRootTrustInstall is a passthrough; Firefox prefs are scheduled by the VM off-UI. - Assert.IsTrue((bool)complete.Invoke(interception, [true])!); + Assert.IsTrue((bool)complete.Invoke(null, [true])!); } finally { diff --git a/tests/Titanium.Inspector.Tests/SonarPrCoverageGapTests.cs b/tests/Titanium.Inspector.Tests/SonarPrCoverageGapTests.cs index 3a903e76c..2d768cc5d 100644 --- a/tests/Titanium.Inspector.Tests/SonarPrCoverageGapTests.cs +++ b/tests/Titanium.Inspector.Tests/SonarPrCoverageGapTests.cs @@ -107,17 +107,17 @@ public async Task Replay_AttachBodyFromFile_CoversStreamContentPath() using var request = new HttpRequestMessage(HttpMethod.Post, "https://example.test/"); var session = new SessionSnapshot { ContentType = "application/octet-stream" }; await using var fs = await (Task)attach.Invoke( - null, [request, session, null, bodyPath, CancellationToken.None])!; + null, [request, session, null, bodyPath])!; Assert.IsNotNull(fs); Assert.IsNotNull(request.Content); Assert.AreEqual(4, request.Content!.Headers.ContentLength); - await Assert.ThrowsExceptionAsync(async () => + await Assert.ThrowsExactlyAsync(async () => { using var missingReq = new HttpRequestMessage(HttpMethod.Post, "https://example.test/"); _ = await (Task)attach.Invoke( null, - [missingReq, session, null, Path.Combine(dir, "missing.bin"), CancellationToken.None])!; + [missingReq, session, null, Path.Combine(dir, "missing.bin")])!; }); } finally diff --git a/tests/Titanium.Inspector.Tests/TrustCommandCoverageTests.cs b/tests/Titanium.Inspector.Tests/TrustCommandCoverageTests.cs index 9a2ce1347..2bf1430a7 100644 --- a/tests/Titanium.Inspector.Tests/TrustCommandCoverageTests.cs +++ b/tests/Titanium.Inspector.Tests/TrustCommandCoverageTests.cs @@ -397,7 +397,6 @@ public async Task DecryptTrustTerminalChoice_AndCertutilRecovery_StayHeadless() }; var flags = BindingFlags.NonPublic | BindingFlags.Instance; - typeof(MainWindowViewModel).GetMethod("NotifyDecryptHttpsUnchanged", flags)!.Invoke(vm, null); typeof(MainWindowViewModel).GetMethod("SetDecryptHttpsCore", flags)!.Invoke(vm, [false]); Assert.IsFalse(vm.DecryptHttps); typeof(MainWindowViewModel).GetMethod("SetDecryptHttpsCore", flags)!.Invoke(vm, [true]); diff --git a/tests/Titanium.Inspector.Tests/TrustDecisionTableTests.cs b/tests/Titanium.Inspector.Tests/TrustDecisionTableTests.cs index 82fd20c25..1a4a09a73 100644 --- a/tests/Titanium.Inspector.Tests/TrustDecisionTableTests.cs +++ b/tests/Titanium.Inspector.Tests/TrustDecisionTableTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Reflection; +using System.Text.RegularExpressions; using System.Windows.Input; using Microsoft.VisualStudio.TestTools.UnitTesting; using Titanium.Inspector.Services; @@ -8,7 +9,7 @@ namespace Titanium.Inspector.Tests; [TestClass] -public class TrustDecisionTableTests +public partial class TrustDecisionTableTests { [TestMethod] [TestCategory("Inspector-Trust-Decision")] @@ -186,11 +187,12 @@ await ExecuteUntilAsync( StringAssert.DoesNotMatch( harness.Vm.StatusText, - new System.Text.RegularExpressions.Regex( - "certutil not found on PATH", - System.Text.RegularExpressions.RegexOptions.IgnoreCase)); + CertutilNotFoundOnPathRegex()); } + [GeneratedRegex("certutil not found on PATH", RegexOptions.IgnoreCase)] + private static partial Regex CertutilNotFoundOnPathRegex(); + [TestMethod] [TestCategory("Inspector-Trust-Decision")] public async Task InstallCa_AlreadyTrusted_AndFailThenRecover_CoverTrustBranches() diff --git a/tests/Titanium.Inspector.Tests/TrustUxStressTests.cs b/tests/Titanium.Inspector.Tests/TrustUxStressTests.cs index 6462a3d01..dca3cb907 100644 --- a/tests/Titanium.Inspector.Tests/TrustUxStressTests.cs +++ b/tests/Titanium.Inspector.Tests/TrustUxStressTests.cs @@ -9,7 +9,7 @@ namespace Titanium.Inspector.Tests; [TestClass] -public class TrustUxStressTests +public partial class TrustUxStressTests { [TestMethod] [TestCategory("Inspector-Stress")] @@ -133,7 +133,7 @@ private static void AssertNoUnexpectedSlowUx(long uxBefore) fs.Seek(uxBefore, SeekOrigin.Begin); using var reader = new StreamReader(fs); var tail = reader.ReadToEnd(); - foreach (Match m in Regex.Matches(tail, @"SLOW\s+#\d+\s+(\S+)\s+ms=(\d+)")) + foreach (Match m in SlowUxTraceRegex().Matches(tail)) { var name = m.Groups[1].Value; var ms = int.Parse(m.Groups[2].Value); @@ -155,6 +155,9 @@ private static void AssertNoUnexpectedSlowUx(long uxBefore) } } + [GeneratedRegex(@"SLOW\s+#\d+\s+(\S+)\s+ms=(\d+)")] + private static partial Regex SlowUxTraceRegex(); + private static void OverrideRootPfx(InterceptionService interception, string path) { var field = typeof(InterceptionService).GetField("_rootPfxPath", diff --git a/tests/Titanium.Web.Proxy.UnitTests/Http2ServerHelloProbeBudgetTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Http2ServerHelloProbeBudgetTests.cs index 300222364..fd500161c 100644 --- a/tests/Titanium.Web.Proxy.UnitTests/Http2ServerHelloProbeBudgetTests.cs +++ b/tests/Titanium.Web.Proxy.UnitTests/Http2ServerHelloProbeBudgetTests.cs @@ -77,7 +77,7 @@ public async Task TryComplete_PropagatesProbeTimeoutException_InsteadOfTreatingI { var probe = Task.FromException(new TimeoutException("origin TLS timed out")); - var thrown = await Assert.ThrowsExceptionAsync(() => + var thrown = await Assert.ThrowsExactlyAsync(() => ProxyServer.TryCompleteHttp2NegotiationBeforeClientAlpnAsync(probe, CancellationToken.None)); Assert.AreEqual("origin TLS timed out", thrown.Message); From 797b550d1eddda03da5043e6b49d78a0a6f93f41 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Tue, 15 Sep 2026 13:49:33 -0700 Subject: [PATCH 3/7] ci(release): gate beta/stable with cli-e2e, product-smoke, and supply-chain. Bump VersionPrefix to 7.0.10 for a fresh NuGet identity after 7.0.9-beta. Require cli-e2e for NuGet/tag and Plus packaging; skip duplicate push editions; add stable Linux compare-product-smoke; nuget audit, Dependabot, CodeQL, and CycloneDX SBOMs. Keep Chocolatey/winget skipped via SKIP_CATALOG_PUBLISH. --- .github/BRANCH-PROTECTION.md | 8 +- .github/branch-ruleset-beta-stable.json | 46 ++++++++++ .github/dependabot.yml | 26 ++++++ .github/workflows/codeql.yml | 46 ++++++++++ .github/workflows/dotnetcore.yml | 41 +++++++-- .github/workflows/release.yml | 87 ++++++++++++++++++- .github/workflows/rps-saturation.yml | 8 +- src/Titanium.Cli/Titanium.Cli.csproj | 2 +- .../Titanium.Inspector.csproj | 2 +- src/Titanium.Plus/Titanium.Plus.csproj | 2 +- .../Titanium.Web.Proxy.Abstractions.csproj | 2 +- .../Titanium.Web.Proxy.Configuration.csproj | 2 +- .../Titanium.Web.Proxy.csproj | 2 +- tools/RpsLoadProbe/PERF-GATES.md | 8 +- tools/packaging/CHOCOLATEY_FOLLOWUP.md | 6 +- tools/packaging/WINGET_FOLLOWUP.md | 9 +- 16 files changed, 269 insertions(+), 28 deletions(-) create mode 100644 .github/branch-ruleset-beta-stable.json create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/BRANCH-PROTECTION.md b/.github/BRANCH-PROTECTION.md index 7209898a1..0231681d9 100644 --- a/.github/BRANCH-PROTECTION.md +++ b/.github/BRANCH-PROTECTION.md @@ -13,9 +13,11 @@ Configure in GitHub **Settings → Rules → Rulesets** (or classic branch prote | Require approvals | recommended (≥1 maintainer) | | Require conversation resolution | recommended | | Require status checks to pass | **yes** | -| Required checks | `.NET / build`, `.NET / ui-portable` (all matrix legs), `RPS saturation / rps` | +| Required checks | `.NET / build`, `.NET / ui-portable` (all 3 OS), `.NET / cli-e2e` (all 3 OS), `RPS saturation / rps` (all 3 OS on PR `compare-spot`) | | Allow bypass | admins only for emergencies | +Exact check names must match the Actions job names as shown on a PR (including matrix suffixes). The checked-in template is [`.github/branch-ruleset-beta-stable.json`](branch-ruleset-beta-stable.json) — after the first green PR, confirm the matrix suffixes in the Actions UI and adjust the JSON if GitHub renamed a leg. + ## `develop` Keep lighter rules for velocity (direct push OK if that is current practice). Do **not** require full `compare-product` on every feature PR. @@ -27,4 +29,6 @@ Keep lighter rules for velocity (direct push OK if that is current practice). Do gh api repos/{owner}/{repo}/rulesets --input .github/branch-ruleset-beta-stable.json ``` -Exact check names must match the Actions job names as shown on a PR (including matrix suffixes for `ui-portable`). +## Stable release RPS + +After merge to `stable`, [`release.yml`](workflows/release.yml) runs Linux **`compare-product-smoke`** before the GA GitHub Release. Full 3-OS wiki-grade `compare-product` stays a maintainer `workflow_dispatch` on [`rps-saturation.yml`](workflows/rps-saturation.yml). diff --git a/.github/branch-ruleset-beta-stable.json b/.github/branch-ruleset-beta-stable.json new file mode 100644 index 000000000..43983c4f9 --- /dev/null +++ b/.github/branch-ruleset-beta-stable.json @@ -0,0 +1,46 @@ +{ + "name": "release-lines", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/beta", "refs/heads/stable"], + "exclude": [] + } + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["merge", "squash", "rebase"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": ".NET / build" }, + { "context": ".NET / ui-portable (windows-latest)" }, + { "context": ".NET / ui-portable (ubuntu-latest)" }, + { "context": ".NET / ui-portable (macos-latest)" }, + { "context": ".NET / cli-e2e (windows-latest)" }, + { "context": ".NET / cli-e2e (ubuntu-latest)" }, + { "context": ".NET / cli-e2e (macos-latest)" }, + { "context": "RPS saturation / rps (ubuntu-latest)" }, + { "context": "RPS saturation / rps (windows-latest)" }, + { "context": "RPS saturation / rps (macos-15-intel)" } + ] + } + }, + { "type": "non_fast_forward" }, + { "type": "deletion" } + ], + "bypass_actors": [] +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..6d29d0f51 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 +updates: + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 10 + labels: + - dependencies + groups: + nuget-minor-patch: + patterns: + - "*" + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - dependencies diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..31e0adc7a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,46 @@ +name: CodeQL + +on: + pull_request: + branches: [develop, beta, stable] + paths-ignore: + - 'website/**' + - 'wiki/**' + - 'docs/**' + - '*.md' + schedule: + - cron: '17 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (csharp) + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: csharp + build-mode: manual + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.0.x + + - name: Build for CodeQL + run: | + dotnet restore src/Titanium.Web.Proxy.sln + dotnet build src/Titanium.Web.Proxy.sln -c Release --no-restore + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:csharp" diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index aa92927c6..3c4143d8d 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -72,7 +72,9 @@ jobs: Add-Content -Path $env:GITHUB_PATH -Value "$env:USERPROFILE\.dotnet\tools" - name: Restore - run: dotnet restore src/Titanium.Web.Proxy.sln --warnaserror + run: | + # Fail on high/critical vulnerable transitive packages (dotnet NuGet audit). + dotnet restore src/Titanium.Web.Proxy.sln --warnaserror --audit --audit-level high - name: Build run: dotnet build src/Titanium.Web.Proxy.sln --configuration Release --no-restore --warnaserror @@ -412,7 +414,7 @@ jobs: - name: compare-editions (publish gate) shell: pwsh run: | - # Stable tags additionally require compare-product PASS via release.yml on that SHA. + # Stable GitHub Release additionally requires Linux compare-product-smoke via release.yml. pwsh tools/RpsLoadProbe/run-rps.ps1 ` -Mode compare-editions ` -Concurrency '8,16,32,64' ` @@ -461,7 +463,7 @@ jobs: publish: if: github.event_name == 'push' && (github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/stable') - needs: [build, ui-portable, rps-publish-gate, rps-peer-gate] + needs: [build, ui-portable, cli-e2e, rps-publish-gate, rps-peer-gate] runs-on: windows-latest environment: nuget-publish permissions: @@ -478,6 +480,9 @@ jobs: dotnet-version: | 10.0.x + - name: NuGet audit before pack + run: dotnet restore src/Titanium.Web.Proxy.sln --audit --audit-level high + - name: Pack Beta if: github.ref == 'refs/heads/beta' run: | @@ -492,6 +497,27 @@ jobs: dotnet pack src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj --configuration Release dotnet pack src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj --configuration Release + - name: Generate CycloneDX SBOM for MIT packages + shell: pwsh + run: | + dotnet tool install --global CycloneDX --version 6.2.0 + New-Item -ItemType Directory -Force -Path sbom | Out-Null + foreach ($proj in @( + 'src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj', + 'src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj', + 'src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj' + )) { + $name = [IO.Path]::GetFileNameWithoutExtension($proj) + dotnet CycloneDX $proj -o "sbom" -F Json -fn "$name.cdx.json" -t --disable-package-restore + } + + - name: Upload MIT package SBOM + uses: actions/upload-artifact@v4 + with: + name: mit-nuget-sbom + path: sbom/*.cdx.json + if-no-files-found: warn + - name: Log in to NuGet uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 id: login @@ -513,10 +539,10 @@ jobs: # Product zips stay on release.yml (v* tags). After beta/stable merge, create/move the # version tag and dispatch release.yml (GITHUB_TOKEN tag pushes do not re-trigger workflows). # release.yml also packs/pushes Chocolatey (titanium-cli / titanium-inspector) after the - # GitHub Release exists — no separate chocolatey step here. + # GitHub Release exists — skipped while vars.SKIP_CATALOG_PUBLISH=true. cut-product-tag: if: github.event_name == 'push' && (github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/stable') - needs: [build, ui-portable, rps-publish-gate, rps-peer-gate] + needs: [build, ui-portable, cli-e2e, rps-publish-gate, rps-peer-gate] runs-on: ubuntu-latest permissions: contents: write @@ -528,6 +554,7 @@ jobs: - name: Create/move product tag and dispatch release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SKIP_CATALOG_PUBLISH: ${{ vars.SKIP_CATALOG_PUBLISH }} run: | set -euo pipefail PREFIX=$(grep -oP '(?<=)[^<]+' src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj | head -n1) @@ -544,5 +571,7 @@ jobs: git tag -f -a "$TAG" -m "$TAG" "$GITHUB_SHA" git push origin "refs/tags/$TAG" --force echo "Dispatching product release for $TAG (channel=$CHANNEL) at $GITHUB_SHA" - gh workflow run release.yml --ref "$GITHUB_REF_NAME" -f channel="$CHANNEL" + SKIP_FLAG=false + if [[ "${SKIP_CATALOG_PUBLISH:-}" == "true" ]]; then SKIP_FLAG=true; fi + gh workflow run release.yml --ref "$GITHUB_REF_NAME" -f channel="$CHANNEL" -f skip_catalog_publish="$SKIP_FLAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4e409243..d90401edd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,11 @@ on: description: 'stable or beta' required: true default: 'stable' + skip_catalog_publish: + description: 'Skip Chocolatey (and any future catalog) publish' + required: false + type: boolean + default: false permissions: contents: read @@ -294,7 +299,7 @@ jobs: path: staged-cli-${{ matrix.rid }}/ if-no-files-found: error build-plus: - needs: [resolve-version, test] + needs: [resolve-version, test, cli-e2e] runs-on: ubuntu-latest permissions: contents: read @@ -551,9 +556,66 @@ jobs: grep -qi 'IsSupported' /cli/smoke-out.txt test -e /cli/libmsquic.so -o -e /cli/libmsquic.so.2 -o -e /cli/libmsquic.so.2.5.7 + # Stable-only Linux product RPS smoke (not wiki-grade 3-OS median-of-3). + # Beta channel skips this; spot + editions already ran on the merge SHA. + rps-product-smoke: + needs: [resolve-version] + if: needs.resolve-version.outputs.channel == 'stable' + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + - name: Install libmsquic (HTTP/3) + run: | + set -euo pipefail + . /etc/os-release + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + "https://packages.microsoft.com/config/${ID}/${VERSION_ID}/packages-microsoft-prod.deb" \ + -o packages-microsoft-prod.deb + sudo dpkg -i packages-microsoft-prod.deb + rm -f packages-microsoft-prod.deb + sudo apt-get update + sudo apt-get install -y libmsquic + pwsh -NoProfile -Command 'if (-not [System.Net.Quic.QuicListener]::IsSupported) { throw "QuicListener.IsSupported is false after libmsquic install" }; Write-Host "QuicListener.IsSupported=$([System.Net.Quic.QuicListener]::IsSupported)"' + - name: compare-product-smoke (Linux) + shell: pwsh + run: | + pwsh tools/RpsLoadProbe/run-rps.ps1 ` + -Mode compare-product-smoke ` + -Concurrency '8,16,32,64' ` + -WarmupSec 2 ` + -DurationSec 8 ` + -Repeats 1 ` + -ResultsDir tools/RpsLoadProbe/results ` + -BombardierCheck + $csv = Get-ChildItem tools/RpsLoadProbe/results -Filter 'rps-ramp-*.csv' | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if (-not $csv) { throw 'No CSV found for compare-product-smoke gate validation' } + pwsh tools/RpsLoadProbe/validate-compare-product-gates.ps1 -CsvPath $csv.FullName + - name: Upload smoke CSV + if: always() + uses: actions/upload-artifact@v4 + with: + name: rps-product-smoke-csv + path: tools/RpsLoadProbe/results/rps-ramp-*.csv + if-no-files-found: ignore + publish-release: - needs: [resolve-version, build-cli, build-plus, build-inspector, smoke-http3] - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + needs: [resolve-version, build-cli, build-plus, build-inspector, smoke-http3, rps-product-smoke] + if: | + always() && + (startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch') && + needs.resolve-version.result == 'success' && + needs.build-cli.result == 'success' && + needs.build-plus.result == 'success' && + needs.build-inspector.result == 'success' && + needs.smoke-http3.result == 'success' && + (needs.rps-product-smoke.result == 'success' || needs.rps-product-smoke.result == 'skipped') runs-on: ubuntu-latest permissions: contents: write @@ -565,6 +627,15 @@ jobs: - uses: actions/download-artifact@v4 with: path: artifacts + - name: Attach MIT NuGet SBOM when present + continue-on-error: true + run: | + set -euo pipefail + mkdir -p artifacts/sbom + # Optional artifact from .NET / publish (same SHA cut); ignore if missing. + if [[ -d artifacts/mit-nuget-sbom ]]; then + cp -a artifacts/mit-nuget-sbom/. artifacts/sbom/ || true + fi - name: Build release-manifest and create GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -583,7 +654,12 @@ jobs: find artifacts -type f \( \ -name '*.zip' -o -name '*.dll' -o -name '*.msi' \ -o -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' -o -name '*.dmg' \ + -o -name '*.cdx.json' \ \) -exec cp {} dist/ \; + # Prefer explicit SBOM folder copied by the prior step when present. + if [[ -d artifacts/sbom ]]; then + cp -a artifacts/sbom/. dist/ || true + fi python3 - <<'PY' import hashlib, json, os, pathlib, datetime tag = os.environ["RELEASE_TAG"] @@ -710,7 +786,10 @@ jobs: publish-chocolatey: needs: [resolve-version, publish-release] - if: needs.publish-release.result == 'success' + if: | + needs.publish-release.result == 'success' && + vars.SKIP_CATALOG_PUBLISH != 'true' && + (github.event_name != 'workflow_dispatch' || github.event.inputs.skip_catalog_publish != 'true') runs-on: windows-latest permissions: contents: read diff --git a/.github/workflows/rps-saturation.yml b/.github/workflows/rps-saturation.yml index daaa2f0f0..a3d2e37c5 100644 --- a/.github/workflows/rps-saturation.yml +++ b/.github/workflows/rps-saturation.yml @@ -15,8 +15,8 @@ name: RPS saturation on: pull_request: branches: [beta, stable] - push: - branches: [beta, stable] + # Push editions are gated by .NET / rps-publish-gate (Ubuntu only). Do not duplicate + # 3-OS compare-editions on every beta/stable merge. workflow_dispatch: inputs: mode: @@ -203,11 +203,11 @@ permissions: jobs: rps: - # PR → beta/stable: compare-spot; push → beta/stable: compare-editions; dispatch: inputs.mode + # PR → beta/stable: compare-spot; dispatch: inputs.mode permissions: contents: read env: - RPS_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || (github.event_name == 'pull_request' && 'compare-spot' || 'compare-editions') }} + RPS_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'compare-spot' }} RPS_CONCURRENCY: ${{ github.event_name == 'workflow_dispatch' && inputs.concurrency || '8,16,32,64' }} RPS_WARMUP_SEC: ${{ github.event_name == 'workflow_dispatch' && inputs.warmup_sec || 2 }} RPS_DURATION_SEC: ${{ github.event_name == 'workflow_dispatch' && inputs.duration_sec || 8 }} diff --git a/src/Titanium.Cli/Titanium.Cli.csproj b/src/Titanium.Cli/Titanium.Cli.csproj index 35a707190..66c5d910f 100644 --- a/src/Titanium.Cli/Titanium.Cli.csproj +++ b/src/Titanium.Cli/Titanium.Cli.csproj @@ -7,7 +7,7 @@ latest enable false - 7.0.9 + 7.0.10 Jehonathan Thomas Titanium Web Proxy CLI (titanium / twp). MIT diff --git a/src/Titanium.Inspector/Titanium.Inspector.csproj b/src/Titanium.Inspector/Titanium.Inspector.csproj index 757ff6012..9fb6a04d7 100644 --- a/src/Titanium.Inspector/Titanium.Inspector.csproj +++ b/src/Titanium.Inspector/Titanium.Inspector.csproj @@ -8,7 +8,7 @@ enable true false - 7.0.9 + 7.0.10 Jehonathan Thomas Titanium Inspector desktop traffic debugger (PolyForm Noncommercial). LICENSE diff --git a/src/Titanium.Plus/Titanium.Plus.csproj b/src/Titanium.Plus/Titanium.Plus.csproj index 86db356a8..734bc21dd 100644 --- a/src/Titanium.Plus/Titanium.Plus.csproj +++ b/src/Titanium.Plus/Titanium.Plus.csproj @@ -7,7 +7,7 @@ enable True StrongNameKey.snk - 7.0.9 + 7.0.10 Jehonathan Thomas Titanium Web Proxy Plus advanced features plugin (PolyForm Noncommercial). LICENSE diff --git a/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj b/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj index 52f090719..9b3a3ffee 100644 --- a/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj +++ b/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj @@ -7,7 +7,7 @@ enable True StrongNameKey.snk - 7.0.9 + 7.0.10 Jehonathan Thomas Shared contracts for Titanium Web Proxy routing, clusters, middleware, and plugins. MIT diff --git a/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj b/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj index 4a2ea79ee..2069987da 100644 --- a/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj +++ b/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj @@ -7,7 +7,7 @@ enable True StrongNameKey.snk - 7.0.9 + 7.0.10 Jehonathan Thomas YAML/JSON configuration binding for Titanium Web Proxy CLI and reverse-proxy documents. MIT diff --git a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj index 5deada820..4710becd3 100644 --- a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj +++ b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj @@ -13,7 +13,7 @@ - 7.0.9 + 7.0.10