New symbolic link implementation for legacy windows

This commit is contained in:
Caelan Sayler
2025-08-16 16:40:58 +01:00
committed by Caelan
parent efa9d296b0
commit 031bd9b63a
4 changed files with 656 additions and 83 deletions

View File

@@ -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()}");
}
}
}
}

View File

@@ -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<ReparseHeader>(buffer);
var reparseHeaderSize = Marshal.SizeOf<ReparseHeader>();
// 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<SymbolicData>(buffer + reparseHeaderSize);
var offset = Marshal.SizeOf<SymbolicData>() + 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<JunctionData>(buffer + reparseHeaderSize);
var offset = Marshal.SizeOf<JunctionData>() + 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());
}
}
}
}

View File

@@ -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
{
/// <summary>
/// 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
}
}

View File

@@ -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<IOException>(() => SymbolicLink.GetTarget(regularFile));
Assert.Contains("junction", ex1.Message.ToLower());
var ex2 = Assert.Throws<IOException>(() => SymbolicLink.GetTarget(regularDir));
Assert.Contains("junction", ex2.Message.ToLower());
var ex3 = Assert.Throws<IOException>(() => 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));
}
}