File: Utils\ReparsePoint.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Microsoft.Win32.SafeHandles;
 
namespace Aspire.Cli.Utils;
 
/// <summary>
/// Helpers for creating reparse points (symlinks on Unix, symlinks-or-junctions on
/// Windows) used to point stable public paths at versioned bundle directories.
/// </summary>
/// <remarks>
/// 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 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>
internal static partial class ReparsePoint
{
    /// <summary>
    /// Creates (or replaces) a directory reparse point at <paramref name="linkPath"/>
    /// whose target is <paramref name="target"/>.
    /// </summary>
    /// <remarks>
    /// The target must be a local directory path. On Windows, if symbolic-link
    /// 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">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))
        {
            throw new ArgumentException("Link path is required.", nameof(linkPath));
        }
 
        if (string.IsNullOrEmpty(target))
        {
            throw new ArgumentException("Target path is required.", nameof(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
        // any window where the public name does not exist.
        var tempLinkPath = GetTempLinkPath(linkPath);
        RemoveIfExists(tempLinkPath);
 
        CreateSymlinkOrJunction(tempLinkPath, absoluteTarget);
 
        try
        {
            // Guard against replacing a real directory on any platform. Callers must
            // remove or migrate existing real directories before calling this method.
            if (Exists(linkPath) && !IsReparsePoint(linkPath))
            {
                throw new InvalidOperationException(
                    $"Cannot replace '{linkPath}': it is a real directory, not a reparse point. " +
                    "Callers must remove or migrate existing directories before creating a reparse point.");
            }
 
            if (OperatingSystem.IsWindows())
            {
                // Windows has no native overwriting rename for directory reparse points,
                // so remove the existing link then rename. The window is guarded by the
                // caller's bundle lock.
                RemoveIfExists(linkPath);
                Directory.Move(tempLinkPath, linkPath);
            }
            else
            {
                // On Unix, rename(2) atomically replaces an existing symlink with the
                // source symlink (both are treated as links, not directories, by the
                // kernel). This avoids any window where the public path is missing.
                if (NativeMethods.rename(tempLinkPath, linkPath) != 0)
                {
                    var errno = Marshal.GetLastPInvokeError();
                    throw new IOException(
                        $"rename('{tempLinkPath}', '{linkPath}') failed with errno {errno}.");
                }
            }
        }
        catch
        {
            // If replacement fails, clean up the temporary link so the next attempt starts fresh.
            RemoveIfExists(tempLinkPath);
            throw;
        }
    }
 
    /// <summary>
    /// Returns <see langword="true"/> if <paramref name="path"/> exists as a file
    /// or directory (resolving through reparse points).
    /// </summary>
    public static bool Exists(string path)
    {
        return Directory.Exists(path) || File.Exists(path);
    }
 
    /// <summary>
    /// Returns <see langword="true"/> if <paramref name="path"/> is a reparse
    /// point (symlink or junction).
    /// </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);
            if (info.Exists)
            {
                return (info.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
            }
 
            var dirInfo = new DirectoryInfo(path);
            if (dirInfo.Exists)
            {
                return (dirInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
            }
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
        }
 
        return false;
    }
 
    /// <summary>
    /// Resolves the immediate target of a reparse point. Returns <see langword="null"/>
    /// if <paramref name="path"/> is not a reparse point or the target cannot be read.
    /// </summary>
    public static string? GetTarget(string path)
    {
        try
        {
            var attributes = File.GetAttributes(path);
            if ((attributes & FileAttributes.ReparsePoint) != FileAttributes.ReparsePoint)
            {
                return null;
            }
 
            var dirInfo = new DirectoryInfo(path);
            if (!string.IsNullOrEmpty(dirInfo.LinkTarget))
            {
                return dirInfo.LinkTarget;
            }
 
            var fileInfo = new FileInfo(path);
            if (!string.IsNullOrEmpty(fileInfo.LinkTarget))
            {
                return fileInfo.LinkTarget;
            }
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
        }
 
        return null;
    }
 
    /// <summary>
    /// Removes <paramref name="path"/> if it exists. Handles both regular directories,
    /// reparse points, and files. A reparse point is removed without following through
    /// to its target.
    /// </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);
            if (dirInfo.Exists)
            {
                if ((dirInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
                {
                    // Deleting a directory reparse point removes the link without touching its target.
                    dirInfo.Delete(recursive: false);
                }
                else
                {
                    dirInfo.Delete(recursive: true);
                }
 
                return;
            }
 
            if (File.Exists(path))
            {
                File.Delete(path);
            }
        }
        catch (DirectoryNotFoundException)
        {
        }
        catch (FileNotFoundException)
        {
        }
    }
 
    private static void CreateSymlinkOrJunction(string linkPath, string target)
    {
        if (!OperatingSystem.IsWindows())
        {
            Directory.CreateSymbolicLink(linkPath, target);
            return;
        }
 
        // 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);
            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
        // and is effectively atomic.
        var parent = Path.GetDirectoryName(linkPath) ?? ".";
        var name = Path.GetFileName(linkPath);
        var suffix = Guid.NewGuid().ToString("N")[..8];
        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)
    // ═══════════════════════════════════════════════════════════════════════
 
    /// <summary>
    /// Directly creates a Windows directory junction at <paramref name="linkPath"/>
    /// pointing to <paramref name="target"/>, bypassing the symbolic-link preference.
    /// Exposed for testing so the junction code path can be exercised even on
    /// systems where <see cref="Directory.CreateSymbolicLink"/> would succeed
    /// (e.g. Windows with Developer Mode enabled).
    /// </summary>
    [SupportedOSPlatform("windows")]
    internal static void CreateWindowsJunction(string linkPath, string target)
    {
        Directory.CreateDirectory(linkPath);
 
        try
        {
            // The substitute name for a mount-point junction must be an NT path
            // prefixed with "\??\" and target an absolute local directory.
            var substituteName = @"\??\" + target.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
            var printName = target;
 
            using var handle = OpenReparsePointHandle(linkPath, write: true);
            WriteMountPointReparseData(handle, substituteName, printName);
        }
        catch
        {
            // If we failed mid-way, clean up the empty directory we just created
            // so subsequent attempts can start fresh.
            try
            {
                Directory.Delete(linkPath);
            }
            catch
            {
            }
            throw;
        }
    }
 
    [SupportedOSPlatform("windows")]
    private static SafeFileHandle OpenReparsePointHandle(string path, bool write)
    {
        var access = write
            ? Win32Constants.GENERIC_READ | Win32Constants.GENERIC_WRITE
            : Win32Constants.GENERIC_READ;
        var handle = NativeMethods.CreateFileW(
            path,
            access,
            Win32Constants.FILE_SHARE_READ | Win32Constants.FILE_SHARE_WRITE | Win32Constants.FILE_SHARE_DELETE,
            IntPtr.Zero,
            Win32Constants.OPEN_EXISTING,
            Win32Constants.FILE_FLAG_BACKUP_SEMANTICS | Win32Constants.FILE_FLAG_OPEN_REPARSE_POINT,
            IntPtr.Zero);
 
        if (handle.IsInvalid)
        {
            throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(),
                $"Failed to open reparse point handle for '{path}'.");
        }
 
        return handle;
    }
 
    /// <summary>
    /// Maximum size of a reparse data buffer (defined by MAXIMUM_REPARSE_DATA_BUFFER_SIZE in the Windows SDK).
    /// </summary>
    private const int MaxReparseDataBufferSize = 16 * 1024; // 16 KB
 
    [SupportedOSPlatform("windows")]
    private static void WriteMountPointReparseData(SafeFileHandle handle, string substituteName, string printName)
    {
        var subNameBytes = System.Text.Encoding.Unicode.GetBytes(substituteName);
        var printNameBytes = System.Text.Encoding.Unicode.GetBytes(printName);
 
        // Layout (mount-point reparse buffer):
        //   DWORD ReparseTag
        //   WORD  ReparseDataLength
        //   WORD  Reserved
        //   WORD  SubstituteNameOffset
        //   WORD  SubstituteNameLength
        //   WORD  PrintNameOffset
        //   WORD  PrintNameLength
        //   WCHAR PathBuffer[...]
        //     - SubstituteName, NUL
        //     - PrintName, NUL
 
        var headerSize = 8; // tag + length + reserved
        var mountPointInfoSize = 8; // four WORDs
        var pathBufferSize = subNameBytes.Length + 2 + printNameBytes.Length + 2;
        var reparseDataLength = mountPointInfoSize + pathBufferSize;
        var totalSize = headerSize + reparseDataLength;
 
        if (reparseDataLength > ushort.MaxValue)
        {
            throw new PathTooLongException(
                $"Junction target path is too long. The reparse data ({reparseDataLength} bytes) exceeds the " +
                $"maximum of {ushort.MaxValue} bytes. Use a shorter target path.");
        }
 
        if (totalSize > MaxReparseDataBufferSize)
        {
            throw new PathTooLongException(
                $"Junction target path is too long. The reparse buffer ({totalSize} bytes) exceeds the " +
                $"maximum of {MaxReparseDataBufferSize} bytes. Use a shorter target path.");
        }
 
        var buffer = new byte[totalSize];
        var span = buffer.AsSpan();
 
        System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(span[..4], Win32Constants.IO_REPARSE_TAG_MOUNT_POINT);
        System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(4, 2), (ushort)reparseDataLength);
        // Reserved remains zero.
 
        var pathOffset = headerSize + mountPointInfoSize;
        var subNameOffset = 0;
        var subNameLength = subNameBytes.Length;
        var printNameOffset = subNameLength + 2;
        var printNameLength = printNameBytes.Length;
 
        System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(8, 2), (ushort)subNameOffset);
        System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(10, 2), (ushort)subNameLength);
        System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(12, 2), (ushort)printNameOffset);
        System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(14, 2), (ushort)printNameLength);
 
        subNameBytes.CopyTo(span[pathOffset..]);
        // 2 NUL bytes after substitute name already zero in buffer.
        printNameBytes.CopyTo(span[(pathOffset + printNameOffset)..]);
 
        if (!NativeMethods.DeviceIoControl(
                handle,
                Win32Constants.FSCTL_SET_REPARSE_POINT,
                buffer,
                (uint)totalSize,
                IntPtr.Zero,
                0,
                out _,
                IntPtr.Zero))
        {
            throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(),
                "FSCTL_SET_REPARSE_POINT failed while creating directory junction.");
        }
    }
 
    private static partial class NativeMethods
    {
        [LibraryImport("kernel32.dll", SetLastError = true, EntryPoint = "CreateFileW", StringMarshalling = StringMarshalling.Utf16)]
        internal static partial SafeFileHandle CreateFileW(
            string lpFileName,
            uint dwDesiredAccess,
            uint dwShareMode,
            IntPtr lpSecurityAttributes,
            uint dwCreationDisposition,
            uint dwFlagsAndAttributes,
            IntPtr hTemplateFile);
 
        [LibraryImport("kernel32.dll", SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        internal static partial bool DeviceIoControl(
            SafeFileHandle hDevice,
            uint dwIoControlCode,
            byte[] lpInBuffer,
            uint nInBufferSize,
            IntPtr lpOutBuffer,
            uint nOutBufferSize,
            out uint lpBytesReturned,
            IntPtr lpOverlapped);
 
        [LibraryImport("kernel32.dll", SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        internal static partial bool DeviceIoControl(
            SafeFileHandle hDevice,
            uint dwIoControlCode,
            IntPtr lpInBuffer,
            uint nInBufferSize,
            byte[] lpOutBuffer,
            uint nOutBufferSize,
            out uint lpBytesReturned,
            IntPtr lpOverlapped);
 
        // POSIX rename(2): atomic on a single filesystem, overwrites destination if it
        // exists and is of a compatible kind (symlink/file replacing symlink/file).
        [LibraryImport("libc", SetLastError = true, StringMarshalling = StringMarshalling.Utf8, EntryPoint = "rename")]
        internal static partial int rename(string oldpath, string newpath);
    }
 
    /// <summary>
    /// Reads the reparse tag (e.g. <see cref="Win32Constants.IO_REPARSE_TAG_MOUNT_POINT"/>
    /// or <see cref="Win32Constants.IO_REPARSE_TAG_SYMLINK"/>) from an existing reparse
    /// point. Returns <see langword="null"/> if <paramref name="path"/> is not a reparse
    /// point or the tag cannot be read.
    /// </summary>
    [SupportedOSPlatform("windows")]
    internal static uint? GetReparseTag(string path)
    {
        if (!IsReparsePoint(path))
        {
            return null;
        }
 
        using var handle = OpenReparsePointHandle(path, write: false);
 
        // REPARSE_DATA_BUFFER starts with a DWORD ReparseTag; we only need the
        // first 4 bytes but must supply a buffer large enough for the ioctl.
        var buffer = new byte[16 * 1024];
        if (!NativeMethods.DeviceIoControl(
                handle,
                Win32Constants.FSCTL_GET_REPARSE_POINT,
                IntPtr.Zero,
                0,
                buffer,
                (uint)buffer.Length,
                out _,
                IntPtr.Zero))
        {
            return null;
        }
 
        return System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(0, 4));
    }
}