Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 108 additions & 13 deletions src/Aspire.Cli/Utils/ReparsePoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ namespace Aspire.Cli.Utils;
/// Windows strategy: prefer a symbolic link (<see cref="Directory.CreateSymbolicLink"/>)
/// — available to users with Developer Mode or admin — and fall back to a directory
/// junction (created via <c>DeviceIoControl</c> + <c>FSCTL_SET_REPARSE_POINT</c>)
/// when symlink creation is denied. Junctions need no elevation, work for local
/// directory targets, and are transparent to <see cref="Directory.Exists(string)"/>
/// and file enumeration.
/// when symlink creation is denied or the created symlink cannot be evaluated.
/// Junctions need no elevation, work for local directory targets, and are
/// transparent to <see cref="Directory.Exists(string)"/> and file enumeration.
///
/// Unix strategy: symbolic link via <see cref="Directory.CreateSymbolicLink"/>.
/// </remarks>
Expand All @@ -29,13 +29,13 @@ internal static partial class ReparsePoint
/// </summary>
/// <remarks>
/// The target must be a local directory path. On Windows, if symbolic-link
/// creation is denied (for example, the user does not have Developer Mode
/// enabled and is not running as admin), this method falls back to creating
/// a directory junction. The public behavior is otherwise identical: the
/// resulting path resolves to <paramref name="target"/> for I/O purposes.
/// creation is denied or the created symbolic link cannot be evaluated, this
/// method falls back to creating a directory junction. The public behavior is
/// otherwise identical: the resulting path resolves to <paramref name="target"/>
/// for I/O purposes.
/// </remarks>
/// <param name="linkPath">The path to create the reparse point at.</param>
/// <param name="target">Absolute path to the target directory.</param>
/// <param name="target">Path to the target directory. Relative paths are resolved against the link's parent directory.</param>
public static void CreateOrReplace(string linkPath, string target)
{
if (string.IsNullOrEmpty(linkPath))
Expand All @@ -48,7 +48,7 @@ public static void CreateOrReplace(string linkPath, string target)
throw new ArgumentException("Target path is required.", nameof(target));
}

var absoluteTarget = Path.GetFullPath(target);
var absoluteTarget = ResolveTargetPath(linkPath, target);

// Create the new reparse point under a temporary name adjacent to the
// final link, then atomically rename over the existing link. This avoids
Expand Down Expand Up @@ -113,6 +113,15 @@ public static bool Exists(string path)
/// </summary>
public static bool IsReparsePoint(string path)
{
try
{
var attributes = File.GetAttributes(path);
return (attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
}

try
{
var info = new FileInfo(path);
Expand Down Expand Up @@ -142,14 +151,20 @@ public static bool IsReparsePoint(string path)
{
try
{
var attributes = File.GetAttributes(path);
if ((attributes & FileAttributes.ReparsePoint) != FileAttributes.ReparsePoint)
{
return null;
}

var dirInfo = new DirectoryInfo(path);
if (dirInfo.Exists && (dirInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
if (!string.IsNullOrEmpty(dirInfo.LinkTarget))
{
return dirInfo.LinkTarget;
}

var fileInfo = new FileInfo(path);
if (fileInfo.Exists && (fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
if (!string.IsNullOrEmpty(fileInfo.LinkTarget))
{
return fileInfo.LinkTarget;
}
Expand All @@ -168,6 +183,38 @@ public static bool IsReparsePoint(string path)
/// </summary>
public static void RemoveIfExists(string path)
{
try
{
var attributes = File.GetAttributes(path);
if ((attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
{
if ((attributes & FileAttributes.Directory) == FileAttributes.Directory)
{
Directory.Delete(path);
}
else
{
File.Delete(path);
}

return;
}
}
catch (DirectoryNotFoundException)
{
return;
}
catch (FileNotFoundException)
{
return;
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}

try
{
var dirInfo = new DirectoryInfo(path);
Expand Down Expand Up @@ -207,20 +254,43 @@ private static void CreateSymlinkOrJunction(string linkPath, string target)
return;
}

// Windows: try symbolic link first; fall back to a junction if creation is denied.
// Windows: try symbolic link first; fall back to a junction if creation is denied
// or if Windows policy allows creation but prevents following this link type.
try
{
Directory.CreateSymbolicLink(linkPath, target);
return;
if (CanFollowDirectoryReparsePoint(linkPath))
{
return;
}

RemoveIfExists(linkPath);
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{
RemoveIfExists(linkPath);
// Fall through to junction creation below.
}

CreateWindowsJunction(linkPath, target);
}

internal static bool CanFollowDirectoryReparsePoint(string path)
{
try
{
// Force Windows to evaluate the link immediately. Directory.Exists can
// report true for a symlink whose evaluation class is disabled.
using var enumerator = Directory.EnumerateFileSystemEntries(path).GetEnumerator();
_ = enumerator.MoveNext();
return true;
}
catch
{
return false;
}
}

private static string GetTempLinkPath(string linkPath)
{
// Use an adjacent path under the same parent so the rename stays on-volume
Expand All @@ -231,6 +301,31 @@ private static string GetTempLinkPath(string linkPath)
return Path.Combine(parent, $"{name}.new.{suffix}");
}

internal static string ResolveTargetPath(string linkPath, string target)
{
var normalizedTarget = NormalizeWindowsTargetPath(target);
if (Path.IsPathFullyQualified(normalizedTarget))
{
return Path.GetFullPath(normalizedTarget);
}

var linkParent = Path.GetDirectoryName(Path.GetFullPath(linkPath)) ?? ".";
return Path.GetFullPath(Path.Combine(linkParent, normalizedTarget));
}

private static string NormalizeWindowsTargetPath(string target)
{
const string ntLocalPathPrefix = @"\??\";
if (OperatingSystem.IsWindows() &&
target.StartsWith(ntLocalPathPrefix, StringComparison.Ordinal) &&
target.Length > ntLocalPathPrefix.Length)
{
return target[ntLocalPathPrefix.Length..];
}

return target;
}

// ═══════════════════════════════════════════════════════════════════════
// Windows junction fallback (no admin / dev-mode required)
// ═══════════════════════════════════════════════════════════════════════
Expand Down
51 changes: 49 additions & 2 deletions tests/Aspire.Cli.Tests/Utils/ReparsePointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,47 @@ public void RemoveIfExists_DoesNothingForMissingPath()
ReparsePoint.RemoveIfExists(Path.Combine(workspace.WorkspaceRoot.FullName, "missing"));
}

[Fact]
public void ResolveTargetPath_ResolvesRelativeTargetAgainstLinkDirectory()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var root = workspace.WorkspaceRoot.FullName;

var link = Path.Combine(root, "bundle");
var target = Path.Combine(root, "versions", "v1");

var resolvedTarget = ReparsePoint.ResolveTargetPath(link, Path.Combine("versions", "v1"));

Assert.Equal(Path.GetFullPath(target), resolvedTarget);
}

[Fact]
public void CanFollowDirectoryReparsePoint_ReturnsFalseWhenSymlinkTargetCannotBeOpened()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var root = workspace.WorkspaceRoot.FullName;

var link = Path.Combine(root, "bundle");
try
{
Directory.CreateSymbolicLink(link, Path.Combine("versions", "missing"));
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{
Assert.Skip("Symlink creation is not available (Developer Mode not enabled or not running as admin).");
return;
}

try
{
Assert.False(ReparsePoint.CanFollowDirectoryReparsePoint(link));
}
finally
{
ReparsePoint.RemoveIfExists(link);
}
}

// ─────────────────────────────────────────────────────────────────────
// Windows-specific: explicitly exercise the junction code path.
//
Expand Down Expand Up @@ -338,14 +379,20 @@ public void CreateOrReplace_MigratesJunctionToSymlink_WhenSymlinksAreAvailable()
using var workspace = TemporaryWorkspace.Create(outputHelper);
var root = workspace.WorkspaceRoot.FullName;

// Probe: can we create symlinks on this machine? If not, skip —
// we cannot assert a symlink was created.
// Probe: can we create and evaluate symlinks on this machine? If not, skip —
// CreateOrReplace should fall back to a junction and this test cannot assert
// that a symlink was created.
var probe = Path.Combine(root, "symlink-probe");
var probeTarget = Path.Combine(root, "probe-target");
Directory.CreateDirectory(probeTarget);
try
{
Directory.CreateSymbolicLink(probe, probeTarget);
if (!ReparsePoint.CanFollowDirectoryReparsePoint(probe))
{
Assert.Skip("Symlink evaluation is not available on this machine.");
return;
}
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{
Expand Down
Loading
Loading