mirror of
https://github.com/velopack/velopack.git
synced 2025-10-25 15:19:22 +00:00
New symbolic link implementation for legacy windows
This commit is contained in:
56
src/lib-csharp/Util/SymbolicLink.Unix.cs
Normal file
56
src/lib-csharp/Util/SymbolicLink.Unix.cs
Normal 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()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
211
src/lib-csharp/Util/SymbolicLink.Windows.cs
Normal file
211
src/lib-csharp/Util/SymbolicLink.Windows.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user