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 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);
    }
 
}