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