diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 1a447a69b..763dd8cfe 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf9eea964..97e14ab9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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' @@ -191,6 +198,7 @@ jobs: contents: read strategy: fail-fast: false + max-parallel: 3 matrix: include: - rid: win-x64 @@ -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' diff --git a/src/Titanium.Inspector/Services/SessionArchive.cs b/src/Titanium.Inspector/Services/SessionArchive.cs index 34608348e..bb354c5bb 100644 --- a/src/Titanium.Inspector/Services/SessionArchive.cs +++ b/src/Titanium.Inspector/Services/SessionArchive.cs @@ -70,7 +70,7 @@ public static async Task ExportNativeArchiveAsync(IEnumerable 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++; } @@ -98,7 +98,7 @@ public static async Task> ImportNativeArchiveAsync(string continue; } - await using var stream = entry.Open(); + await using var stream = await entry.OpenAsync(ct); var snap = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct); if (snap is not null) { diff --git a/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs b/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs index 6c2cb7396..cf6d73ae1 100644 --- a/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs +++ b/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs @@ -87,6 +87,28 @@ public sealed class CertificateManager : IDisposable private static readonly ConcurrentDictionary _saveCertificateLocks = new(); + /// + /// When , skip Add/Remove that trigger Windows + /// CryptUI "Root Certificate Store" Yes/No dialogs (which hang headless CI and unattended + /// dotnet test). Personal () mutations still run. + /// Also treated as true when CI, GITHUB_ACTIONS, TF_BUILD, or + /// TITANIUM_SKIP_ROOT_STORE_UI=1 is set. Opt back in for intentional interactive Install CA + /// (e.g. local E2E-Slow Chrome) by setting this to in a process that + /// does not set those env vars. + /// + public static bool SuppressInteractiveRootStoreMutations { get; set; } + + /// + /// True when Root-store Add/Remove should be skipped to avoid modal CryptUI prompts. + /// + 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); + /// /// Cache dictionary /// @@ -660,6 +682,10 @@ private void RemoveOrphanedSameCommonNameCertificates(StoreLocation storeLocatio 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); @@ -708,6 +734,10 @@ private bool InstallCertificate(StoreName storeName, StoreLocation storeLocation 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 @@ -1192,6 +1222,14 @@ internal static CertificateEngine CoerceEngineForPlatform(CertificateEngine valu 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); @@ -1389,6 +1427,10 @@ public bool TrustRootCertificateAsAdmin(bool machineTrusted = false) : true; // NOSONAR S1125 } + // 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 @@ -1534,6 +1576,10 @@ public bool RemoveTrustedRootCertificateAsAdmin(bool machineTrusted = false) : true; // NOSONAR S1125 } + // Elevated certutil -delstore shows UAC; skip when Root UI is suppressed. + if (ShouldSuppressInteractiveRootStoreMutations) + return true; + var infos = new List(); if (!machineTrusted) infos.Add(new ProcessStartInfo diff --git a/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt b/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt index 02dc461dd..21430d4c6 100644 --- a/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt +++ b/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt @@ -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! diff --git a/tests/Titanium.Cli.Tests/SuppressRootStoreUiModuleInit.cs b/tests/Titanium.Cli.Tests/SuppressRootStoreUiModuleInit.cs new file mode 100644 index 000000000..bdad11c8b --- /dev/null +++ b/tests/Titanium.Cli.Tests/SuppressRootStoreUiModuleInit.cs @@ -0,0 +1,14 @@ +using System.Runtime.CompilerServices; +using Titanium.Web.Proxy.Network; + +namespace Titanium.Cli.Tests; + +/// +/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging local/CI test runs. +/// +internal static class SuppressRootStoreUiModuleInit +{ + [ModuleInitializer] + internal static void Init() => + CertificateManager.SuppressInteractiveRootStoreMutations = true; +} diff --git a/tests/Titanium.E2E.Tests/InspectorChromeSystemProxyE2ETests.cs b/tests/Titanium.E2E.Tests/InspectorChromeSystemProxyE2ETests.cs index db88b61a9..50a520b1c 100644 --- a/tests/Titanium.E2E.Tests/InspectorChromeSystemProxyE2ETests.cs +++ b/tests/Titanium.E2E.Tests/InspectorChromeSystemProxyE2ETests.cs @@ -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; @@ -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 diff --git a/tests/Titanium.E2E.Tests/SuppressRootStoreUiModuleInit.cs b/tests/Titanium.E2E.Tests/SuppressRootStoreUiModuleInit.cs new file mode 100644 index 000000000..daeb8f96b --- /dev/null +++ b/tests/Titanium.E2E.Tests/SuppressRootStoreUiModuleInit.cs @@ -0,0 +1,16 @@ +using System.Runtime.CompilerServices; +using Titanium.Web.Proxy.Network; + +namespace Titanium.E2E.Tests; + +/// +/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging automated E2E. +/// Intentional interactive Install CA (e.g. E2E-Slow Chrome) must set +/// to false. +/// +internal static class SuppressRootStoreUiModuleInit +{ + [ModuleInitializer] + internal static void Init() => + CertificateManager.SuppressInteractiveRootStoreMutations = true; +} diff --git a/tests/Titanium.Inspector.Tests/SuppressRootStoreUiModuleInit.cs b/tests/Titanium.Inspector.Tests/SuppressRootStoreUiModuleInit.cs new file mode 100644 index 000000000..92f2b3959 --- /dev/null +++ b/tests/Titanium.Inspector.Tests/SuppressRootStoreUiModuleInit.cs @@ -0,0 +1,15 @@ +using System.Runtime.CompilerServices; +using Titanium.Web.Proxy.Network; + +namespace Titanium.Inspector.Tests; + +/// +/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging local/CI test runs. +/// Inspector tests should prefer UseInMemoryTrustState; this is a safety net. +/// +internal static class SuppressRootStoreUiModuleInit +{ + [ModuleInitializer] + internal static void Init() => + CertificateManager.SuppressInteractiveRootStoreMutations = true; +} diff --git a/tests/Titanium.Plus.Tests/SuppressRootStoreUiModuleInit.cs b/tests/Titanium.Plus.Tests/SuppressRootStoreUiModuleInit.cs new file mode 100644 index 000000000..23fbb2d63 --- /dev/null +++ b/tests/Titanium.Plus.Tests/SuppressRootStoreUiModuleInit.cs @@ -0,0 +1,14 @@ +using System.Runtime.CompilerServices; +using Titanium.Web.Proxy.Network; + +namespace Titanium.Plus.Tests; + +/// +/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging local/CI test runs. +/// +internal static class SuppressRootStoreUiModuleInit +{ + [ModuleInitializer] + internal static void Init() => + CertificateManager.SuppressInteractiveRootStoreMutations = true; +} diff --git a/tests/Titanium.Web.Proxy.IntegrationTests/SuppressRootStoreUiModuleInit.cs b/tests/Titanium.Web.Proxy.IntegrationTests/SuppressRootStoreUiModuleInit.cs new file mode 100644 index 000000000..81d2d16fc --- /dev/null +++ b/tests/Titanium.Web.Proxy.IntegrationTests/SuppressRootStoreUiModuleInit.cs @@ -0,0 +1,14 @@ +using System.Runtime.CompilerServices; +using Titanium.Web.Proxy.Network; + +namespace Titanium.Web.Proxy.IntegrationTests; + +/// +/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging local/CI test runs. +/// +internal static class SuppressRootStoreUiModuleInit +{ + [ModuleInitializer] + internal static void Init() => + CertificateManager.SuppressInteractiveRootStoreMutations = true; +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/CertificateManagerTests.cs b/tests/Titanium.Web.Proxy.UnitTests/CertificateManagerTests.cs index 369923320..3f7afcfa7 100644 --- a/tests/Titanium.Web.Proxy.UnitTests/CertificateManagerTests.cs +++ b/tests/Titanium.Web.Proxy.UnitTests/CertificateManagerTests.cs @@ -852,7 +852,12 @@ 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 }; @@ -860,6 +865,37 @@ public void RemoveTrustedRootCertificate_NullRoot_LogsWithoutThrowing() 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; + } + } + /// /// Only DefaultWindows is rewritten off Windows; BouncyCastleFast must remain selectable /// on Linux/macOS (it is fully managed BouncyCastle). diff --git a/tests/Titanium.Web.Proxy.UnitTests/SuppressRootStoreUiModuleInit.cs b/tests/Titanium.Web.Proxy.UnitTests/SuppressRootStoreUiModuleInit.cs new file mode 100644 index 000000000..a6e261360 --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/SuppressRootStoreUiModuleInit.cs @@ -0,0 +1,14 @@ +using System.Runtime.CompilerServices; +using Titanium.Web.Proxy.Network; + +namespace Titanium.Web.Proxy.UnitTests; + +/// +/// Prevent Windows CryptUI Root Store Yes/No dialogs from hanging local/CI test runs. +/// +internal static class SuppressRootStoreUiModuleInit +{ + [ModuleInitializer] + internal static void Init() => + CertificateManager.SuppressInteractiveRootStoreMutations = true; +} diff --git a/tools/packaging/build-inspector-msi.ps1 b/tools/packaging/build-inspector-msi.ps1 index f5a08b8ba..faf5a9615 100644 --- a/tools/packaging/build-inspector-msi.ps1 +++ b/tools/packaging/build-inspector-msi.ps1 @@ -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" ` diff --git a/tools/packaging/bundle-http3-native.ps1 b/tools/packaging/bundle-http3-native.ps1 index 59614ce35..aa44b9a69 100644 --- a/tools/packaging/bundle-http3-native.ps1 +++ b/tools/packaging/bundle-http3-native.ps1 @@ -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): $_" } } } @@ -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)