Merge branch 'develop' into kdb/msi-wip

This commit is contained in:
Kevin Bost
2025-09-15 21:08:21 -07:00
21 changed files with 703 additions and 87 deletions

View File

@@ -50,3 +50,21 @@ I've used a lot of installer frameworks and Velopack is by far the best. Everyth
I'm extremely impressed with Velopack's performance in creating releases, as well as checking for and applying updates. It is significantly faster than other tools. The vpk CLI is intuitive and easy to implement, even with my complex build pipeline. Thanks to Velopack, I've been able to streamline my workflow and save valuable time. It's a fantastic tool that I highly recommend!
[- khdc (Discord)](https://discord.com/channels/767856501477343282/947444323765583913/1216460920696344576)
## Sponsors
Huge thanks to everyone who provides the project with free tools, services, or donations!
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://velopack.github.io/velopack.sponsorkit/sponsors-white.svg">
<img alt="Sponsors Cloud" src="https://velopack.github.io/velopack.sponsorkit/sponsors-black.svg">
</picture>
<span>Free tools provided by<br/></span>
<a href="https://jb.gg/OpenSource">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://www.jetbrains.com/company/brand/img/logo_jb_dos_3.svg">
<img alt="Jetbrains Logo" src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg" width="160">
</picture>
</a>
</div>

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,10 @@
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
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 +35,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.");
}
@@ -63,7 +60,7 @@ namespace Velopack.Util
{
var isLink = TryGetLinkFsi(linkPath, out var fsi);
if (fsi != null && !isLink) {
throw new IOException("Path is not a junction point / symlink.");
ThrowPathNotASymlinkException(linkPath);
} else {
fsi?.Delete();
}
@@ -77,6 +74,7 @@ namespace Velopack.Util
public static string GetTarget(string linkPath, bool relative = false)
{
var target = GetUnresolvedTarget(linkPath);
if (relative) {
if (Path.IsPathRooted(target)) {
return PathUtil.MakePathRelativeTo(Path.GetDirectoryName(linkPath)!, target);
@@ -92,63 +90,62 @@ 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
linkPath = linkPath.TrimEnd('\\', '/');
#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)
{
linkPath = linkPath.TrimEnd('\\', '/');
if (!TryGetLinkFsi(linkPath, out var fsi)) {
throw new IOException("Path does not exist or is not a junction point / symlink.");
ThrowPathNotASymlinkException(linkPath);
}
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!;
string target;
#if NET6_0_OR_GREATER
target = fsi!.LinkTarget!;
#else
throw new NotSupportedException();
#endif
if (VelopackRuntimeInfo.IsWindows) {
target = WindowsReadLink(linkPath);
} else if (VelopackRuntimeInfo.IsLinux || VelopackRuntimeInfo.IsOSX) {
target = UnixReadLink(linkPath);
} else {
throw new NotSupportedException("Symbolic links are not supported on this platform.");
}
#endif
if (String.IsNullOrEmpty(target)) {
ThrowPathNotASymlinkException(linkPath);
}
return target;
}
private static bool TryGetLinkFsi(string path, out FileSystemInfo? fsi)
@@ -167,36 +164,12 @@ 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()}");
}
}
#if NET6_0_OR_GREATER
[DoesNotReturn]
#endif
private static void ThrowPathNotASymlinkException(string path)
{
throw new IOException($"The path '{path}' is not a symbolic link or junction point.");
}
}
}

View File

@@ -51,7 +51,7 @@ pub fn load_bundle_from_file<'a, P: AsRef<Path>>(file_name: P) -> Result<BundleZ
})
}
pub fn load_bundle_from_memory(zip_range: &[u8]) -> Result<BundleZip<'_>, Error> {
pub fn load_bundle_from_memory<'a>(zip_range: &'a [u8]) -> Result<BundleZip<'a>, Error> {
info!("Loading bundle from embedded zip...");
let cursor: Box<dyn ReadSeek> = Box::new(Cursor::new(zip_range));
let zip = ZipArchive::new(cursor)?;

View File

@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.1.3" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.24.0" />
<PackageReference Include="Gitea.Net.API" Version="25.3.5" />
<PackageReference Include="Gitea.Net.API" Version="25.8.18" />
<PackageReference Include="Octokit" Version="14.0.0" />
<PackageReference Include="RestSharp" Version="112.1.0" />
</ItemGroup>

View File

@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.10" />
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.11" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Microsoft.Extensions.Logging;
using Velopack.Core;
using Velopack.Core.Abstractions;
@@ -56,6 +56,12 @@ public class OsxPackCommandRunner : PackageBuilder<OsxPackOptions>
}
}
// Files in the MacOS directory need to be signed, but text files are signed via xattrs, which we don't yet preserve
// in nupkg releases. Instead we can put it in the Resources dir and symlink to it. Symlinks don't need to be signed.
var resourcesdir = structure.ResourcesDirectory;
File.WriteAllText(Path.Combine(resourcesdir, "sq.version"), GenerateNuspecContent());
SymbolicLink.Create(Path.Combine(macosdir, "sq.version"), Path.Combine(resourcesdir, "sq.version"), false, true);
progress(100);
return Task.FromResult(dir.FullName);
}

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("junction", ex2.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));
}
}