Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/dotnetcore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ jobs:
runs-on: windows-latest
timeout-minutes: 60
permissions:
contents: read
# write: Publish Documentation (EndBug/add-and-commit) pushes DocFX output to develop.
# GITHUB_TOKEN with contents:read cannot push (403 github-actions[bot]).
contents: write
# SonarCloud / PR decoration when token is present
pull-requests: write
checks: write
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ jobs:
contents: read
strategy:
fail-fast: false
# Cap concurrency — many self-contained publishes on shared ubuntu runners run out of disk.
max-parallel: 3
matrix:
include:
- rid: linux-x64
Expand All @@ -131,6 +133,11 @@ jobs:
os: macos-latest
steps:
- uses: actions/checkout@v6
- name: Free disk space (Linux)
if: runner.os == 'Linux'
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL || true
df -h
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
Expand Down Expand Up @@ -191,6 +198,7 @@ jobs:
contents: read
strategy:
fail-fast: false
max-parallel: 3
matrix:
include:
- rid: win-x64
Expand All @@ -216,6 +224,11 @@ jobs:
msi: false
steps:
- uses: actions/checkout@v6
- name: Free disk space (Linux)
if: runner.os == 'Linux'
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL || true
df -h
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
Expand Down
4 changes: 2 additions & 2 deletions src/Titanium.Inspector/Services/SessionArchive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public static async Task ExportNativeArchiveAsync(IEnumerable<SessionSnapshot> s
{
ct.ThrowIfCancellationRequested();
var entry = zip.CreateEntry($"session-{index:D5}.json");
await using var stream = entry.Open();
await using var stream = await entry.OpenAsync(ct);
await JsonSerializer.SerializeAsync(stream, session, cancellationToken: ct);
index++;
}
Expand Down Expand Up @@ -98,7 +98,7 @@ public static async Task<List<SessionSnapshot>> ImportNativeArchiveAsync(string
continue;
}

await using var stream = entry.Open();
await using var stream = await entry.OpenAsync(ct);
var snap = await JsonSerializer.DeserializeAsync<SessionSnapshot>(stream, cancellationToken: ct);
if (snap is not null)
{
Expand Down
46 changes: 46 additions & 0 deletions src/Titanium.Web.Proxy/Certificates/CertificateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@

private static readonly ConcurrentDictionary<string, object> _saveCertificateLocks = new();

/// <summary>
/// When <see langword="true"/>, skip <see cref="StoreName.Root"/> Add/Remove that trigger Windows
/// CryptUI "Root Certificate Store" Yes/No dialogs (which hang headless CI and unattended
/// <c>dotnet test</c>). Personal (<see cref="StoreName.My"/>) mutations still run.
/// Also treated as true when <c>CI</c>, <c>GITHUB_ACTIONS</c>, <c>TF_BUILD</c>, or
/// <c>TITANIUM_SKIP_ROOT_STORE_UI=1</c> is set. Opt back in for intentional interactive Install CA
/// (e.g. local E2E-Slow Chrome) by setting this to <see langword="false"/> in a process that
/// does not set those env vars.
/// </summary>
public static bool SuppressInteractiveRootStoreMutations { get; set; }

/// <summary>
/// True when Root-store Add/Remove should be skipped to avoid modal CryptUI prompts.
/// </summary>
internal static bool ShouldSuppressInteractiveRootStoreMutations =>
SuppressInteractiveRootStoreMutations
|| IsTruthyEnv("CI")
|| IsTruthyEnv("GITHUB_ACTIONS")
|| IsTruthyEnv("TF_BUILD")
|| string.Equals(Environment.GetEnvironmentVariable("TITANIUM_SKIP_ROOT_STORE_UI"), "1",
StringComparison.Ordinal);

/// <summary>
/// Cache dictionary
/// </summary>
Expand Down Expand Up @@ -178,7 +200,7 @@
/// Read live on every disk save, independently of <paramref name="maxCacheEntriesProvider" />.
/// <see langword="null" /> return value means unbounded.
/// </param>
internal CertificateManager(string? rootCertificateName, string? rootCertificateIssuerName, // NOSONAR S107 -- Constructor preserves established configuration wiring.

Check warning on line 203 in src/Titanium.Web.Proxy/Certificates/CertificateManager.cs

View workflow job for this annotation

GitHub Actions / build

Constructor has 8 parameters, which is greater than the 7 authorized.
bool userTrustRootCertificate, bool machineTrustRootCertificate, bool trustRootCertificateAsAdmin,
ILogger logger, Func<int?>? maxCacheEntriesProvider = null, Func<int?>? maxDiskCacheEntriesProvider = null)
{
Expand Down Expand Up @@ -660,6 +682,10 @@
private void RemoveMatchingCertificates(
StoreName storeName, StoreLocation storeLocation, string expectedCn, string? keepThumbprint)
{
// Root Remove shows a blocking "Do you want to DELETE ... from the Root Store?" dialog on Windows.
if (storeName == StoreName.Root && ShouldSuppressInteractiveRootStoreMutations)
return;

try
{
using var store = new X509Store(storeName, storeLocation);
Expand Down Expand Up @@ -708,6 +734,10 @@

if (FindCertificates(storeName, storeLocation, certificate.Thumbprint).Count > 0) return false;

// Root Add shows a blocking Trusted Root Yes/No security dialog on Windows.
if (storeName == StoreName.Root && ShouldSuppressInteractiveRootStoreMutations)
return false;

var x509Store = new X509Store(storeName, storeLocation);

try
Expand Down Expand Up @@ -793,7 +823,7 @@
/// <param name="certificateName"></param>
/// <param name="isRootCertificate"></param>
/// <returns></returns>
internal X509Certificate2? CreateCertificate(string certificateName, bool isRootCertificate) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.

Check warning on line 826 in src/Titanium.Web.Proxy/Certificates/CertificateManager.cs

View workflow job for this annotation

GitHub Actions / build

Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed.
{
X509Certificate2? certificate;
try
Expand Down Expand Up @@ -911,7 +941,7 @@
/// </summary>
/// <param name="certificateName"></param>
/// <returns></returns>
public async Task<X509Certificate2?> CreateServerCertificate(string certificateName) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.

Check warning on line 944 in src/Titanium.Web.Proxy/Certificates/CertificateManager.cs

View workflow job for this annotation

GitHub Actions / build

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.
{
// check in cache first
if (TryGetValidCachedCertificate(certificateName, out var cachedCertificate))
Expand Down Expand Up @@ -1192,6 +1222,14 @@
return value;
}

private static bool IsTruthyEnv(string name)
{
var v = Environment.GetEnvironmentVariable(name);
return string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(v, "1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(v, "yes", StringComparison.OrdinalIgnoreCase);
}

private static bool IsSelfSigned(X509Certificate2 cert) =>
cert.SubjectName.RawData.SequenceEqual(cert.IssuerName.RawData);

Expand All @@ -1202,7 +1240,7 @@
/// <returns>
/// true if succeeded, else false.
/// </returns>
public bool CreateRootCertificate(bool persistToFile = true) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.

Check warning on line 1243 in src/Titanium.Web.Proxy/Certificates/CertificateManager.cs

View workflow job for this annotation

GitHub Actions / build

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.
{
lock (rootCertCreationLock)
{
Expand Down Expand Up @@ -1386,9 +1424,13 @@
// Explicit true when only user-store trust was requested (no machine step).
return machineTrusted
? Helpers.UnixCertificateTrust.TrustMachineSsl(certificate, RootCertificateName)
: true; // NOSONAR S1125

Check warning on line 1427 in src/Titanium.Web.Proxy/Certificates/CertificateManager.cs

View workflow job for this annotation

GitHub Actions / build

Remove the unnecessary Boolean literal(s).
}

// Elevated certutil shows UAC; skip in CI / test processes that suppress Root UI.
if (ShouldSuppressInteractiveRootStoreMutations)
return false;

// certutil.exe only accepts the PFX password via a plain "-p password" command-line argument -
// it has no file/stdin-based alternative (confirmed: no documented option to read it from a
// file). ProcessStartInfo.Arguments is visible to any other process/user that lists this
Expand Down Expand Up @@ -1531,9 +1573,13 @@
// Explicit true when only user-store untrust was requested (no machine step).
return machineTrusted
? Helpers.UnixCertificateTrust.UntrustMachineSsl(RootCertificate, RootCertificateName)
: true; // NOSONAR S1125

Check warning on line 1576 in src/Titanium.Web.Proxy/Certificates/CertificateManager.cs

View workflow job for this annotation

GitHub Actions / build

Remove the unnecessary Boolean literal(s).
}

// Elevated certutil -delstore shows UAC; skip when Root UI is suppressed.
if (ShouldSuppressInteractiveRootStoreMutations)
return true;

var infos = new List<ProcessStartInfo>();
if (!machineTrusted)
infos.Add(new ProcessStartInfo
Expand Down Expand Up @@ -1628,7 +1674,7 @@
rootCertificate = null;
}

private void Dispose(bool disposing) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.

Check warning on line 1677 in src/Titanium.Web.Proxy/Certificates/CertificateManager.cs

View workflow job for this annotation

GitHub Actions / build

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
{
if (disposed) return;

Expand Down
2 changes: 2 additions & 0 deletions src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Titanium.Web.Proxy.Network.CertificateManager.ApplyFastColdStartLeafSettings() -
static Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.GetSharedLeafCertificateDirectory() -> string!
static Titanium.Web.Proxy.Network.CertificateManager.LeafRsaKeyPairBufferSize.get -> int
static Titanium.Web.Proxy.Network.CertificateManager.LeafRsaKeyPairBufferSize.set -> void
static Titanium.Web.Proxy.Network.CertificateManager.SuppressInteractiveRootStoreMutations.get -> bool
static Titanium.Web.Proxy.Network.CertificateManager.SuppressInteractiveRootStoreMutations.set -> void
Titanium.Web.Proxy.Options.ProxyResourceLimits.MaxCertificateDiskCacheEntries.get -> int?
Titanium.Web.Proxy.Options.ProxyResourceLimits.MaxOriginHttp2ConnectionsPerAuthority.get -> int
Titanium.Web.Proxy.Options.ProxyResourceLimits.WithCertificateCacheBounds(int? maxCertificateCacheEntries, int? maxCertificateDiskCacheEntries) -> Titanium.Web.Proxy.Options.ProxyResourceLimits!
Expand Down
14 changes: 14 additions & 0 deletions tests/Titanium.Cli.Tests/SuppressRootStoreUiModuleInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Runtime.CompilerServices;
using Titanium.Web.Proxy.Network;

namespace Titanium.Cli.Tests;

/// <summary>
/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging local/CI test runs.
/// </summary>
internal static class SuppressRootStoreUiModuleInit
{
[ModuleInitializer]
internal static void Init() =>
CertificateManager.SuppressInteractiveRootStoreMutations = true;
}
14 changes: 12 additions & 2 deletions tests/Titanium.E2E.Tests/InspectorChromeSystemProxyE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Titanium.E2E.Tests.Harness;
using Titanium.Inspector.Services;
using Titanium.Web.Proxy.Network;

namespace Titanium.E2E.Tests;

Expand Down Expand Up @@ -43,8 +44,17 @@ public async Task Chrome_ThroughSystemProxy_WithQuicDisabled_CapturesSession()
try
{
await interception.StartAsync(IPAddress.Loopback, proxyPort);
var trusted = interception.InstallRootCertificate(machineStore: false);
Assert.IsTrue(trusted, "CA must be in CurrentUser Root for Chrome");
var previousSuppress = CertificateManager.SuppressInteractiveRootStoreMutations;
CertificateManager.SuppressInteractiveRootStoreMutations = false;
try
{
var trusted = interception.InstallRootCertificate(machineStore: false);
Assert.IsTrue(trusted, "CA must be in CurrentUser Root for Chrome");
}
finally
{
CertificateManager.SuppressInteractiveRootStoreMutations = previousSuppress;
}
Assert.IsTrue(interception.SetSystemProxy(true), "SetAsSystemProxy failed");

chromeProc = Process.Start(new ProcessStartInfo
Expand Down
16 changes: 16 additions & 0 deletions tests/Titanium.E2E.Tests/SuppressRootStoreUiModuleInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Runtime.CompilerServices;
using Titanium.Web.Proxy.Network;

namespace Titanium.E2E.Tests;

/// <summary>
/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging automated E2E.
/// Intentional interactive Install CA (e.g. E2E-Slow Chrome) must set
/// <see cref="CertificateManager.SuppressInteractiveRootStoreMutations"/> to false.
/// </summary>
internal static class SuppressRootStoreUiModuleInit
{
[ModuleInitializer]
internal static void Init() =>
CertificateManager.SuppressInteractiveRootStoreMutations = true;
}
15 changes: 15 additions & 0 deletions tests/Titanium.Inspector.Tests/SuppressRootStoreUiModuleInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Runtime.CompilerServices;
using Titanium.Web.Proxy.Network;

namespace Titanium.Inspector.Tests;

/// <summary>
/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging local/CI test runs.
/// Inspector tests should prefer <c>UseInMemoryTrustState</c>; this is a safety net.
/// </summary>
internal static class SuppressRootStoreUiModuleInit
{
[ModuleInitializer]
internal static void Init() =>
CertificateManager.SuppressInteractiveRootStoreMutations = true;
}
14 changes: 14 additions & 0 deletions tests/Titanium.Plus.Tests/SuppressRootStoreUiModuleInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Runtime.CompilerServices;
using Titanium.Web.Proxy.Network;

namespace Titanium.Plus.Tests;

/// <summary>
/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging local/CI test runs.
/// </summary>
internal static class SuppressRootStoreUiModuleInit
{
[ModuleInitializer]
internal static void Init() =>
CertificateManager.SuppressInteractiveRootStoreMutations = true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Runtime.CompilerServices;
using Titanium.Web.Proxy.Network;

namespace Titanium.Web.Proxy.IntegrationTests;

/// <summary>
/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging local/CI test runs.
/// </summary>
internal static class SuppressRootStoreUiModuleInit
{
[ModuleInitializer]
internal static void Init() =>
CertificateManager.SuppressInteractiveRootStoreMutations = true;
}
38 changes: 37 additions & 1 deletion tests/Titanium.Web.Proxy.UnitTests/CertificateManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -852,14 +852,50 @@ public async Task ClearRootCertificate_DisposesPendingEvictionsImmediately()
[TestMethod]
public void RemoveTrustedRootCertificate_NullRoot_LogsWithoutThrowing()
{
using var mgr = new CertificateManager(null, null, false, false, false, NullLogger.Instance)
// Unique CN: must not match the product default "Titanium Root Certificate Authority"
// or Remove walks CurrentUser\Root and Windows shows a blocking DELETE dialog.
using var mgr = new CertificateManager(
"Titanium UnitTest NullRoot CA " + Guid.NewGuid().ToString("N"),
"TitaniumUnitTest",
false, false, false, NullLogger.Instance)
{
CertificateEngine = CertificateEngine.BouncyCastleFast
};
mgr.RemoveTrustedRootCertificate(machineTrusted: false);
Assert.IsNull(mgr.RootCertificate);
}

[TestMethod]
public void SuppressInteractiveRootStoreMutations_SkipsRootAddAndRemove()
{
if (!RunTime.IsWindows)
Assert.Inconclusive("Root-store CryptUI suppression is Windows-focused.");

var previous = CertificateManager.SuppressInteractiveRootStoreMutations;
CertificateManager.SuppressInteractiveRootStoreMutations = true;
try
{
const string cn = "Titanium UnitTest Suppress Root UI CA";
using var mgr = new CertificateManager(cn, "TitaniumUnitTest", false, false, false,
NullLogger.Instance)
{
CertificateEngine = CertificateEngine.BouncyCastle
};
Assert.IsTrue(mgr.CreateRootCertificate(false));

// Would otherwise open Trusted Root Yes/No; must no-op under suppress.
mgr.TrustRootCertificate(machineTrusted: false);
Assert.IsFalse(mgr.IsRootCertificateUserTrusted());

// Would otherwise open Root DELETE Yes/No for any same-CN leftovers.
mgr.RemoveTrustedRootCertificate(machineTrusted: false);
}
finally
{
CertificateManager.SuppressInteractiveRootStoreMutations = previous;
}
}

/// <summary>
/// Only DefaultWindows is rewritten off Windows; BouncyCastleFast must remain selectable
/// on Linux/macOS (it is fully managed BouncyCastle).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Runtime.CompilerServices;
using Titanium.Web.Proxy.Network;

namespace Titanium.Web.Proxy.UnitTests;

/// <summary>
/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging local/CI test runs.
/// </summary>
internal static class SuppressRootStoreUiModuleInit
{
[ModuleInitializer]
internal static void Init() =>
CertificateManager.SuppressInteractiveRootStoreMutations = true;
}
15 changes: 10 additions & 5 deletions tools/packaging/build-inspector-msi.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@ if (-not (Test-Path (Join-Path $PayloadDir "TitaniumInspector.exe"))) {
throw "TitaniumInspector.exe missing under $PayloadDir"
}

# Always absolute: `dotnet wix -o` is relative to the WiX cwd, while Test-Path after
# Pop-Location is relative to the caller's cwd (repo root on CI).
$msiLeaf = Split-Path -Leaf $OutputMsi
$outDir = Split-Path -Parent $OutputMsi
if ([string]::IsNullOrWhiteSpace($outDir)) {
$outDir = (Get-Location).Path
}
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$OutputMsi = Join-Path (Resolve-Path $outDir) $msiLeaf

Push-Location $wixDir
try {
dotnet tool restore
$outDir = Split-Path -Parent $OutputMsi
if ($outDir) {
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$OutputMsi = Join-Path (Resolve-Path $outDir) (Split-Path -Leaf $OutputMsi)
}

& dotnet wix build $wxs `
-b "PayloadDir=$PayloadDir" `
Expand Down
12 changes: 7 additions & 5 deletions tools/packaging/bundle-http3-native.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,15 @@ function Ensure-SonameLinks([string] $dir) {
function Set-LinuxRpath([string] $dir) {
$patchelf = Ensure-Patchelf
Get-ChildItem -Path $dir -File -Filter "*.so*" | ForEach-Object {
$soFile = $_
# Skip pure symlinks
if ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) { return }
if ($soFile.Attributes -band [IO.FileAttributes]::ReparsePoint) { return }
try {
Invoke-Native $patchelf @("--set-rpath", "`$ORIGIN", $_.FullName)
Invoke-Native $patchelf @("--set-rpath", "`$ORIGIN", $soFile.FullName)
}
catch {
Write-Info "patchelf skipped $($_.Name): $_"
# Catch uses $_ as the error; keep $soFile for the name.
Write-Info "patchelf skipped $($soFile.Name): $_"
}
}
}
Expand Down Expand Up @@ -367,10 +369,10 @@ function Bundle-Homebrew {
$local = Join-Path $PublishDir $depLeaf
if (-not (Test-Path $local)) {
# map libssl.3.dylib style
$alt = $dylibs | Where-Object { $_.Name -like ($depLeaf -replace '\..*$','') + "*" } | Select-Object -First 1
$alt = $dylibs | Where-Object { $_.Name -like (($depLeaf -replace '\..*$','') + "*") } | Select-Object -First 1
if ($alt) { $depLeaf = $alt.Name; $local = $alt.FullName }
}
if (Test-Path $local -or ($dylibs.Name -contains $depLeaf)) {
if ((Test-Path $local) -or ($dylibs.Name -contains $depLeaf)) {
if ($dep -ne "@loader_path/$depLeaf") {
try {
Invoke-Native "install_name_tool" @("-change", $dep, "@loader_path/$depLeaf", $lib.FullName)
Expand Down
Loading