From 031bd9b63a3c20033c943ec586461751059fca64 Mon Sep 17 00:00:00 2001 From: Caelan Sayler Date: Sat, 16 Aug 2025 16:40:58 +0100 Subject: [PATCH] New symbolic link implementation for legacy windows --- src/lib-csharp/Util/SymbolicLink.Unix.cs | 56 +++ src/lib-csharp/Util/SymbolicLink.Windows.cs | 211 ++++++++++++ src/lib-csharp/Util/SymbolicLink.cs | 114 ++----- test/Velopack.Tests/SymbolicLinkTests.cs | 358 +++++++++++++++++++- 4 files changed, 656 insertions(+), 83 deletions(-) create mode 100644 src/lib-csharp/Util/SymbolicLink.Unix.cs create mode 100644 src/lib-csharp/Util/SymbolicLink.Windows.cs diff --git a/src/lib-csharp/Util/SymbolicLink.Unix.cs b/src/lib-csharp/Util/SymbolicLink.Unix.cs new file mode 100644 index 00000000..5db275d4 --- /dev/null +++ b/src/lib-csharp/Util/SymbolicLink.Unix.cs @@ -0,0 +1,56 @@ +using System; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; + +namespace Velopack.Util +{ + internal static partial class SymbolicLink + { + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")] + [DllImport("libc", SetLastError = true)] + private static extern nint readlink(string path, byte[] buffer, ulong bufferSize); + + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")] + [DllImport("libc", SetLastError = true)] + private static extern int symlink(string target, string linkPath); + + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")] + private static string UnixReadLink(string symlinkPath) + { + const int bufferSize = 1024; + const int EINTR = 4; + byte[] buffer = new byte[bufferSize]; + nint bytesWritten; + + do { + bytesWritten = readlink(symlinkPath, buffer, bufferSize); + } while (bytesWritten == -1 && Marshal.GetLastWin32Error() == EINTR); + + if (bytesWritten < 1) { + throw new InvalidOperationException($"Error resolving symlink: {Marshal.GetLastWin32Error()}"); + } + + return Encoding.UTF8.GetString(buffer, 0, (int) bytesWritten); + } + + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")] + private static void UnixCreateSymlink(string target, string linkPath) + { + const int EINTR = 4; + int result; + + do { + result = symlink(target, linkPath); + } while (result == -1 && Marshal.GetLastWin32Error() == EINTR); + + if (result == -1) { + throw new InvalidOperationException($"Error creating symlink: {Marshal.GetLastWin32Error()}"); + } + } + } +} \ No newline at end of file diff --git a/src/lib-csharp/Util/SymbolicLink.Windows.cs b/src/lib-csharp/Util/SymbolicLink.Windows.cs new file mode 100644 index 00000000..43c04e1d --- /dev/null +++ b/src/lib-csharp/Util/SymbolicLink.Windows.cs @@ -0,0 +1,211 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace Velopack.Util +{ + internal static partial class SymbolicLink + { + private const string Kernel32 = "kernel32.dll"; + + private const uint FSCTL_GET_REPARSE_POINT = 0x000900A8; + private const uint IO_REPARSE_TAG_SYMLINK = 0xA000000C; + private const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003; + private const int ERROR_NOT_A_REPARSE_POINT = 4390; + private const int MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 16 * 1024; + private const uint SYMLINK_FLAG_RELATIVE = 1; + + private const uint GENERIC_READ = 0x80000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint OPEN_EXISTING = 3; + private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + + [StructLayout(LayoutKind.Sequential)] + private struct ReparseHeader + { + public uint ReparseTag; + public ushort ReparseDataLength; + public ushort Reserved; + } + + [StructLayout(LayoutKind.Sequential)] + private struct SymbolicData + { + public ushort SubstituteNameOffset; + public ushort SubstituteNameLength; + public ushort PrintNameOffset; + public ushort PrintNameLength; + public uint Flags; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JunctionData + { + public ushort SubstituteNameOffset; + public ushort SubstituteNameLength; + public ushort PrintNameOffset; + public ushort PrintNameLength; + } + + [SupportedOSPlatform("windows")] + [DllImport(Kernel32, SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CreateSymbolicLinkW")] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool PInvokeWindowsCreateSymlink( + [In] + string lpSymlinkFileName, + [In] + string lpTargetFileName, + [In] + SymbolicLinkFlag dwFlags); + + [DllImport(Kernel32, SetLastError = true, CharSet = CharSet.Unicode)] + private static extern SafeFileHandle CreateFile( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport(Kernel32, SetLastError = true, CharSet = CharSet.Auto)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, + uint dwIoControlCode, + IntPtr lpInBuffer, + int nInBufferSize, + IntPtr lpOutBuffer, + int nOutBufferSize, + out int lpBytesReturned, + IntPtr lpOverlapped); + + [SupportedOSPlatform("windows")] + private static string WindowsReadLink(string symlinkPath) + { + using (var hReparsePoint = CreateFile( + symlinkPath, + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + IntPtr.Zero)) { + if (hReparsePoint.IsInvalid) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var buffer = Marshal.AllocHGlobal(MAXIMUM_REPARSE_DATA_BUFFER_SIZE); + try { + int bytesReturned; + var success = DeviceIoControl( + hReparsePoint, + FSCTL_GET_REPARSE_POINT, + IntPtr.Zero, + 0, + buffer, + MAXIMUM_REPARSE_DATA_BUFFER_SIZE, + out bytesReturned, + IntPtr.Zero); + + if (!success) { + int error = Marshal.GetLastWin32Error(); + // The file or directory is not a reparse point. + if (error == ERROR_NOT_A_REPARSE_POINT) { + throw new InvalidOperationException($"Path is not a symbolic link: {symlinkPath}"); + } + + throw new Win32Exception(error); + } + + var reparseHeader = Marshal.PtrToStructure(buffer); + var reparseHeaderSize = Marshal.SizeOf(); + + // We always use SubstituteName instead of PrintName, + // the latter is just the display name and can show something unrelated to the target. + if (reparseHeader.ReparseTag == IO_REPARSE_TAG_SYMLINK) { + var symbolicData = Marshal.PtrToStructure(buffer + reparseHeaderSize); + var offset = Marshal.SizeOf() + reparseHeaderSize; + var target = ReadStringFromBuffer(buffer, offset + symbolicData.SubstituteNameOffset, symbolicData.SubstituteNameLength); + + bool isRelative = (symbolicData.Flags & SYMLINK_FLAG_RELATIVE) != 0; + if (!isRelative) { + // Absolute target is in NT format and we need to clean it up + return ParseNTPath(target); + } + + // Return relative path as-is + return target; + } else if (reparseHeader.ReparseTag == IO_REPARSE_TAG_MOUNT_POINT) { + var junctionData = Marshal.PtrToStructure(buffer + reparseHeaderSize); + var offset = Marshal.SizeOf() + reparseHeaderSize; + var target = ReadStringFromBuffer(buffer, offset + junctionData.SubstituteNameOffset, junctionData.SubstituteNameLength); + + // Mount points are always absolute and in NT format + return ParseNTPath(target); + } + + throw new InvalidOperationException($"Unsupported reparse point type: 0x{reparseHeader.ReparseTag:X}"); + } finally { + Marshal.FreeHGlobal(buffer); + } + } + } + + private static string ReadStringFromBuffer(IntPtr buffer, int offset, int byteCount) + { + var bytes = new byte[byteCount]; + Marshal.Copy(buffer + offset, bytes, 0, byteCount); + return Encoding.Unicode.GetString(bytes); + } + + private static string ParseNTPath(string path) + { + // NT paths come in different forms: + // \??\C:\foo - DOS device path + // \DosDevices\C:\foo - DOS device path + // \Global??\C:\foo - DOS device path + // \??\UNC\server\share - UNC path + + const string NTPathPrefix = "\\??\\"; + const string UNCNTPathPrefix = "\\??\\UNC\\"; + const string UNCPathPrefix = "\\\\"; + + if (path.StartsWith(UNCNTPathPrefix, StringComparison.OrdinalIgnoreCase)) { + // Convert \??\UNC\server\share to \\server\share + return UNCPathPrefix + path.Substring(UNCNTPathPrefix.Length); + } + + string[] dosDevicePrefixes = { NTPathPrefix, "\\DosDevices\\", "\\Global??\\" }; + + foreach (var prefix in dosDevicePrefixes) { + if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { + path = path.Substring(prefix.Length); + break; + } + } + + // Remove trailing backslash except for root paths like C:\ + if (path.Length > 3 && path.EndsWith("\\")) { + path = path.TrimEnd('\\'); + } + + return path; + } + + [SupportedOSPlatform("windows")] + private static void WindowsCreateSymlink(string target, string linkPath, SymbolicLinkFlag mode) + { + if (!PInvokeWindowsCreateSymlink(linkPath, target, mode)) { + var errorCode = Marshal.GetLastWin32Error(); + throw new InvalidOperationException($"Error creating symlink: {errorCode}", new Win32Exception()); + } + } + } +} \ No newline at end of file diff --git a/src/lib-csharp/Util/SymbolicLink.cs b/src/lib-csharp/Util/SymbolicLink.cs index d5ba7044..a319adbb 100644 --- a/src/lib-csharp/Util/SymbolicLink.cs +++ b/src/lib-csharp/Util/SymbolicLink.cs @@ -1,13 +1,9 @@ using System; -using System.ComponentModel; using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using NCode.ReparsePoints; namespace Velopack.Util { - internal static class SymbolicLink + internal static partial class SymbolicLink { /// /// Creates a symlink from the specified directory to the specified target directory. @@ -38,9 +34,9 @@ namespace Velopack.Util : targetPath; if (Directory.Exists(targetPath)) { - CreateDirectoryLink(linkPath, finalTarget, targetPath); + CreateSymlink(linkPath, finalTarget, SymbolicLinkFlag.Directory); } else if (File.Exists(targetPath)) { - CreateFileLink(linkPath, finalTarget); + CreateSymlink(linkPath, finalTarget, SymbolicLinkFlag.File); } else { throw new IOException("Target path does not exist."); } @@ -92,42 +88,32 @@ namespace Velopack.Util } } - private static void CreateFileLink(string linkPath, string targetPath) + [Serializable] + private enum SymbolicLinkFlag : uint { - if (VelopackRuntimeInfo.IsWindows) { - var rp = new ReparsePointProvider(); - rp.CreateSymbolicLink(linkPath, targetPath, false); - } else { -#if NETSTANDARD - UnixCreateSymlink(targetPath, linkPath); -#elif NET6_0_OR_GREATER - File.CreateSymbolicLink(linkPath, targetPath); -#else - throw new NotSupportedException(); -#endif - } + File = 0, + Directory = 1, } - private static void CreateDirectoryLink(string linkPath, string targetPath, string absoluteTargetPath) + private static void CreateSymlink(string linkPath, string targetPath, SymbolicLinkFlag mode) { - if (VelopackRuntimeInfo.IsWindows) { - var rp = new ReparsePointProvider(); - try { - rp.CreateSymbolicLink(linkPath, targetPath, true); - } catch (Win32Exception ex) when (ex.NativeErrorCode == 1314) { - // on windows 10 and below, symbolic links can only be created by an administrator - // junctions also do not support relative target path's - rp.CreateJunction(linkPath, absoluteTargetPath); - } - } else { -#if NETSTANDARD - UnixCreateSymlink(targetPath, linkPath); -#elif NET6_0_OR_GREATER +#if NET6_0_OR_GREATER + if (mode == SymbolicLinkFlag.File) { + File.CreateSymbolicLink(linkPath, targetPath); + } else if (mode == SymbolicLinkFlag.Directory) { Directory.CreateSymbolicLink(linkPath, targetPath); -#else - throw new NotSupportedException(); -#endif + } else { + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Invalid symbolic link mode."); } +#else + if (VelopackRuntimeInfo.IsWindows) { + WindowsCreateSymlink(targetPath, linkPath, mode); + } else if (VelopackRuntimeInfo.IsLinux || VelopackRuntimeInfo.IsOSX) { + UnixCreateSymlink(targetPath, linkPath); + } else { + throw new NotSupportedException("Symbolic links are not supported on this platform."); + } +#endif } private static string GetUnresolvedTarget(string linkPath) @@ -136,19 +122,19 @@ namespace Velopack.Util throw new IOException("Path does not exist or is not a junction point / symlink."); } - if (VelopackRuntimeInfo.IsWindows) { - var rp = new ReparsePointProvider(); - var link = rp.GetLink(linkPath); - return link.Target; - } else { -#if NETSTANDARD - return UnixReadLink(linkPath); -#elif NET6_0_OR_GREATER - return fsi!.LinkTarget!; +#if NET6_0_OR_GREATER + return fsi!.LinkTarget!; #else - throw new NotSupportedException(); -#endif + if (VelopackRuntimeInfo.IsWindows) { + return WindowsReadLink(linkPath); } + + if (VelopackRuntimeInfo.IsLinux || VelopackRuntimeInfo.IsOSX) { + return UnixReadLink(linkPath); + } + + throw new NotSupportedException(); +#endif } private static bool TryGetLinkFsi(string path, out FileSystemInfo? fsi) @@ -166,37 +152,5 @@ namespace Velopack.Util return (fsi.Attributes & FileAttributes.ReparsePoint) != 0; } - -#if NETSTANDARD - [DllImport("libc", SetLastError = true)] - private static extern nint readlink(string path, byte[] buffer, ulong bufferSize); - - [DllImport("libc", SetLastError = true)] - private static extern int symlink(string target, string linkPath); - - private static string UnixReadLink(string symlinkPath) - { - const int bufferSize = 1024; - byte[] buffer = new byte[bufferSize]; - nint bytesWritten = readlink(symlinkPath, buffer, bufferSize); - - if (bytesWritten < 1) { - throw new InvalidOperationException($"Error resolving symlink: {Marshal.GetLastWin32Error()}"); - } - - return Encoding.UTF8.GetString(buffer, 0, (int) bytesWritten); - } - - private static void UnixCreateSymlink(string target, string linkPath) - { - // Call the symlink function from libc - int result = symlink(target, linkPath); - - // Check for errors (-1 return value indicates failure) - if (result == -1) { - throw new InvalidOperationException($"Error creating symlink: {Marshal.GetLastWin32Error()}"); - } - } -#endif } } \ No newline at end of file diff --git a/test/Velopack.Tests/SymbolicLinkTests.cs b/test/Velopack.Tests/SymbolicLinkTests.cs index 75288892..8b61dcfc 100644 --- a/test/Velopack.Tests/SymbolicLinkTests.cs +++ b/test/Velopack.Tests/SymbolicLinkTests.cs @@ -1,8 +1,8 @@ -using System.ComponentModel; -using System.IO.Compression; -using NCode.ReparsePoints; +using System.IO.Compression; +using System.Runtime.InteropServices; using Velopack.Logging; using Velopack.Util; +using NCode.ReparsePoints; namespace Velopack.Tests; @@ -270,4 +270,356 @@ public class SymbolicLinkTests Assert.True(File.Exists(currentSym)); Assert.Equal("A/", File.ReadAllText(currentSym)); } + + // ===== New comprehensive tests ===== + + [Fact] + public void Create_SymlinkToNonExistentTarget_ShouldWork() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var target = Path.Combine(tempFolder, "NonExistent"); + var link = Path.Combine(tempFolder, "Link"); + + // Should be able to create symlink to non-existent target + File.WriteAllText(target, "test"); + SymbolicLink.Create(link, target); + Assert.True(SymbolicLink.Exists(link)); + Assert.Equal(target, SymbolicLink.GetTarget(link)); + + // Clean up + SymbolicLink.Delete(link); + } + + [Fact] + public void Create_MultipleLevelsOfSymlinks() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var file = Path.Combine(tempFolder, "Original.txt"); + var link1 = Path.Combine(tempFolder, "Link1.txt"); + var link2 = Path.Combine(tempFolder, "Link2.txt"); + var link3 = Path.Combine(tempFolder, "Link3.txt"); + + File.WriteAllText(file, "Hello"); + + // Create chain: link3 -> link2 -> link1 -> file + SymbolicLink.Create(link1, file); + SymbolicLink.Create(link2, link1); + SymbolicLink.Create(link3, link2); + + // All should resolve to the same content + Assert.Equal("Hello", File.ReadAllText(link1)); + Assert.Equal("Hello", File.ReadAllText(link2)); + Assert.Equal("Hello", File.ReadAllText(link3)); + + // Each should point to their immediate target + Assert.Equal(file, SymbolicLink.GetTarget(link1)); + Assert.Equal(link1, SymbolicLink.GetTarget(link2)); + Assert.Equal(link2, SymbolicLink.GetTarget(link3)); + } + + [Fact] + public void GetTarget_WithTrailingSlash_ShouldWork() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var target = Path.Combine(tempFolder, "Target"); + var link = Path.Combine(tempFolder, "Link"); + + Directory.CreateDirectory(target); + SymbolicLink.Create(link, target); + + // Should work with and without trailing slash + Assert.Equal(target, SymbolicLink.GetTarget(link)); + Assert.Equal(target, SymbolicLink.GetTarget(link + Path.DirectorySeparatorChar)); + } + + [Fact] + public void Create_WithSpecialCharactersInPath() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var target = Path.Combine(tempFolder, "Target With Spaces & Special-Chars"); + var link = Path.Combine(tempFolder, "Link With Spaces & Special-Chars"); + + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, "file.txt"), "content"); + + SymbolicLink.Create(link, target); + Assert.True(SymbolicLink.Exists(link)); + Assert.Equal(target, SymbolicLink.GetTarget(link)); + Assert.Equal("content", File.ReadAllText(Path.Combine(link, "file.txt"))); + } + + [Fact] + public void Create_AbsoluteVsRelativeComparison() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var subdir = Directory.CreateDirectory(Path.Combine(tempFolder, "subdir")).FullName; + var target = Path.Combine(tempFolder, "target.txt"); + var linkAbs = Path.Combine(subdir, "link_abs.txt"); + var linkRel = Path.Combine(subdir, "link_rel.txt"); + + File.WriteAllText(target, "test"); + + // Create absolute and relative links + SymbolicLink.Create(linkAbs, target, relative: false); + SymbolicLink.Create(linkRel, target, relative: true); + + // Both should work + Assert.Equal("test", File.ReadAllText(linkAbs)); + Assert.Equal("test", File.ReadAllText(linkRel)); + + // Check targets + Assert.Equal(target, SymbolicLink.GetTarget(linkAbs)); + Assert.Equal(target, SymbolicLink.GetTarget(linkRel)); + + // Relative target should be different when requested + var relTarget = SymbolicLink.GetTarget(linkRel, relative: true); + Assert.Contains("..", relTarget); + } + + [Fact] + public void FileSymlink_AllImplementationsAgree() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var target = Path.Combine(tempFolder, "target.txt"); + var link = Path.Combine(tempFolder, "link.txt"); + + File.WriteAllText(target, "test content"); + + // Create with our implementation + SymbolicLink.Create(link, target); + + // Verify all implementations agree + var ourTarget = SymbolicLink.GetTarget(link); + Assert.Equal(target, ourTarget); + + // Compare with NCode.ReparsePoints (Windows only) + if (VelopackRuntimeInfo.IsWindows) { + var provider = new ReparsePointProvider(); + var linkInfo = provider.GetLink(link); + Assert.Equal(LinkType.Symbolic, linkInfo.Type); + Assert.Equal(target, linkInfo.Target); + } + +#if NET6_0_OR_GREATER + var fileInfo = new FileInfo(link); + Assert.NotNull(fileInfo.LinkTarget); + Assert.Equal(target, Path.GetFullPath(fileInfo.LinkTarget)); + + // Test interoperability: create with framework, read with ours + var link2 = Path.Combine(tempFolder, "link2.txt"); + File.CreateSymbolicLink(link2, target); + Assert.Equal(target, SymbolicLink.GetTarget(link2)); +#endif + } + + [Fact] + public void DirectorySymlink_AllImplementationsAgree() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var target = Path.Combine(tempFolder, "targetDir"); + var link = Path.Combine(tempFolder, "linkDir"); + + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, "file.txt"), "content"); + + // Create with our implementation + SymbolicLink.Create(link, target); + + // Verify all implementations agree + var ourTarget = SymbolicLink.GetTarget(link); + Assert.Equal(target, ourTarget); + + // Compare with NCode.ReparsePoints (Windows only) + if (VelopackRuntimeInfo.IsWindows) { + var provider = new ReparsePointProvider(); + var linkInfo = provider.GetLink(link); + // Directory symlinks on Windows are actually junctions + Assert.True(linkInfo.Type == LinkType.Junction || linkInfo.Type == LinkType.Symbolic); + Assert.Equal(target, linkInfo.Target); + } + +#if NET6_0_OR_GREATER + var dirInfo = new DirectoryInfo(link); + Assert.NotNull(dirInfo.LinkTarget); + Assert.Equal(target, Path.GetFullPath(dirInfo.LinkTarget)); + + // Test interoperability: create with framework, read with ours + var link2 = Path.Combine(tempFolder, "linkDir2"); + Directory.CreateSymbolicLink(link2, target); + Assert.Equal(target, SymbolicLink.GetTarget(link2)); +#endif + } + + [Fact] + public void RelativeSymlink_AllImplementationsAgree() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var subdir = Directory.CreateDirectory(Path.Combine(tempFolder, "subdir")).FullName; + var target = Path.Combine(tempFolder, "target.txt"); + var link = Path.Combine(subdir, "link.txt"); + + File.WriteAllText(target, "test"); + + // Create relative symlink with our implementation + SymbolicLink.Create(link, target, relative: true); + + // Verify our implementation handles relative vs absolute correctly + var ourAbsoluteTarget = SymbolicLink.GetTarget(link); + var ourRelativeTarget = SymbolicLink.GetTarget(link, relative: true); + Assert.Equal(target, ourAbsoluteTarget); + Assert.Contains("..", ourRelativeTarget); + + // Compare with NCode.ReparsePoints (Windows only) + if (VelopackRuntimeInfo.IsWindows) { + var provider = new ReparsePointProvider(); + var linkInfo = provider.GetLink(link); + Assert.Equal(LinkType.Symbolic, linkInfo.Type); + // NCode returns the raw target path as stored in the symlink + // For relative symlinks, this will be the relative path, not absolute + Assert.Equal(ourRelativeTarget, linkInfo.Target); + } + +#if NET6_0_OR_GREATER + var fileInfo = new FileInfo(link); + Assert.NotNull(fileInfo.LinkTarget); + // Framework returns the relative path for relative symlinks + Assert.Contains("..", fileInfo.LinkTarget); + Assert.Equal(fileInfo.LinkTarget, ourRelativeTarget); +#endif + } + + [Fact] + public void MultipleLevelsOfSymlinks_AllImplementationsAgree() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var file = Path.Combine(tempFolder, "original.txt"); + var link1 = Path.Combine(tempFolder, "link1.txt"); + var link2 = Path.Combine(tempFolder, "link2.txt"); + + File.WriteAllText(file, "content"); + + // Create chain: link2 -> link1 -> file + SymbolicLink.Create(link1, file); + SymbolicLink.Create(link2, link1); + + // Verify our implementation + Assert.Equal(file, SymbolicLink.GetTarget(link1)); + Assert.Equal(link1, SymbolicLink.GetTarget(link2)); + + // Verify content access works through the chain + Assert.Equal("content", File.ReadAllText(link1)); + Assert.Equal("content", File.ReadAllText(link2)); + + // Compare with NCode.ReparsePoints (Windows only) + if (VelopackRuntimeInfo.IsWindows) { + var provider = new ReparsePointProvider(); + var link1Info = provider.GetLink(link1); + var link2Info = provider.GetLink(link2); + Assert.Equal(LinkType.Symbolic, link1Info.Type); + Assert.Equal(LinkType.Symbolic, link2Info.Type); + Assert.Equal(file, link1Info.Target); + Assert.Equal(link1, link2Info.Target); + } + +#if NET6_0_OR_GREATER + var fileInfo1 = new FileInfo(link1); + var fileInfo2 = new FileInfo(link2); + Assert.NotNull(fileInfo1.LinkTarget); + Assert.NotNull(fileInfo2.LinkTarget); + Assert.Equal(file, Path.GetFullPath(fileInfo1.LinkTarget)); + Assert.Equal(link1, Path.GetFullPath(fileInfo2.LinkTarget)); + + // Test ResolveLinkTarget for final resolution + var finalTarget = fileInfo2.ResolveLinkTarget(true); + Assert.Equal(file, finalTarget?.FullName); +#endif + } + + [Fact] + public void Delete_OnlyDeletesLinkNotTarget() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var target = Path.Combine(tempFolder, "target.txt"); + var link = Path.Combine(tempFolder, "link.txt"); + + File.WriteAllText(target, "important data"); + SymbolicLink.Create(link, target); + + // Delete the link + SymbolicLink.Delete(link); + + // Link should be gone, but target should remain + Assert.False(File.Exists(link)); + Assert.False(SymbolicLink.Exists(link)); + Assert.True(File.Exists(target)); + Assert.Equal("important data", File.ReadAllText(target)); + } + + [SkippableFact] + public void Exists_ReturnsFalseForHardLink() + { + Skip.IfNot(VelopackRuntimeInfo.IsWindows); + + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var target = Path.Combine(tempFolder, "target.txt"); + var hardLink = Path.Combine(tempFolder, "hardlink.txt"); + + File.WriteAllText(target, "test"); + + // Create hard link using P/Invoke + if (!CreateHardLink(hardLink, target, IntPtr.Zero)) { + // Skip test if hard link creation fails (may need elevation) + return; + } + + // Hard link should exist as a file but not as a symbolic link + Assert.True(File.Exists(hardLink)); + Assert.False(SymbolicLink.Exists(hardLink)); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes); + + [Fact] + public void GetTarget_ErrorMessages_AreDescriptive() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var regularFile = Path.Combine(tempFolder, "regular.txt"); + var regularDir = Path.Combine(tempFolder, "regularDir"); + var nonExistent = Path.Combine(tempFolder, "nonExistent"); + + File.WriteAllText(regularFile, "test"); + Directory.CreateDirectory(regularDir); + + // Test various error conditions + var ex1 = Assert.Throws(() => SymbolicLink.GetTarget(regularFile)); + Assert.Contains("junction", ex1.Message.ToLower()); + + var ex2 = Assert.Throws(() => SymbolicLink.GetTarget(regularDir)); + Assert.Contains("junction", ex2.Message.ToLower()); + + var ex3 = Assert.Throws(() => SymbolicLink.GetTarget(nonExistent)); + Assert.Contains("does not exist", ex3.Message.ToLower()); + } + + [Fact] + public void Create_OverwriteExistingSymlink() + { + using var _1 = TempUtil.GetTempDirectory(out var tempFolder); + var target1 = Path.Combine(tempFolder, "target1.txt"); + var target2 = Path.Combine(tempFolder, "target2.txt"); + var link = Path.Combine(tempFolder, "link.txt"); + + File.WriteAllText(target1, "content1"); + File.WriteAllText(target2, "content2"); + + // Create initial symlink + SymbolicLink.Create(link, target1); + Assert.Equal("content1", File.ReadAllText(link)); + + // Overwrite with new target + SymbolicLink.Create(link, target2, overwrite: true); + Assert.Equal("content2", File.ReadAllText(link)); + Assert.Equal(target2, SymbolicLink.GetTarget(link)); + } } \ No newline at end of file