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