File: Utils\PathNormalizerTests.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 System.Text;
using Aspire.Hosting.Utils;
using Aspire.Cli.Tests.TestServices;
 
namespace Aspire.Cli.Tests.Utils;
 
public class PathNormalizerTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void ResolveSymlinks_IsIdempotent_WhenPathHasNoSymlinks()
    {
        // The input itself may sit under a symlinked root (for example /var -> /private/var
        // on macOS), so we cannot assert the result equals the input. We can assert
        // idempotence: a path with no remaining symlinks must resolve to itself.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var subdir = workspace.WorkspaceRoot.CreateSubdirectory("App");
        var file = new FileInfo(Path.Combine(subdir.FullName, "app.csproj"));
        File.WriteAllText(file.FullName, "<Project />");
 
        var firstPass = PathNormalizer.ResolveSymlinks(file.FullName);
        var secondPass = PathNormalizer.ResolveSymlinks(firstPass);
 
        Assert.Equal(firstPass, secondPass);
    }
 
    [Fact]
    public void ResolveSymlinks_ReturnsInputUnchanged_WhenEmpty()
    {
        Assert.Equal(string.Empty, PathNormalizer.ResolveSymlinks(string.Empty));
    }
 
    [Fact]
    public void ResolveToFilesystemPath_UsesOnDiskCasing_WhenVolumeIsCaseInsensitive()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var directory = workspace.WorkspaceRoot.CreateSubdirectory("MixedCase");
        var file = new FileInfo(Path.Combine(directory.FullName, "App.csproj"));
        File.WriteAllText(file.FullName, "<Project />");
        var caseVariantPath = Path.Combine(workspace.WorkspaceRoot.FullName, "mixedcase", "app.CSPROJ");
        if (!File.Exists(caseVariantPath))
        {
            Assert.Skip("The test volume is case-sensitive.");
        }
 
        Assert.Equal(
            PathNormalizer.ResolveSymlinks(file.FullName),
            PathNormalizer.ResolveToFilesystemPath(caseVariantPath));
    }
 
    [Fact]
    public void ResolveToFilesystemPath_ReturnsExactPath_WhenVolumeIsCaseSensitive()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var directory = workspace.WorkspaceRoot.CreateSubdirectory("MixedCase");
        var file = new FileInfo(Path.Combine(directory.FullName, "App.csproj"));
        File.WriteAllText(file.FullName, "<Project />");
        var caseVariantPath = Path.Combine(workspace.WorkspaceRoot.FullName, "mixedcase", "app.CSPROJ");
        if (File.Exists(caseVariantPath))
        {
            Assert.Skip("The test volume is case-insensitive.");
        }
 
        Assert.Equal(
            PathNormalizer.ResolveSymlinks(file.FullName),
            PathNormalizer.ResolveToFilesystemPath(file.FullName));
    }
 
    [Fact]
    public void ResolveToFilesystemPath_CanonicalizesExistingPrefix_WhenPathIsMissing()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var missingPath = Path.Combine(workspace.WorkspaceRoot.FullName, "Missing", "App.csproj");
        var expectedPath = Path.Combine(
            PathNormalizer.ResolveSymlinks(workspace.WorkspaceRoot.FullName),
            "Missing",
            "App.csproj");
 
        Assert.Equal(expectedPath, PathNormalizer.ResolveToFilesystemPath(missingPath));
    }
 
    [Fact]
    public void ResolveSymlinks_ResolvesFinalFileSymlink()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var target = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "target.csproj"));
        File.WriteAllText(target.FullName, "<Project />");
 
        var linkPath = Path.Combine(workspace.WorkspaceRoot.FullName, "link.csproj");
        TestSymlinkHelper.TryCreateSymlink(linkPath, target.FullName, isDirectory: false);
 
        var resolved = PathNormalizer.ResolveSymlinks(linkPath);
 
        // The link's final target should be canonical-equal to the real file. We use
        // ResolveSymlinks on the target as well to account for the temp directory itself
        // sitting under a symlinked root (for example /tmp -> /private/tmp on macOS).
        Assert.Equal(PathNormalizer.ResolveSymlinks(target.FullName), resolved);
    }
 
    [Fact]
    public void ResolveSymlinks_ResolvesIntermediateDirectorySymlink()
    {
        // The L5 repro relies on a symlink that is NOT the final segment: on macOS,
        // /tmp -> /private/tmp, and the apphost lives at /tmp/L5/x.cs. A single call to
        // Directory.ResolveLinkTarget on the full path would not unwrap /tmp, so the
        // implementation must walk segments.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real");
        var nested = realDirectory.CreateSubdirectory("nested");
        var file = new FileInfo(Path.Combine(nested.FullName, "app.csproj"));
        File.WriteAllText(file.FullName, "<Project />");
 
        var linkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link");
        TestSymlinkHelper.TryCreateSymlink(linkDirectory, realDirectory.FullName);
 
        // Path through the link should resolve to the same canonical path as the path
        // through the real directory.
        var pathThroughLink = Path.Combine(linkDirectory, "nested", "app.csproj");
 
        var resolvedThroughLink = PathNormalizer.ResolveSymlinks(pathThroughLink);
        var resolvedThroughReal = PathNormalizer.ResolveSymlinks(file.FullName);
 
        Assert.Equal(resolvedThroughReal, resolvedThroughLink);
    }
 
    [Fact]
    public void ResolveSymlinks_PreservesPath_WhenLinkIsBroken()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var missingTarget = Path.Combine(workspace.WorkspaceRoot.FullName, "missing.csproj");
        var linkPath = Path.Combine(workspace.WorkspaceRoot.FullName, "broken-link.csproj");
        TestSymlinkHelper.TryCreateSymlink(linkPath, missingTarget, isDirectory: false);
 
        // A broken link should not throw — the method must fall back to returning the
        // path so callers can still surface a useful "file not found" error.
        var resolved = PathNormalizer.ResolveSymlinks(linkPath);
 
        Assert.False(string.IsNullOrEmpty(resolved));
    }
 
    [Fact]
    public void ResolveToFilesystemPath_ResolvesSymlinkedDirectory()
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix-only: validates symlink canonicalization that does not apply on Windows.");
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real");
        var projectFile = new FileInfo(Path.Combine(realDirectory.FullName, "AppHost.csproj"));
        File.WriteAllText(projectFile.FullName, "<Project />");
 
        var linkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link");
        TestSymlinkHelper.TryCreateSymlink(linkDirectory, realDirectory.FullName);
 
        var linkPath = Path.Combine(linkDirectory, projectFile.Name);
        var resolved = PathNormalizer.ResolveToFilesystemPath(linkPath);
 
        Assert.Equal(PathNormalizer.ResolveSymlinks(projectFile.FullName), resolved);
    }
 
    [Fact]
    public void ResolveToFilesystemPath_ResolvesMacOSFirmlink()
    {
        Assert.SkipWhen(!OperatingSystem.IsMacOS(), "macOS APFS firmlinks only exist on macOS.");
 
        var tempDirectory = Directory.CreateTempSubdirectory("aspire-path-normalizer-");
        try
        {
            var file = new FileInfo(Path.Combine(tempDirectory.FullName, "AppHost.csproj"));
            File.WriteAllText(file.FullName, "<Project />");
 
            var logicalPath = file.FullName.StartsWith("/private/var/", StringComparison.Ordinal)
                ? file.FullName["/private".Length..]
                : file.FullName;
 
            Assert.SkipWhen(!logicalPath.StartsWith("/var/", StringComparison.Ordinal), $"Temp path '{logicalPath}' is not under /var.");
 
            var resolved = PathNormalizer.ResolveToFilesystemPath(logicalPath);
 
            Assert.Equal($"/private{logicalPath}", resolved);
        }
        finally
        {
            tempDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void ResolveToFilesystemPath_DoesNotThrow_WhenIntermediateDirectoryIsMissing()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var missingPath = Path.Combine(workspace.WorkspaceRoot.FullName, "Missing.AppHost", "Missing.AppHost.csproj");
 
        var resolved = PathNormalizer.ResolveToFilesystemPath(missingPath);
 
        Assert.EndsWith(Path.Combine("Missing.AppHost", "Missing.AppHost.csproj"), resolved, StringComparison.Ordinal);
    }
 
    [Theory]
    [InlineData("Cafe\u0301")]
    [InlineData("\u1100\u1161")]
    public void ResolveToFilesystemPath_UsesEnumeratedUnicodeNormalization(string decomposedName)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testRoot = workspace.WorkspaceRoot.CreateSubdirectory("unicode-normalization");
        var composedName = decomposedName.Normalize(NormalizationForm.FormC);
        var decomposedPath = Path.Combine(testRoot.FullName, decomposedName);
        Directory.CreateDirectory(decomposedPath);
 
        var enumeratedPath = Assert.Single(Directory.EnumerateDirectories(testRoot.FullName));
        var composedPath = Path.Combine(testRoot.FullName, composedName);
        Assert.SkipUnless(
            Directory.Exists(composedPath),
            "The current filesystem does not resolve normalization-equivalent path segments.");
        Assert.SkipWhen(
            enumeratedPath.Equals(composedPath, StringComparison.Ordinal),
            "The current filesystem enumerates the candidate with the same normalization form.");
 
        // Resolve the enumerated side too because the temporary root can itself be an alias
        // (for example /var -> /private/var on macOS).
        Assert.Equal(
            PathNormalizer.ResolveSymlinks(enumeratedPath),
            PathNormalizer.ResolveToFilesystemPath(composedPath));
    }
 
    [Fact]
    public void ResolveToFilesystemPath_DoesNotChooseBetweenConflictingCaseAndNormalizationMatches()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testRoot = workspace.WorkspaceRoot.CreateSubdirectory("normalization-collision");
        var caseMatch = testRoot.CreateSubdirectory("CAF\u00C9");
        var normalizationMatch = Path.Combine(testRoot.FullName, "Cafe\u0301");
        Assert.SkipWhen(
            Directory.Exists(normalizationMatch),
            "The filesystem aliases both case and normalization, so the two entries cannot coexist.");
        Directory.CreateDirectory(normalizationMatch);
 
        var candidate = Path.Combine(testRoot.FullName, "Caf\u00E9");
        Assert.SkipUnless(
            Directory.Exists(candidate),
            "The filesystem does not resolve either alternate spelling.");
 
        Assert.Equal(
            PathNormalizer.ResolveSymlinks(candidate),
            PathNormalizer.ResolveToFilesystemPath(candidate));
        Assert.False(PathNormalizer.TryResolveToFilesystemPath(candidate, out var unresolvedPath));
        Assert.Equal(candidate, unresolvedPath);
        Assert.True(PathNormalizer.TryResolveToFilesystemPath(caseMatch.FullName, out _));
        Assert.True(PathNormalizer.TryResolveToFilesystemPath(normalizationMatch, out _));
    }
 
    [Fact]
    public void ResolveToFilesystemPath_UsesUppercaseWindowsDriveLetter()
    {
        Assert.SkipWhen(!OperatingSystem.IsWindows(), "Drive-letter casing only applies on Windows.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var file = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "app.csproj"));
        File.WriteAllText(file.FullName, "<Project />");
        Assert.SkipUnless(
            file.FullName.Length >= 3 &&
            file.FullName[1] == ':' &&
            file.FullName[2] == Path.DirectorySeparatorChar,
            "The temporary workspace is not on a drive-letter path.");
 
        var lowercaseDrivePath = $"{char.ToLowerInvariant(file.FullName[0])}{file.FullName[1..]}";
        var uppercaseDrivePath = $"{char.ToUpperInvariant(file.FullName[0])}{file.FullName[1..]}";
 
        Assert.NotEqual(lowercaseDrivePath, uppercaseDrivePath);
        Assert.Equal(uppercaseDrivePath, PathNormalizer.ResolveToFilesystemPath(lowercaseDrivePath));
    }
 
    [Fact]
    public void TryResolveToFilesystemPath_ReturnsFalseForMissingWindowsDriveRoot()
    {
        Assert.SkipWhen(!OperatingSystem.IsWindows(), "Drive-letter roots only apply on Windows.");
 
        var missingDriveLetter = Enumerable.Range('D', 'Z' - 'D' + 1)
            .Select(value => (char)value)
            .FirstOrDefault(driveLetter => !Directory.Exists($"{driveLetter}:{Path.DirectorySeparatorChar}"));
        Assert.SkipWhen(missingDriveLetter == default, "All drive letters are in use.");
 
        var missingDriveRoot = $"{char.ToLowerInvariant(missingDriveLetter)}:{Path.DirectorySeparatorChar}";
 
        Assert.False(PathNormalizer.TryResolveToFilesystemPath(missingDriveRoot, out var resolvedPath));
        Assert.Equal(missingDriveRoot, resolvedPath);
    }
 
}