diff --git a/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs b/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs index f8687660..7d8aa699 100644 --- a/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs @@ -45,6 +45,42 @@ public void Dispose() } } + [Theory] + [InlineData(null)] + [InlineData("")] + public void ResolveRootPfxPath_NoEnvPath_UsesDefaultFileInConfigDirectory(string? envPath) + { + Assert.Equal(_rootPath, CertificateAuthority.ResolveRootPfxPath(envPath, _dir)); + } + + [Fact] + public void ResolveRootPfxPath_RelativeEnvPath_ResolvesAgainstConfigDirectory() + { + var relative = Path.Combine("rootCert", "rootCert.pfx"); + + Assert.Equal( + Path.Combine(_dir, "rootCert", "rootCert.pfx"), + CertificateAuthority.ResolveRootPfxPath(relative, _dir)); + } + + [Fact] + public void ResolveRootPfxPath_RootedEnvPath_IsUsedAsIs() + { + var rooted = Path.Combine(_dir, "elsewhere", "custom.pfx"); + + Assert.Equal(rooted, CertificateAuthority.ResolveRootPfxPath(rooted, Path.Combine(_dir, "config"))); + } + + [Fact] + public void RelativeEnvPath_PersistsRootInSubfolder() + { + var rootPath = CertificateAuthority.ResolveRootPfxPath("rootCert/rootCert.pfx", _dir); + + using var ca = new CertificateAuthority(rootPath, _leafDir); + + Assert.True(File.Exists(Path.Combine(_dir, "rootCert", "rootCert.pfx"))); + } + [Fact] public void FirstRun_CreatesValidRoot_AndPersistsToDisk() { diff --git a/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs index 084ff0df..977f2e83 100644 --- a/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs +++ b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs @@ -18,7 +18,8 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// /// The on-disk layout intentionally mirrors the Titanium engine's /// CertificateDiskCache: the root lives at <configDir>/rootCert.pfx -/// (overridable via DEV_PROXY_CERT_PATH) and leaves at +/// (overridable via DEV_PROXY_CERT_PATH; relative values resolve against +/// <configDir>) and leaves at /// <configDir>/crts/<host>.pfx, both PKCS#12 with an empty password. /// Because the format + path match, this CA will load a root that the Titanium /// engine already created and the OS already trusts — so existing users get @@ -82,15 +83,33 @@ public static CertificateAuthority CreateDefault(ILogger? logger = null) { var configDirectory = ResolveConfigDirectory(); - var envPath = Environment.GetEnvironmentVariable("DEV_PROXY_CERT_PATH"); - var rootPfxPath = !string.IsNullOrEmpty(envPath) && Path.IsPathRooted(envPath) - ? envPath - : Path.Combine(configDirectory, RootCertFileName); + var rootPfxPath = ResolveRootPfxPath( + Environment.GetEnvironmentVariable("DEV_PROXY_CERT_PATH"), + configDirectory); var leafDirectory = Path.Combine(configDirectory, LeafDirectoryName); return new CertificateAuthority(rootPfxPath, leafDirectory, logger); } + /// + /// Resolves the root certificate path like Titanium's + /// CertificateDiskCache.GetRootCertificatePath: a rooted + /// DEV_PROXY_CERT_PATH is used as-is, a relative one is resolved + /// against the config directory, and an empty one falls back to + /// <configDir>/rootCert.pfx. + /// + internal static string ResolveRootPfxPath(string? envPath, string configDirectory) + { + if (string.IsNullOrEmpty(envPath)) + { + return Path.Combine(configDirectory, RootCertFileName); + } + + return Path.IsPathRooted(envPath) + ? envPath + : Path.Combine(configDirectory, envPath); + } + /// The root CA certificate clients must trust to allow interception. public X509Certificate2 RootCertificate => _ca;