Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a9685f9
Add File hard link API and tests
SadPencil Sep 24, 2025
8d8b8dd
Update CompatibilitySuppressions.xml to suppress APICompat
SadPencil Sep 24, 2025
e2dc032
Have to include unrelated suppressions as well
SadPencil Sep 24, 2025
eb72e2a
Revert "Have to include unrelated suppressions as well"
SadPencil Sep 24, 2025
a1d1196
Revert "Update CompatibilitySuppressions.xml to suppress APICompat"
SadPencil Sep 24, 2025
944df06
Correct the expected exception in the hardlink test
SadPencil Sep 24, 2025
aa42eb4
Add CreateHardLink to ref
SadPencil Sep 24, 2025
5536a86
Address incorrect comment
SadPencil Sep 24, 2025
941730d
Reorder code blocks
SadPencil Sep 25, 2025
5029136
Merge branch 'main' into feat-io-hardlink
SadPencil Sep 25, 2025
ea28a89
Slightly update the hard link test
SadPencil Sep 25, 2025
613d944
Add mount helper assert to the base hard link test class
SadPencil Sep 25, 2025
1ad09a8
Merge branch 'main' into feat-io-hardlink
SadPencil Sep 25, 2025
c43c1c1
Apply suggestion from @jozkee
SadPencil Oct 11, 2025
12ab897
Apply suggestion from @jozkee
SadPencil Oct 11, 2025
df34da9
Apply suggestion from @jozkee
SadPencil Oct 11, 2025
8b1bdbb
Apply suggestion from @jozkee
SadPencil Oct 11, 2025
f374e8c
Add CreateHardLink_LinkPathAlreadyExists_Throws test
SadPencil Oct 11, 2025
a175919
Move CreateAsHardLink method from FileSystemInfo to FileInfo
SadPencil Oct 11, 2025
b70714f
Remove GetFileSystemInfo method in FileInfo_HardLinks test
SadPencil Oct 11, 2025
2e39f9b
Revert "Apply suggestion from @jozkee"
SadPencil Oct 11, 2025
eb318f3
Sort the CreateAsHardLink method in the ref file of System.Runtime
SadPencil Oct 11, 2025
d6863ec
Merge branch 'main' into feat-io-hardlink
SadPencil Oct 11, 2025
cdd3091
Use File.CreateHardLink() in System.Formats.Tar(.Tests)
SadPencil Oct 20, 2025
064f980
Apply suggestion from @adamsitnik
SadPencil Oct 20, 2025
4705f9f
Apply suggestion from @adamsitnik
SadPencil Oct 20, 2025
3bd8f3b
Merge branch 'main' into feat-io-hardlink
SadPencil Oct 20, 2025
b035c58
Remove unused includes in System.Formats.Tar(.Tests)
SadPencil Oct 20, 2025
eb968ac
Remove another unused include in System.Formats.Tar.Tests
SadPencil Oct 20, 2025
ce6145f
Remove the unwanted empty line in BaseHardLinks_FileSystem
SadPencil Oct 20, 2025
1eeb5f9
Add clean up for test files in HardLinks tests
SadPencil Oct 20, 2025
9c83786
Add another clean up for test files in HardLinks tests
SadPencil Oct 20, 2025
7b243d6
Format BaseHardLinks.FileSystem.cs files
SadPencil Oct 20, 2025
9ab3bae
Remove the conditional attribute for the child classes of BaseHardLin…
SadPencil Oct 20, 2025
e09869a
Remove the conditional attribute for the child classes of BaseSymboli…
SadPencil Oct 20, 2025
baf0ba2
Apply the same style change for the symlink
SadPencil Oct 20, 2025
3736ecf
Merge branch 'main' into feat-io-hardlink
SadPencil Oct 20, 2025
48cff98
Revert some attribute changes
SadPencil Oct 20, 2025
c14ad79
Revert the unnecessary file clean up for the hard link tests
SadPencil Oct 21, 2025
fc49998
Add FileNotFoundException to the XML document of hard link methods
SadPencil Oct 21, 2025
6703cdd
Merge branch 'main' into feat-io-hardlink
SadPencil Oct 21, 2025
051dfd0
Merge branch 'main' into feat-io-hardlink
SadPencil Oct 21, 2025
56cfa93
Add test CreateHardLink_RelativePath()
SadPencil Oct 21, 2025
80347bb
Merge branch 'feat-io-hardlink' of https://github.com/SadPencil/runti…
SadPencil Oct 21, 2025
44c1e26
Remove incorrect XML doc for CreateAsHardLink
SadPencil Oct 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions src/libraries/Common/tests/System/IO/ReparsePointUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ public static partial class MountHelper
// Helper for ConditionalClass attributes
internal static bool IsSubstAvailable => PlatformDetection.IsSubstAvailable;

/// <summary>
/// Verifies that hard link creation is supported on the file system.
/// </summary>
internal static bool CanCreateHardLinks => s_canCreateHardLinks.Value;

private static readonly Lazy<bool> s_canCreateHardLinks = new Lazy<bool>(() =>
{
bool success = true;

// Verify file hard link creation
string path = Path.GetTempFileName();
string linkPath = path + ".link";
success = CreateHardLink(linkPath: linkPath, targetPath: path);
try { File.Delete(path); } catch { }
try { File.Delete(linkPath); } catch { }

return success;
});

/// <summary>
/// In some cases (such as when running without elevated privileges),
/// the symbolic link may fail to create. Only run this test if it creates
Expand Down Expand Up @@ -65,6 +84,46 @@ public static partial class MountHelper
return success;
});

/// <summary>Creates a hard link using command line tools.</summary>
public static bool CreateHardLink(string linkPath, string targetPath)
{
// It's easy to get the parameters backwards.
Assert.EndsWith(".link", linkPath);
if (linkPath != targetPath) // testing loop
Assert.False(targetPath.EndsWith(".link"), $"{targetPath} should not end with .link");

#if NETFRAMEWORK
bool isWindows = true;
#else
if (!IsProcessStartSupported())
{
return false;
}

bool isWindows = OperatingSystem.IsWindows();
#endif

using Process hardLinkProcess = new Process();
if (isWindows)
{
hardLinkProcess.StartInfo.FileName = "cmd";
hardLinkProcess.StartInfo.Arguments = string.Format("/c mklink /H \"{0}\" \"{1}\"", linkPath, targetPath);
}
else
{
hardLinkProcess.StartInfo.FileName = "/bin/ln";
hardLinkProcess.StartInfo.Arguments = string.Format("\"{0}\" \"{1}\"", targetPath, linkPath);
}
hardLinkProcess.StartInfo.UseShellExecute = false;
hardLinkProcess.StartInfo.RedirectStandardOutput = true;

hardLinkProcess.Start();

hardLinkProcess.WaitForExit();

return hardLinkProcess.ExitCode == 0;
}

/// <summary>Creates a symbolic link using command line tools.</summary>
public static bool CreateSymbolicLink(string linkPath, string targetPath, bool isDirectory)
{
Expand All @@ -76,13 +135,14 @@ public static bool CreateSymbolicLink(string linkPath, string targetPath, bool i
#if NETFRAMEWORK
bool isWindows = true;
#else
if (OperatingSystem.IsIOS() || OperatingSystem.IsTvOS() || OperatingSystem.IsMacCatalyst() || OperatingSystem.IsBrowser() || OperatingSystem.IsWasi()) // OSes that don't support Process.Start()
if (!IsProcessStartSupported())
{
return false;
}

bool isWindows = OperatingSystem.IsWindows();
#endif

using Process symLinkProcess = new Process();
if (isWindows)
{
Expand All @@ -101,7 +161,16 @@ public static bool CreateSymbolicLink(string linkPath, string targetPath, bool i

symLinkProcess.WaitForExit();

return (symLinkProcess.ExitCode == 0);
return symLinkProcess.ExitCode == 0;
}

private static bool IsProcessStartSupported()
{
#if NETFRAMEWORK
return true;
#else
return !(OperatingSystem.IsIOS() || OperatingSystem.IsTvOS() || OperatingSystem.IsMacCatalyst() || OperatingSystem.IsBrowser() || OperatingSystem.IsWasi()); // OSes that don't support Process.Start()
#endif
}

/// <summary>On Windows, creates a junction using command line tools.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,8 @@
<Compile Include="System\Formats\Tar\TarEntry.Windows.cs" />
<Compile Include="System\Formats\Tar\TarHelpers.Windows.cs" />
<Compile Include="System\Formats\Tar\TarWriter.Windows.cs" />
<Compile Include="$(CommonPath)Interop\Windows\Interop.Errors.cs" Link="Common\Interop\Windows\Interop.Errors.cs" />
<Compile Include="$(CommonPath)Interop\Windows\Interop.Libraries.cs" Link="Common\Interop\Windows\Interop.Libraries.cs" />
<Compile Include="$(CommonPath)Interop\Windows\Kernel32\Interop.CreateHardLink.cs" Link="Common\Interop\Windows\Kernel32\Interop.CreateHardLink.cs" />
<Compile Include="$(CommonPath)Interop\Windows\Kernel32\Interop.FormatMessage.cs" Link="Common\Interop\Windows\Kernel32\Interop.FormatMessage.cs" />
<Compile Include="$(CommonPath)System\IO\Archiving.Utils.Windows.cs" Link="Common\System\IO\Archiving.Utils.Windows.cs" />
<Compile Include="$(CommonPath)System\IO\PathInternal.Windows.cs" Link="Common\System\IO\PathInternal.Windows.cs" />
<Compile Include="$(CommonPath)System\IO\Win32Marshal.cs" Link="Common\System\IO\Win32Marshal.cs" />
</ItemGroup>

<!-- Unix specific files -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ private void ExtractAsHardLink(string targetFilePath, string hardLinkFilePath)
Debug.Assert(EntryType is TarEntryType.HardLink);
Debug.Assert(!string.IsNullOrEmpty(targetFilePath));
Debug.Assert(!string.IsNullOrEmpty(hardLinkFilePath));
Interop.CheckIo(Interop.Sys.Link(targetFilePath, hardLinkFilePath), hardLinkFilePath);
File.CreateHardLink(hardLinkFilePath, targetFilePath);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.IO;
using Microsoft.Win32.SafeHandles;

namespace System.Formats.Tar
Expand Down Expand Up @@ -37,7 +38,7 @@ private void ExtractAsHardLink(string targetFilePath, string hardLinkFilePath)
Debug.Assert(EntryType is TarEntryType.HardLink);
Debug.Assert(!string.IsNullOrEmpty(targetFilePath));
Debug.Assert(!string.IsNullOrEmpty(hardLinkFilePath));
Interop.Kernel32.CreateHardLink(hardLinkFilePath, targetFilePath);
File.CreateHardLink(hardLinkFilePath, targetFilePath);
}
#pragma warning restore IDE0060
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,7 @@
<Compile Include="TarFile\TarFile.ExtractToDirectory.File.Tests.Windows.cs" />
<Compile Include="TarFile\TarFile.ExtractToDirectoryAsync.File.Tests.Windows.cs" />
<Compile Include="TarWriter\TarWriter.File.Base.Windows.cs" />
<Compile Include="$(CommonPath)Interop\Windows\Interop.BOOL.cs" Link="Common\Interop\Windows\Interop.BOOL.cs" />
<Compile Include="$(CommonPath)Interop\Windows\Interop.Errors.cs" Link="Common\Interop\Windows\Interop.Errors.cs" />
<Compile Include="$(CommonPath)Interop\Windows\Interop.Libraries.cs" Link="Common\Interop\Windows\Interop.Libraries.cs" />
<Compile Include="$(CommonPath)Interop\Windows\Kernel32\Interop.CreateHardLink.cs" Link="Common\Interop\Windows\Kernel32\Interop.CreateHardLink.cs" />
<Compile Include="$(CommonPath)Interop\Windows\Kernel32\Interop.FormatMessage.cs" Link="Common\Interop\Windows\Kernel32\Interop.FormatMessage.cs" />
<Compile Include="$(CommonPath)System\IO\PathInternal.Windows.cs" Link="Common\System\IO\PathInternal.Windows.cs" />
<Compile Include="$(CommonPath)System\IO\Win32Marshal.cs" Link="Common\System\IO\Win32Marshal.cs" />
<Compile Include="$(CommonPath)System\Text\ValueStringBuilder.cs" Link="Common\System\Text\ValueStringBuilder.cs" />
</ItemGroup>
<!-- Unix specific files -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1816,6 +1816,9 @@
<Compile Include="$(CommonPath)Interop\Windows\Kernel32\Interop.CreateFile_IntPtr.cs">
<Link>Common\Interop\Windows\Kernel32\Interop.CreateFile_IntPtr.cs</Link>
</Compile>
<Compile Include="$(CommonPath)Interop\Windows\Kernel32\Interop.CreateHardLink.cs">
<Link>Common\Interop\Windows\Kernel32\Interop.CreateHardLink.cs</Link>
</Compile>
<Compile Include="$(CommonPath)Interop\Windows\Kernel32\Interop.CreateSymbolicLink.cs">
<Link>Common\Interop\Windows\Kernel32\Interop.CreateSymbolicLink.cs</Link>
</Compile>
Expand Down
23 changes: 23 additions & 0 deletions src/libraries/System.Private.CoreLib/src/System/IO/File.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1410,6 +1410,29 @@ public static Task AppendAllLinesAsync(string path, IEnumerable<string> contents
public static Task AppendAllLinesAsync(string path, IEnumerable<string> contents, Encoding encoding, CancellationToken cancellationToken = default) =>
WriteAllLinesAsync(path, contents, encoding, append: true, cancellationToken);

/// <summary>
/// Creates a hard link located in <paramref name="path"/> that refers to the same file content as <paramref name="pathToTarget"/>.
/// </summary>
/// <param name="path">The path where the hard link should be created.</param>
/// <param name="pathToTarget">The path of the hard link target.</param>
/// <returns>A <see cref="FileInfo"/> instance that wraps the newly created file.</returns>
/// <exception cref="ArgumentNullException"><paramref name="path"/> or <paramref name="pathToTarget"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="path"/> or <paramref name="pathToTarget"/> is empty.
/// -or-
/// <paramref name="path"/> or <paramref name="pathToTarget"/> contains a null character.</exception>
/// <exception cref="FileNotFoundException">The file specified by <paramref name="pathToTarget"/> does not exist.</exception>
/// <exception cref="IOException">A file or directory already exists in the location of <paramref name="path"/>.
/// -or-
/// An I/O error occurred.</exception>
public static FileSystemInfo CreateHardLink(string path, string pathToTarget)
{
string fullPath = Path.GetFullPath(path);
FileSystem.VerifyValidPath(pathToTarget, nameof(pathToTarget));

FileSystem.CreateHardLink(path, pathToTarget);
return new FileInfo(originalPath: path, fullPath: fullPath, isNormalized: true);
}

/// <summary>
/// Creates a file symbolic link identified by <paramref name="path"/> that points to <paramref name="pathToTarget"/>.
/// </summary>
Expand Down
18 changes: 18 additions & 0 deletions src/libraries/System.Private.CoreLib/src/System/IO/FileInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,5 +204,23 @@ private StreamWriter CreateStreamWriter(bool append)
Invalidate();
return streamWriter;
}

/// <summary>
/// Creates a hard link located in <see cref="Name"/> that refers to the same file content as <paramref name="pathToTarget"/>.
/// </summary>
/// <param name="pathToTarget">The path of the hard link target.</param>
/// <exception cref="ArgumentNullException"><paramref name="pathToTarget"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="pathToTarget"/> is empty.
/// -or-
/// <paramref name="pathToTarget"/> contains invalid path characters.</exception>
/// <exception cref="FileNotFoundException">The file specified by <paramref name="pathToTarget"/> does not exist.</exception>
/// <exception cref="IOException">A file or directory already exists in the location of <see cref="Name"/>.
/// -or-
/// An I/O error occurred.</exception>
public void CreateAsHardLink(string pathToTarget)
{
FileSystem.VerifyValidPath(pathToTarget, nameof(pathToTarget));
FileSystem.CreateHardLink(OriginalPath, pathToTarget);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,11 @@ internal static void CreateSymbolicLink(string path, string pathToTarget, bool i
}
#pragma warning restore IDE0060

internal static void CreateHardLink(string path, string pathToTarget)
{
Interop.CheckIo(Interop.Sys.Link(pathToTarget, path), path);
}

internal static FileSystemInfo? ResolveLinkTarget(string linkPath, bool returnFinalTarget, bool isDirectory)
{
ValueStringBuilder sb = new(Interop.DefaultPathBufferSize);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,11 @@ internal static void CreateSymbolicLink(string path, string pathToTarget, bool i
Interop.Kernel32.CreateSymbolicLink(path, pathToTarget, isDirectory);
}

internal static void CreateHardLink(string path, string pathToTarget)
{
Interop.Kernel32.CreateHardLink(path, pathToTarget);
}

internal static FileSystemInfo? ResolveLinkTarget(string linkPath, bool returnFinalTarget, bool isDirectory)
{
string? targetPath = returnFinalTarget ?
Expand Down
2 changes: 2 additions & 0 deletions src/libraries/System.Runtime/ref/System.Runtime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10292,6 +10292,7 @@ public static void Copy(string sourceFileName, string destFileName, bool overwri
public static System.IO.FileStream Create(string path) { throw null; }
public static System.IO.FileStream Create(string path, int bufferSize) { throw null; }
public static System.IO.FileStream Create(string path, int bufferSize, System.IO.FileOptions options) { throw null; }
public static System.IO.FileSystemInfo CreateHardLink(string path, string pathToTarget) { throw null; }
public static System.IO.FileSystemInfo CreateSymbolicLink(string path, string pathToTarget) { throw null; }
public static System.IO.StreamWriter CreateText(string path) { throw null; }
[System.Runtime.Versioning.SupportedOSPlatformAttribute("windows")]
Expand Down Expand Up @@ -10423,6 +10424,7 @@ public FileInfo(string fileName) { }
public System.IO.FileInfo CopyTo(string destFileName) { throw null; }
public System.IO.FileInfo CopyTo(string destFileName, bool overwrite) { throw null; }
public System.IO.FileStream Create() { throw null; }
public void CreateAsHardLink(string pathToTarget) { }
public System.IO.StreamWriter CreateText() { throw null; }
[System.Runtime.Versioning.SupportedOSPlatformAttribute("windows")]
public void Decrypt() { }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.IO;
using Xunit;

namespace System.IO.Tests
{
// Contains test methods that can be used for FileInfo or File.
[ConditionalClass(typeof(MountHelper), nameof(MountHelper.CanCreateHardLinks))]
public abstract class BaseHardLinks_FileSystem : FileSystemTest
{
public BaseHardLinks_FileSystem()
{
Assert.True(MountHelper.CanCreateHardLinks);
}

/// <summary>Creates a new file depending on the implementing class.</summary>
protected abstract void CreateFile(string path);

protected abstract void AssertLinkExists(FileSystemInfo linkInfo);

/// <summary>Calls the actual public API for creating a hard link.</summary>
protected abstract FileSystemInfo CreateHardLink(string path, string pathToTarget);

[Fact]
public void CreateHardLink_NullPathToTarget()
{
Assert.Throws<ArgumentNullException>(() => CreateHardLink(GetRandomFilePath(), pathToTarget: null));
}

[Theory]
[InlineData("")]
[InlineData("\0")]
public void CreateHardLink_InvalidPathToTarget(string pathToTarget)
{
Assert.Throws<ArgumentException>(() => CreateHardLink(GetRandomFilePath(), pathToTarget));
}

[Fact]
public void CreateHardLink_RelativePath()
{
string relativePathToTarget = GetRandomFileName();
string relativePath = GetRandomFileName();

CreateFile(relativePathToTarget);
CreateHardLink(relativePath, relativePathToTarget);
}

[Fact]
public void CreateHardLink_TargetDoesNotExist_Throws()
{
string linkPath = GetRandomFilePath();
string nonExistentTarget = GetRandomFilePath();
Assert.Throws<FileNotFoundException>(() => CreateHardLink(linkPath, nonExistentTarget));
}

[Fact]
public void CreateHardLink_LinkPathAlreadyExists_Throws()
{
string targetPath = GetRandomFilePath();
string linkPath = GetRandomFilePath();

CreateFile(targetPath);
CreateFile(linkPath);

Assert.Throws<IOException>(() => CreateHardLink(linkPath, targetPath));
}

[Fact]
public void CreateHardLink_TargetExists_Succeeds()
{
string targetPath = GetRandomFilePath();
string linkPath = GetRandomFilePath();

CreateFile(targetPath);

FileSystemInfo linkInfo = CreateHardLink(linkPath, targetPath);
AssertLinkExists(linkInfo);

// Both files should have the same content
Assert.Equal(File.ReadAllText(targetPath), File.ReadAllText(linkPath));
}

[Fact]
public void CreateHardLink_ModifyViaOneLink_VisibleViaOther()
{
string targetPath = GetRandomFilePath();
string linkPath = GetRandomFilePath();

File.WriteAllText(targetPath, "original");
CreateHardLink(linkPath, targetPath);

// Modify via link
File.WriteAllText(linkPath, "changed");

// Read via target
Assert.Equal("changed", File.ReadAllText(targetPath));

}

[Fact]
public void CreateHardLink_DeleteOneLink_FileStillAccessible()
{
string targetPath = GetRandomFilePath();
string linkPath = GetRandomFilePath();

File.WriteAllText(targetPath, "data");
CreateHardLink(linkPath, targetPath);

// Delete the original file
File.Delete(targetPath);

// The link should still exist and have the data
Assert.Equal("data", File.ReadAllText(linkPath));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace System.IO.Tests
{
// Contains helper methods that are shared by all symbolic link test classes.
[ConditionalClass(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))]
public abstract partial class BaseSymbolicLinks : FileSystemTest
{
public BaseSymbolicLinks()
Expand Down
Loading
Loading