File: Utils\AppHostHelperTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Cli.Tests.Telemetry;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Utils;
using Aspire.Hosting.Backchannel;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Utils;
 
public class AppHostHelperTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void ComputeAuxiliarySocketPrefix_UsesCompactBackchannelDirectory()
    {
        var appHostPath = Path.Combine("path", "to", "MyApp.AppHost.csproj");
        var homeDirectory = Path.Combine(Path.GetTempPath(), "testuser");
 
        var socketPrefix = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, homeDirectory);
 
        var fileName = Path.GetFileName(socketPrefix);
        Assert.Matches("^[A-Za-z0-9_-]{11}$", fileName);
 
        var dir = Path.GetDirectoryName(socketPrefix);
        Assert.NotNull(dir);
        Assert.Equal(Path.Combine(homeDirectory, ".aspire", "cli", "bch"), dir);
    }
 
    [Fact]
    public void ComputeAuxiliarySocketPrefix_ProducesConsistentHash()
    {
        // Arrange
        var appHostPath = "/path/to/MyApp.AppHost.csproj";
        var homeDirectory = "/home/user";
 
        // Act
        var socketPrefix1 = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, homeDirectory);
        var socketPrefix2 = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, homeDirectory);
 
        // Assert - Same input should produce same prefix
        Assert.Equal(socketPrefix1, socketPrefix2);
    }
 
    [Fact]
    public void ComputeAuxiliarySocketPrefix_ProducesDifferentHashForDifferentAppHosts()
    {
        // Arrange
        var appHostPath1 = "/path/to/App1.AppHost.csproj";
        var appHostPath2 = "/path/to/App2.AppHost.csproj";
        var homeDirectory = "/home/user";
 
        // Act
        var socketPrefix1 = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath1, homeDirectory);
        var socketPrefix2 = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath2, homeDirectory);
 
        // Assert - Different inputs should produce different prefixes
        Assert.NotEqual(socketPrefix1, socketPrefix2);
    }
 
    [Fact]
    public void ComputeAuxiliarySocketPrefix_DoesNotUseReservedWindowsName()
    {
        var appHostPath = "/path/to/MyApp.AppHost.csproj";
        var homeDirectory = "/home/user";
 
        var socketPrefix = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, homeDirectory);
 
        var fileName = Path.GetFileName(socketPrefix);
        Assert.Equal(11, fileName.Length);
        Assert.DoesNotContain("auxi.sock.", fileName);
        Assert.DoesNotContain("aux.sock.", fileName);
    }
 
    [Fact]
    public void ComputeAuxiliarySocketPrefix_AppHostIdIs11Base64UrlCharacters()
    {
        var appHostPath = "/path/to/MyApp.AppHost.csproj";
        var homeDirectory = "/home/user";
 
        var socketPrefix = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, homeDirectory);
 
        var fileName = Path.GetFileName(socketPrefix);
        Assert.Equal(11, fileName.Length);
        Assert.Matches("^[A-Za-z0-9_-]+$", fileName);
    }
 
    [Fact]
    public void ComputeSocketPath_UsesUtf8ByteCountLimitForNonAsciiHomeDirectory()
    {
        var homeDirectory = @"C:\Users\TanakaTarou(田中太郎)";
        var appHostPath = @"C:\src\MyApp.AppHost\MyApp.AppHost.csproj";
        var processId = 26688;
        var oldSocketPath = Path.Combine(homeDirectory, ".aspire", "cli", "backchannels", "auxi.sock.3a579b6853b74a71.fee67dd76369.26688");
 
        var socketPath = BackchannelConstants.ComputeSocketPath(appHostPath, homeDirectory, processId);
 
        Assert.True(
            BackchannelConstants.GetSocketPathByteCountIncludingNull(oldSocketPath) > BackchannelConstants.GetMaxSocketPathBytesIncludingNull(),
            $"The legacy path should exceed the platform byte limit for this regression case: {oldSocketPath}");
        Assert.True(
            BackchannelConstants.GetSocketPathByteCountIncludingNull(socketPath) <= BackchannelConstants.GetMaxSocketPathBytesIncludingNull(),
            $"The compact path should fit the platform byte limit: {socketPath}");
    }
 
    [Fact]
    public void ExtractHashFromSocketPath_ExtractsHashFromCompactFormat()
    {
        var socketPath = "/home/user/.aspire/cli/bch/AbCdEfGhIjkLmNoPqRs.12345";
 
        var hash = AppHostHelper.ExtractHashFromSocketPath(socketPath);
 
        Assert.Equal("AbCdEfGhIjk", hash);
    }
 
    [Fact]
    public void ExtractHashFromSocketPath_ExtractsHashFromLegacyCurrentFormat()
    {
        var socketPath = "/home/user/.aspire/cli/backchannels/auxi.sock.abc123def4567890.a1b2c3d4e5f6.12345";
 
        var hash = AppHostHelper.ExtractHashFromSocketPath(socketPath);
 
        Assert.Equal("abc123def4567890", hash);
    }
 
    [Fact]
    public void ExtractHashFromSocketPath_ExtractsHashFromPreviousFormat()
    {
        var socketPath = "/home/user/.aspire/cli/backchannels/auxi.sock.abc123def4567890.12345";
 
        var hash = AppHostHelper.ExtractHashFromSocketPath(socketPath);
 
        Assert.Equal("abc123def4567890", hash);
    }
 
    [Fact]
    public void ExtractHashFromSocketPath_ExtractsHashFromOldFormat()
    {
        var socketPath = "/home/user/.aspire/cli/backchannels/auxi.sock.abc123def4567890";
 
        var hash = AppHostHelper.ExtractHashFromSocketPath(socketPath);
 
        Assert.Equal("abc123def4567890", hash);
    }
 
    [Fact]
    public void ExtractHashFromSocketPath_ExtractsHashFromLegacyAuxFormat()
    {
        var socketPath = "/home/user/.aspire/cli/backchannels/aux.sock.abc123def4567890";
 
        var hash = AppHostHelper.ExtractHashFromSocketPath(socketPath);
 
        Assert.Equal("abc123def4567890", hash);
    }
 
    [Fact]
    public void ExtractHashFromSocketPath_ReturnsNullForUnrecognizedFormat()
    {
        var socketPath = "/home/user/.aspire/cli/backchannels/unknown.sock.abc123";
 
        var hash = AppHostHelper.ExtractHashFromSocketPath(socketPath);
 
        Assert.Null(hash);
    }
 
    [Fact]
    public void ExtractPidFromSocketPath_ExtractsPidFromNewFormat()
    {
        // Legacy current format: auxi.sock.{hash}.{instanceHash}.{pid}
        var socketPath = "/home/user/.aspire/cli/backchannels/auxi.sock.abc123def4567890.a1b2c3d4e5f6.12345";
 
        var pid = AppHostHelper.ExtractPidFromSocketPath(socketPath);
 
        Assert.Equal(12345, pid);
    }
 
    [Fact]
    public void ExtractPidFromSocketPath_ExtractsPidFromPreviousFormat()
    {
        // Legacy previous format: auxi.sock.{hash}.{pid}
        var socketPath = "/home/user/.aspire/cli/backchannels/auxi.sock.abc123def4567890.12345";
 
        var pid = AppHostHelper.ExtractPidFromSocketPath(socketPath);
 
        Assert.Equal(12345, pid);
    }
 
    [Fact]
    public void ExtractPidFromSocketPath_ReturnsNullForOldFormat()
    {
        // Old format: auxi.sock.{hash} - no PID
        var socketPath = "/home/user/.aspire/cli/backchannels/auxi.sock.abc123def4567890";
 
        var pid = AppHostHelper.ExtractPidFromSocketPath(socketPath);
 
        Assert.Null(pid);
    }
 
    [Fact]
    public void ExtractPidFromSocketPath_ReturnsNullForInvalidPid()
    {
        // Invalid PID (not a number)
        var socketPath = "/home/user/.aspire/cli/backchannels/auxi.sock.abc123def4567890.notapid";
 
        var pid = AppHostHelper.ExtractPidFromSocketPath(socketPath);
 
        Assert.Null(pid);
    }
 
    [Fact]
    public void ProcessExists_ReturnsTrueForCurrentProcess()
    {
        var currentPid = Environment.ProcessId;
 
        var exists = AppHostHelper.ProcessExists(currentPid);
 
        Assert.True(exists);
    }
 
    [Fact]
    public void ProcessExists_ReturnsFalseForInvalidPid()
    {
        // Use a very high PID that's unlikely to exist
        var invalidPid = int.MaxValue - 1;
 
        var exists = AppHostHelper.ProcessExists(invalidPid);
 
        Assert.False(exists);
    }
 
    [Fact]
    public void FindMatchingSockets_ReturnsEmptyForNonExistentDirectory()
    {
        var appHostPath = "/path/to/MyApp.AppHost.csproj";
        var homeDirectory = "/nonexistent/home/directory";
 
        var sockets = BackchannelConstants.FindMatchingSockets(appHostPath, homeDirectory);
 
        Assert.Empty(sockets);
    }
 
    [Fact]
    public void FindMatchingSockets_FindsMatchingSocketFiles()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var backchannelsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch");
        Directory.CreateDirectory(backchannelsDir);
 
        var appHostPath = "/path/to/MyApp.AppHost.csproj";
 
        var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, workspace.WorkspaceRoot.FullName);
        var appHostId = Path.GetFileName(prefix);
 
        var socket1 = Path.Combine(backchannelsDir, $"{appHostId}a1b2C3d4.12345");
        var socket2 = Path.Combine(backchannelsDir, $"{appHostId}Z9y8X7w6.67890");
        File.WriteAllText(socket1, "");
        File.WriteAllText(socket2, "");
 
        var otherSocket = Path.Combine(backchannelsDir, "differentId1a1b2C3d4.99999");
        File.WriteAllText(otherSocket, "");
 
        var sockets = BackchannelConstants.FindMatchingSockets(appHostPath, workspace.WorkspaceRoot.FullName);
 
        Assert.Equal(2, sockets.Length);
        Assert.Contains(socket1, sockets);
        Assert.Contains(socket2, sockets);
        Assert.DoesNotContain(otherSocket, sockets);
    }
 
    [Fact]
    public void FindMatchingSockets_FindsOldFormatSocketsWithoutPid()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var legacyBackchannelsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "backchannels");
        Directory.CreateDirectory(legacyBackchannelsDir);
 
        var appHostPath = "/path/to/MyApp.AppHost.csproj";
 
        var hash = AppHostHelper.ComputeLegacyHashes(appHostPath)[0];
 
        var oldFormatSocket = Path.Combine(legacyBackchannelsDir, $"auxi.sock.{hash}");
        File.WriteAllText(oldFormatSocket, "");
 
        var legacyPidSocket = Path.Combine(legacyBackchannelsDir, $"auxi.sock.{hash}.12345");
        File.WriteAllText(legacyPidSocket, "");
 
        var sockets = BackchannelConstants.FindMatchingSockets(appHostPath, workspace.WorkspaceRoot.FullName);
 
        // Should find both old and new format
        Assert.Equal(2, sockets.Length);
        Assert.Contains(oldFormatSocket, sockets);
        Assert.Contains(legacyPidSocket, sockets);
    }
 
    [Fact]
    public void FindMatchingSockets_DoesNotMatchSimilarHashes()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var backchannelsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch");
        Directory.CreateDirectory(backchannelsDir);
 
        var appHostPath = "/path/to/MyApp.AppHost.csproj";
 
        var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, workspace.WorkspaceRoot.FullName);
        var appHostId = Path.GetFileName(prefix);
 
        // 8 base64url chars but missing the '.' separator before PID
        var badSeparator = Path.Combine(backchannelsDir, $"{appHostId}AbCdEfGhX12345");
        File.WriteAllText(badSeparator, "");
 
        // Correct structure but non-integer PID
        var badPid = Path.Combine(backchannelsDir, $"{appHostId}AbCdEfGh.notapid");
        File.WriteAllText(badPid, "");
 
        var sockets = BackchannelConstants.FindMatchingSockets(appHostPath, workspace.WorkspaceRoot.FullName);
 
        // Should NOT match the similar hash
        Assert.Empty(sockets);
    }
 
    [Fact]
    public void FindMatchingSockets_ReturnsEmptyWhenNoMatchingFiles()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var backchannelsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch");
        Directory.CreateDirectory(backchannelsDir);
 
        var appHostPath = "/path/to/MyApp.AppHost.csproj";
 
        // Create sockets for a DIFFERENT app host
        var otherSocket = Path.Combine(backchannelsDir, "differentId1a1b2C3d4.99999");
        File.WriteAllText(otherSocket, "");
 
        var sockets = BackchannelConstants.FindMatchingSockets(appHostPath, workspace.WorkspaceRoot.FullName);
 
        Assert.Empty(sockets);
    }
 
    [Fact]
    public void CleanupOrphanedSockets_CleansUpBothOldAndNewFormatSockets()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var backchannelsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch");
        Directory.CreateDirectory(backchannelsDir);
 
        var appHostPath = "/path/to/MyApp.AppHost.csproj";
 
        var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, workspace.WorkspaceRoot.FullName);
        var appHostId = AppHostHelper.ExtractHashFromSocketPath(prefix)!;
 
        var oldFormatSocket = Path.Combine(backchannelsDir, appHostId);
        File.WriteAllText(oldFormatSocket, "");
 
        var deadPid = int.MaxValue - 1;
        var orphanedSocket = Path.Combine(backchannelsDir, $"{appHostId}a1b2C3d4.{deadPid}");
        File.WriteAllText(orphanedSocket, "");
 
        var currentPid = Environment.ProcessId;
        var liveSocket = Path.Combine(backchannelsDir, $"{appHostId}Z9y8X7w6.{currentPid}");
        File.WriteAllText(liveSocket, "");
 
        var deleted = AppHostHelper.CleanupOrphanedSockets(backchannelsDir, appHostId, currentPid);
 
        // Should only delete the orphaned socket (dead PID)
        Assert.Equal(1, deleted);
        Assert.True(File.Exists(oldFormatSocket), "Old format socket should still exist (can't detect orphan)");
        Assert.False(File.Exists(orphanedSocket), "Orphaned socket should be deleted");
        Assert.True(File.Exists(liveSocket), "Live socket should still exist");
    }
 
    [Fact]
    public void FindMatchingNonOrphanedSockets_RemovesDeadPidSocketsAndKeepsLiveAndPidlessSockets()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var backchannelsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch");
        Directory.CreateDirectory(backchannelsDir);
 
        var appHostPath = "/path/to/MyApp.AppHost.csproj";
        // FindMatchingNonOrphanedSockets resolves symlinks (which canonicalizes via Path.GetFullPath)
        // before hashing, so the socket files must be keyed off the same resolved path. On Windows
        // Path.GetFullPath roots the drive-less "/path/to/..." to "C:\path\to\...", giving a different
        // hash than the raw string; resolving here keeps both sides consistent across all platforms.
        var resolvedAppHostPath = PathNormalizer.ResolveSymlinks(appHostPath);
        var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedAppHostPath, workspace.WorkspaceRoot.FullName);
        var appHostId = Path.GetFileName(prefix);
        var deadPid = int.MaxValue - 1;
        var currentPid = Environment.ProcessId;
 
        var orphanedSocket = Path.Combine(backchannelsDir, $"{appHostId}a1b2C3d4.{deadPid}");
        var liveSocket = Path.Combine(backchannelsDir, $"{appHostId}Z9y8X7w6.{currentPid}");
        var pidlessSocket = Path.Combine(backchannelsDir, appHostId);
        File.WriteAllText(orphanedSocket, "");
        File.WriteAllText(liveSocket, "");
        File.WriteAllText(pidlessSocket, "");
 
        var remainingSockets = AppHostHelper.FindMatchingNonOrphanedSockets(
            appHostPath,
            workspace.WorkspaceRoot.FullName,
            currentPid,
            NullLogger.Instance);
 
        Assert.Collection(
            remainingSockets.Order(StringComparer.Ordinal),
            socket => Assert.Equal(pidlessSocket, socket),
            socket => Assert.Equal(liveSocket, socket));
        Assert.False(File.Exists(orphanedSocket));
        Assert.True(File.Exists(liveSocket));
        Assert.True(File.Exists(pidlessSocket));
    }
 
    [Fact]
    public void FindMatchingNonOrphanedSockets_WithSymlinkedPath_MatchesCanonicalSocket()
    {
        Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(),
            "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real");
        var symlinkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link");
        Directory.CreateSymbolicLink(symlinkDirectory, realDirectory.FullName);
 
        var projectFileViaSymlink = Path.Combine(symlinkDirectory, "TestAppHost.csproj");
        File.WriteAllText(projectFileViaSymlink, "<Project />");
 
        var canonicalProjectPath = PathNormalizer.ResolveSymlinks(projectFileViaSymlink);
        var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(canonicalProjectPath, workspace.WorkspaceRoot.FullName);
        var appHostId = Path.GetFileName(prefix);
        var currentPid = Environment.ProcessId;
        var liveSocket = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch", $"{appHostId}a1b2C3d4.{currentPid}");
        Directory.CreateDirectory(Path.GetDirectoryName(liveSocket)!);
        File.WriteAllText(liveSocket, "");
 
        var remainingSockets = AppHostHelper.FindMatchingNonOrphanedSockets(
            projectFileViaSymlink,
            workspace.WorkspaceRoot.FullName,
            currentPid,
            NullLogger.Instance);
 
        Assert.Collection(remainingSockets, socket => Assert.Equal(liveSocket, socket));
    }
 
    [Fact]
    public void ComputeAuxiliarySocketPrefix_ResolvedSymlinkPath_MatchesRealTargetPrefix()
    {
        Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(),
            "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var homeDirectory = workspace.WorkspaceRoot.FullName;
 
        // Build a directory symlink ("link" -> "real") and reference the same on-disk AppHost
        // through both paths. This reproduces the macOS temp-path shape where /var/folders/...
        // is a symlink to /private/var/folders/..., so the symlinked and real paths are the same
        // file but differ textually.
        var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real");
        var symlinkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link");
        Directory.CreateSymbolicLink(symlinkDirectory, realDirectory.FullName);
 
        var realProjectPath = Path.Combine(realDirectory.FullName, "TestAppHost.csproj");
        File.WriteAllText(realProjectPath, "<Project />");
        var projectFileViaSymlink = Path.Combine(symlinkDirectory, "TestAppHost.csproj");
 
        // The AppHost keys its auxiliary backchannel socket on the symlink-resolved path, so the CLI
        // must resolve the symlinked path to arrive at the same socket prefix as the real target.
        var resolvedViaSymlink = PathNormalizer.ResolveSymlinks(projectFileViaSymlink);
        var resolvedRealTarget = PathNormalizer.ResolveSymlinks(realProjectPath);
        Assert.Equal(resolvedRealTarget, resolvedViaSymlink);
 
        var prefixViaResolvedSymlink = AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedViaSymlink, homeDirectory);
        var prefixForRealTarget = AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedRealTarget, homeDirectory);
        Assert.Equal(prefixForRealTarget, prefixViaResolvedSymlink);
 
        // The raw (unresolved) symlinked path hashes to a different prefix — exactly the mismatch that
        // caused detached `aspire start` to wait on a hash the AppHost never used and time out.
        var prefixViaRawSymlink = AppHostHelper.ComputeAuxiliarySocketPrefix(projectFileViaSymlink, homeDirectory);
        Assert.NotEqual(prefixForRealTarget, prefixViaRawSymlink);
    }
 
    [Theory]
    [InlineData("10.0.0", true)]
    [InlineData("9.2.0", true)]
    [InlineData("9.3.0", true)]
    [InlineData("13.0.0-preview.1", true)]
    [InlineData("9.1.0", false)]
    [InlineData("8.0.0", false)]
    [InlineData("1.0.0", false)]
    public async Task CheckAppHostCompatibility_VersionCheck(string aspireVersion, bool expectedCompatible)
    {
        var runner = new TestDotNetCliRunner
        {
            GetAppHostInformationAsyncCallback = (_, _, _) => (0, true, aspireVersion)
        };
        var interactionService = new TestInteractionService();
        var telemetry = TestTelemetryHelper.CreateInitializedTelemetry();
        var projectFile = new FileInfo(Path.Combine(Path.GetTempPath(), "test.csproj"));
        var workingDirectory = new DirectoryInfo(Path.GetTempPath());
 
        var (isCompatible, returnedVersion) = await AppHostHelper.CheckAppHostCompatibilityAsync(
            runner, interactionService, projectFile, telemetry, workingDirectory, "test.log", CancellationToken.None);
 
        Assert.Equal(expectedCompatible, isCompatible);
        Assert.Equal(aspireVersion, returnedVersion);
    }
 
    [Fact]
    public void ComputeLegacyHashes_IncludesDriveLetterOnlyHashSharedAcrossCasings()
    {
        Assert.SkipWhen(!OperatingSystem.IsWindows(),
            "Drive-letter legacy fallback behavior only applies on Windows.");
 
        var upperDrivePath = @"C:\Path\To\MyApp.AppHost.csproj";
        var lowerDrivePath = @"c:\Path\To\MyApp.AppHost.csproj";
 
        var upperHashes = AppHostHelper.ComputeLegacyHashes(upperDrivePath);
        var lowerHashes = AppHostHelper.ComputeLegacyHashes(lowerDrivePath);
 
        // The drive-letter-only normalized hash (produced by AppHost versions that only
        // upper-cased the drive letter) must appear in both arrays so sockets created by
        // those AppHosts are still discoverable regardless of which drive-letter casing
        // the current caller has.
        var shared = upperHashes.Intersect(lowerHashes, StringComparer.Ordinal).ToArray();
        Assert.NotEmpty(shared);
 
        // The current (full-uppercase on Windows) hash must also be shared because the
        // entire path is now normalized.
        Assert.Equal(upperHashes[0], lowerHashes[0]);
    }
 
    [Fact]
    public void ComputeLegacyHash_ReturnsNullOnNonWindowsWhenPathUnchanged()
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(),
            "Non-Windows behavior is validated by this test.");
 
        var appHostPath = "/path/to/MyApp.AppHost.csproj";
        var legacyHash = AppHostHelper.ComputeLegacyHash(appHostPath);
 
        Assert.Null(legacyHash);
    }
 
    [Fact]
    public void ComputeHash_IsCaseInsensitiveAcrossFullPathOnWindows()
    {
        Assert.SkipWhen(!OperatingSystem.IsWindows(),
            "Full-path normalization only applies on Windows.");
 
        var upper = @"C:\Foo\Bar\App.AppHost.csproj";
        var mixed = @"c:\foo\BAR\app.apphost.CSPROJ";
 
        Assert.Equal(BackchannelConstants.ComputeHash(upper), BackchannelConstants.ComputeHash(mixed));
        Assert.Equal(BackchannelConstants.ComputeAppHostId(upper), BackchannelConstants.ComputeAppHostId(mixed));
    }
 
    [Fact]
    public void FindMatchingSockets_FindsCompactSocketAcrossPathCasing()
    {
        Assert.SkipWhen(!OperatingSystem.IsWindows(),
            "Full-path normalization only applies on Windows.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var backchannelsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch");
        Directory.CreateDirectory(backchannelsDir);
 
        var upperPath = @"C:\Foo\Bar\App.AppHost.csproj";
        var mixedPath = @"c:\foo\BAR\app.apphost.CSPROJ";
 
        var appHostId = BackchannelConstants.ComputeAppHostId(upperPath);
        var socket = Path.Combine(backchannelsDir, $"{appHostId}a1b2C3d4.12345");
        File.WriteAllText(socket, "");
 
        var found = BackchannelConstants.FindMatchingSockets(mixedPath, workspace.WorkspaceRoot.FullName);
        Assert.Single(found);
        Assert.Contains(socket, found);
    }
 
    [Fact]
    public void FindMatchingSockets_FindsSocketsCreatedWithDifferentDriveLetterCasing()
    {
        Assert.SkipWhen(!OperatingSystem.IsWindows(),
            "Drive letter normalization only applies on Windows.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var backchannelsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch");
        Directory.CreateDirectory(backchannelsDir);
 
        // Simulate the real-world mismatch: FileInfo.FullName yields an uppercase drive letter
        // (e.g. "C:\...") while MSBuild metadata may yield a lowercase one (e.g. "c:\...").
        // Only the drive letter casing differs; the rest of the path is identical.
        var upperDrivePath = @"C:\Development\MyApp\MyApp.AppHost.csproj";
        var lowerDrivePath = @"c:\Development\MyApp\MyApp.AppHost.csproj";
 
        // Both should produce the same AppHost ID after drive-letter normalization.
        var upperPrefix = AppHostHelper.ComputeAuxiliarySocketPrefix(upperDrivePath, workspace.WorkspaceRoot.FullName);
        var lowerPrefix = AppHostHelper.ComputeAuxiliarySocketPrefix(lowerDrivePath, workspace.WorkspaceRoot.FullName);
        Assert.Equal(upperPrefix, lowerPrefix);
 
        var appHostId = Path.GetFileName(upperPrefix);
 
        var socket = Path.Combine(backchannelsDir, $"{appHostId}a1b2C3d4.12345");
        File.WriteAllText(socket, "");
 
        // Both path variants should find the socket
        var fromUpper = BackchannelConstants.FindMatchingSockets(upperDrivePath, workspace.WorkspaceRoot.FullName);
        var fromLower = BackchannelConstants.FindMatchingSockets(lowerDrivePath, workspace.WorkspaceRoot.FullName);
 
        Assert.Single(fromUpper);
        Assert.Single(fromLower);
        Assert.Contains(socket, fromUpper);
        Assert.Contains(socket, fromLower);
    }
 
    [Fact]
    public void FindMatchingSockets_LegacyHashFindsSocketsFromOlderAppHost()
    {
        Assert.SkipWhen(!OperatingSystem.IsWindows(),
            "Legacy hash divergence only occurs on Windows where drive-letter casing is normalized.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var backchannelsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "backchannels");
        Directory.CreateDirectory(backchannelsDir);
 
        // A path with a lowercase drive letter produces a legacy hash that differs from the
        // normalized hash (which has an uppercase drive letter).
        var appHostPath = @"c:\Development\MyApp\MyApp.AppHost.csproj";
        var legacyHash = AppHostHelper.ComputeLegacyHash(appHostPath);
        Assert.NotNull(legacyHash);
 
        // Create a socket using the legacy (pre-normalization) hash, as an older AppHost would
        var legacySocket = Path.Combine(backchannelsDir, $"auxi.sock.{legacyHash}.a1b2c3d4e5f6.99999");
        File.WriteAllText(legacySocket, "");
 
        var currentHash = AppHostHelper.ComputeLegacyHashes(appHostPath)[0];
        Assert.NotEqual(currentHash, legacyHash);
 
        // FindMatchingSockets should still find the legacy socket via fallback
        var found = BackchannelConstants.FindMatchingSockets(appHostPath, workspace.WorkspaceRoot.FullName);
        Assert.Single(found);
        Assert.Contains(legacySocket, found);
    }
}