diff --git a/src/Layout/redist/targets/GenerateInstallerLayout.targets b/src/Layout/redist/targets/GenerateInstallerLayout.targets
index 069c6a2fde2a..046baf402339 100644
--- a/src/Layout/redist/targets/GenerateInstallerLayout.targets
+++ b/src/Layout/redist/targets/GenerateInstallerLayout.targets
@@ -95,7 +95,10 @@
LayoutDnxShim;
CrossgenLayout;
ReplaceBundledRuntimePackFilesWithSymbolicLinks"
- AfterTargets="AfterBuild" />
+ AfterTargets="AfterBuild">
+
+
+
diff --git a/src/Tasks/sdk-tasks/DeduplicateAssembliesWithLinks.cs b/src/Tasks/sdk-tasks/DeduplicateAssembliesWithLinks.cs
new file mode 100644
index 000000000000..09717e354e02
--- /dev/null
+++ b/src/Tasks/sdk-tasks/DeduplicateAssembliesWithLinks.cs
@@ -0,0 +1,182 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#if !NETFRAMEWORK
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Hashing;
+using System.Linq;
+
+namespace Microsoft.DotNet.Build.Tasks;
+
+///
+/// Deduplicates assemblies (.dll and .exe files) in a directory by replacing duplicates with links (hard or symbolic).
+/// Assemblies are grouped by content hash, and a deterministic "primary" file is selected (closest to root, alphabetically
+/// first) which duplicates are linked to. Text-based files (config, json, xml, etc.) are not deduplicated.
+///
+public sealed class DeduplicateAssembliesWithLinks : Task
+{
+ ///
+ /// The root directory to scan for duplicate assemblies.
+ ///
+ [Required]
+ public string LayoutDirectory { get; set; } = null!;
+
+ ///
+ /// If true, creates hard links. If false, creates symbolic links.
+ ///
+ public bool UseHardLinks { get; set; } = false;
+
+ private string LinkType => UseHardLinks ? "hard link" : "symbolic link";
+
+ public override bool Execute()
+ {
+ if (!Directory.Exists(LayoutDirectory))
+ {
+ Log.LogError($"LayoutDirectory '{LayoutDirectory}' does not exist.");
+ return false;
+ }
+
+ Log.LogMessage(MessageImportance.High, $"Scanning for duplicate assemblies in '{LayoutDirectory}' (using {LinkType}s)...");
+
+ // Only deduplicate assemblies - non-assembly files are small and offer minimal ROI.
+ // Some non-assembly files such as config files shouldn't be linked (may be edited).
+ var files = Directory.GetFiles(LayoutDirectory, "*", SearchOption.AllDirectories)
+ .Where(f => IsAssembly(f))
+ .ToList();
+
+ Log.LogMessage(MessageImportance.Normal, $"Found {files.Count} assemblies eligible for deduplication.");
+
+ var (filesByHash, hashingSuccess) = HashAndGroupFiles(files);
+ if (!hashingSuccess)
+ {
+ return false;
+ }
+
+ var duplicateGroups = filesByHash.Values.Where(g => g.Count > 1).ToList();
+ Log.LogMessage(MessageImportance.Normal, $"Found {duplicateGroups.Count} groups of duplicate assemblies.");
+ return DeduplicateFileGroups(duplicateGroups);
+ }
+
+ private (Dictionary> filesByHash, bool success) HashAndGroupFiles(List files)
+ {
+ var filesByHash = new Dictionary>();
+ bool hasErrors = false;
+
+ foreach (var filePath in files)
+ {
+ try
+ {
+ var fileInfo = new FileInfo(filePath);
+ var hash = ComputeFileHash(filePath);
+ var entry = new FileEntry(
+ filePath,
+ hash,
+ fileInfo.Length,
+ GetPathDepth(filePath, LayoutDirectory));
+
+ if (!filesByHash.ContainsKey(hash))
+ {
+ filesByHash[hash] = new List();
+ }
+
+ filesByHash[hash].Add(entry);
+ }
+ catch (Exception ex)
+ {
+ Log.LogError($"Failed to hash file '{filePath}': {ex.Message}");
+ hasErrors = true;
+ }
+ }
+
+ return (filesByHash, !hasErrors);
+ }
+
+ private bool DeduplicateFileGroups(List> duplicateGroups)
+ {
+ int totalFilesDeduped = 0;
+ long totalBytesSaved = 0;
+ bool hasErrors = false;
+
+ foreach (var group in duplicateGroups)
+ {
+ // Sort deterministically: by depth (ascending), then alphabetically (ordinal for reproducibility)
+ var sorted = group.OrderBy(f => f.Depth).ThenBy(f => f.Path, StringComparer.Ordinal).ToList();
+
+ // First file is the "primary" - all duplicates will link to it
+ var primary = sorted[0];
+ var duplicates = sorted.Skip(1).ToList();
+
+ foreach (var duplicate in duplicates)
+ {
+ try
+ {
+ CreateLink(duplicate.Path, primary.Path);
+ totalFilesDeduped++;
+ totalBytesSaved += duplicate.Size;
+ Log.LogMessage(MessageImportance.Low, $" Linked: {duplicate.Path} -> {primary.Path}");
+ }
+ catch (Exception ex)
+ {
+ Log.LogError($"Failed to create {LinkType} from '{duplicate.Path}' to '{primary.Path}': {ex.Message}");
+ hasErrors = true;
+ }
+ }
+ }
+
+ Log.LogMessage(MessageImportance.High,
+ $"Deduplication complete: {totalFilesDeduped} files replaced with {LinkType}s, saving {totalBytesSaved / (1024.0 * 1024.0):F2} MB.");
+
+ return !hasErrors;
+ }
+
+ private void CreateLink(string duplicateFilePath, string primaryFilePath)
+ {
+ // Delete the duplicate file before creating the link
+ File.Delete(duplicateFilePath);
+
+ if (UseHardLinks)
+ {
+ File.CreateHardLink(duplicateFilePath, primaryFilePath);
+ }
+ else
+ {
+ // Create relative symlink so it works when directory is moved/archived
+ var duplicateDirectory = Path.GetDirectoryName(duplicateFilePath)!;
+ var relativePath = Path.GetRelativePath(duplicateDirectory, primaryFilePath);
+ File.CreateSymbolicLink(duplicateFilePath, relativePath);
+ }
+ }
+
+ private static string ComputeFileHash(string filePath)
+ {
+ var xxHash = new XxHash64();
+ using var stream = File.OpenRead(filePath);
+
+ byte[] buffer = new byte[65536]; // 64KB buffer
+ int bytesRead;
+ while ((bytesRead = stream.Read(buffer)) > 0)
+ {
+ xxHash.Append(buffer[..bytesRead]);
+ }
+
+ return Convert.ToHexString(xxHash.GetCurrentHash());
+ }
+
+ private static int GetPathDepth(string filePath, string rootDirectory)
+ {
+ var relativePath = Path.GetRelativePath(rootDirectory, filePath);
+ return relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Length - 1;
+ }
+
+ private static bool IsAssembly(string filePath)
+ {
+ var extension = Path.GetExtension(filePath);
+ return extension.Equals(".dll", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".exe", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private record FileEntry(string Path, string Hash, long Size, int Depth);
+}
+#endif
diff --git a/src/Tasks/sdk-tasks/ReplaceFilesWithSymbolicLinks.cs b/src/Tasks/sdk-tasks/ReplaceFilesWithSymbolicLinks.cs
index d5cf136412f1..63711faf484d 100644
--- a/src/Tasks/sdk-tasks/ReplaceFilesWithSymbolicLinks.cs
+++ b/src/Tasks/sdk-tasks/ReplaceFilesWithSymbolicLinks.cs
@@ -17,7 +17,7 @@
namespace Microsoft.DotNet.Build.Tasks
{
///
- /// Replaces files that have the same content with hard links.
+ /// Replaces files that have the same content with symbolic links.
///
public sealed class ReplaceFilesWithSymbolicLinks : Task
{
diff --git a/src/Tasks/sdk-tasks/sdk-tasks.InTree.targets b/src/Tasks/sdk-tasks/sdk-tasks.InTree.targets
index 6bce610eb419..e7c318676b77 100644
--- a/src/Tasks/sdk-tasks/sdk-tasks.InTree.targets
+++ b/src/Tasks/sdk-tasks/sdk-tasks.InTree.targets
@@ -33,6 +33,11 @@
+
+
diff --git a/src/Tasks/sdk-tasks/sdk-tasks.csproj b/src/Tasks/sdk-tasks/sdk-tasks.csproj
index 11d957de167e..896d3ea68711 100644
--- a/src/Tasks/sdk-tasks/sdk-tasks.csproj
+++ b/src/Tasks/sdk-tasks/sdk-tasks.csproj
@@ -20,6 +20,7 @@
+
diff --git a/test/EndToEnd.Tests/GivenSdkArchives.cs b/test/EndToEnd.Tests/GivenSdkArchives.cs
new file mode 100644
index 000000000000..f2a495cd67b8
--- /dev/null
+++ b/test/EndToEnd.Tests/GivenSdkArchives.cs
@@ -0,0 +1,41 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Runtime.InteropServices;
+using EndToEnd.Tests.Utilities;
+
+namespace EndToEnd.Tests;
+
+public class GivenSdkArchives(ITestOutputHelper log) : SdkTest(log)
+{
+ [Fact]
+ public void ItHasDeduplicatedAssemblies()
+ {
+ // TODO: Windows is not supported yet - blocked on signing support (https://github.com/dotnet/sdk/issues/52182).
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return;
+ }
+
+ // Find and extract archive
+ string archivePath = TestContext.FindSdkAcquisitionArtifact("dotnet-sdk-*.tar.gz");
+ Log.WriteLine($"Found SDK archive: {Path.GetFileName(archivePath)}");
+ string extractedPath = ExtractArchive(archivePath);
+
+ // Verify deduplication worked by checking for symbolic links
+ SymbolicLinkHelpers.VerifyDirectoryHasRelativeSymlinks(extractedPath, Log, "archive");
+ }
+
+ private string ExtractArchive(string archivePath)
+ {
+ var testDir = TestAssetsManager.CreateTestDirectory();
+ string extractPath = Path.Combine(testDir.Path, "sdk-extracted");
+ Directory.CreateDirectory(extractPath);
+
+ Log.WriteLine($"Extracting archive to: {extractPath}");
+
+ SymbolicLinkHelpers.ExtractTarGz(archivePath, extractPath, Log);
+
+ return extractPath;
+ }
+}
diff --git a/test/EndToEnd.Tests/Utilities/SymbolicLinkHelpers.cs b/test/EndToEnd.Tests/Utilities/SymbolicLinkHelpers.cs
new file mode 100644
index 000000000000..03e04049d6e5
--- /dev/null
+++ b/test/EndToEnd.Tests/Utilities/SymbolicLinkHelpers.cs
@@ -0,0 +1,102 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace EndToEnd.Tests.Utilities;
+
+///
+/// Shared helpers for verifying symbolic links and extracting archives in tests.
+///
+internal static class SymbolicLinkHelpers
+{
+ ///
+ /// Minimum number of deduplicated links expected in an SDK layout.
+ /// Used by both symbolic link and hard link validation.
+ ///
+ public const int MinExpectedDeduplicatedLinks = 100;
+
+ ///
+ /// Extracts a tar.gz archive to a directory using system tar command.
+ /// Uses system tar for simplicity.
+ ///
+ /// Path to the .tar.gz file to extract.
+ /// Directory to extract files into.
+ /// Test output logger.
+ public static void ExtractTarGz(string tarGzPath, string destinationDirectory, ITestOutputHelper log)
+ {
+ new RunExeCommand(log, "tar")
+ .Execute("-xzf", tarGzPath, "-C", destinationDirectory)
+ .Should().Pass();
+ }
+
+ ///
+ /// Extracts an installer package to a temporary directory, verifies symbolic links, and cleans up.
+ ///
+ /// Path to the installer file.
+ /// Type of package (e.g., "deb", "rpm", "pkg") for logging.
+ /// Action that extracts the package contents to the provided temp directory.
+ /// Test output logger.
+ public static void VerifyPackageSymlinks(string installerFile, string packageType, Action extractPackage, ITestOutputHelper log)
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"{packageType}-test-{Guid.NewGuid()}");
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ extractPackage(tempDir);
+ VerifyDirectoryHasRelativeSymlinks(tempDir, log, $"{packageType} package");
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+ }
+
+ ///
+ /// Verifies that a directory contains >100 symbolic links and all use relative paths.
+ ///
+ /// The directory to check for symbolic links.
+ /// Test output logger.
+ /// Name of the context being tested (for error messages, e.g., "deb package", "archive").
+ public static void VerifyDirectoryHasRelativeSymlinks(string directory, ITestOutputHelper log, string contextName)
+ {
+ // Find all symbolic links in the directory
+ var findResult = new RunExeCommand(log, "find")
+ .WithWorkingDirectory(directory)
+ .Execute(".", "-type", "l");
+
+ findResult.Should().Pass();
+
+ var symlinkPaths = (findResult.StdOut ?? string.Empty)
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .ToList();
+
+ log.WriteLine($"Found {symlinkPaths.Count} symbolic links in {contextName}");
+
+ Assert.True(symlinkPaths.Count > MinExpectedDeduplicatedLinks,
+ $"Expected more than {MinExpectedDeduplicatedLinks} symbolic links in {contextName}, but found only {symlinkPaths.Count}. " +
+ "This suggests deduplication did not run correctly.");
+
+ // Verify all symlinks use relative paths (not absolute)
+ var absoluteSymlinks = new List();
+ foreach (var symlinkPath in symlinkPaths)
+ {
+ var fullPath = Path.Combine(directory, symlinkPath.TrimStart('.', '/'));
+ var readlinkResult = new RunExeCommand(log, "readlink")
+ .Execute(fullPath);
+
+ readlinkResult.Should().Pass();
+
+ var target = (readlinkResult.StdOut ?? string.Empty).Trim();
+ if (target.StartsWith("/"))
+ {
+ absoluteSymlinks.Add($"{symlinkPath} -> {target}");
+ }
+ }
+
+ Assert.Empty(absoluteSymlinks);
+ log.WriteLine($"Verified all {symlinkPaths.Count} symbolic links use relative paths");
+ }
+}
diff --git a/test/Microsoft.NET.TestFramework/TestContext.cs b/test/Microsoft.NET.TestFramework/TestContext.cs
index d6e73cacfd63..685cc2946a50 100644
--- a/test/Microsoft.NET.TestFramework/TestContext.cs
+++ b/test/Microsoft.NET.TestFramework/TestContext.cs
@@ -1,4 +1,4 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
@@ -57,6 +57,32 @@ public string TestAssetsDirectory
public string? NuGetExePath { get; set; }
+ public string? ShippingPackagesDirectory { get; set; }
+
+ ///
+ /// Finds a single SDK acquisition artifact (tar.gz, pkg, deb, rpm) matching the specified pattern
+ /// in the .
+ ///
+ /// The file pattern to search for (e.g., "dotnet-sdk-*.tar.gz").
+ /// The full path to the matching artifact.
+ public static string FindSdkAcquisitionArtifact(string filePattern)
+ {
+ string? shippingDir = Current.ShippingPackagesDirectory;
+ if (string.IsNullOrEmpty(shippingDir))
+ {
+ throw new InvalidOperationException("ShippingPackagesDirectory must be set in the current TestContext before calling FindSdkAcquisitionArtifact.");
+ }
+
+ var files = Directory.GetFiles(shippingDir, filePattern);
+ if (files.Length != 1)
+ {
+ throw new InvalidOperationException(
+ $"Expected exactly 1 file matching '{filePattern}' in {shippingDir}, but found {files.Length}.");
+ }
+
+ return files[0];
+ }
+
public string? SdkVersion { get; set; }
private ToolsetInfo? _toolsetUnderTest;
@@ -234,6 +260,7 @@ public static void Initialize(TestCommandLine commandLine)
testContext.NuGetCachePath = Path.Combine(artifactsDir, ".nuget", "packages");
testContext.TestPackages = Path.Combine(artifactsDir, "tmp", repoConfiguration, "testing", "testpackages");
+ testContext.ShippingPackagesDirectory = Path.Combine(artifactsDir, "packages", repoConfiguration, "Shipping");
}
else if (runAsTool)
{
@@ -263,6 +290,15 @@ public static void Initialize(TestCommandLine commandLine)
}
}
+ if (testContext.ShippingPackagesDirectory is null)
+ {
+ string? dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT");
+ if (!string.IsNullOrEmpty(dotnetRoot))
+ {
+ testContext.ShippingPackagesDirectory = Path.Combine(dotnetRoot, ".nuget");
+ }
+ }
+
if (commandLine.SdkVersion != null)
{
testContext.SdkVersion = commandLine.SdkVersion;
diff --git a/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs b/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs
new file mode 100644
index 000000000000..366c93f7bdea
--- /dev/null
+++ b/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs
@@ -0,0 +1,345 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+using Microsoft.DotNet.Build.Tasks;
+using Microsoft.NET.TestFramework.Commands;
+
+namespace Microsoft.CoreSdkTasks.Tests;
+
+public class DeduplicateAssembliesWithLinksTests(ITestOutputHelper log) : SdkTest(log)
+{
+#if !NETFRAMEWORK
+ [Fact]
+ public void WhenDuplicatesExistItCreatesHardLinks()
+ {
+ var layoutDir = TestAssetsManager.CreateTestDirectory().Path;
+
+ var content = "duplicate assembly content";
+ var file1 = Path.Combine(layoutDir, "assembly1.dll");
+ var file2 = Path.Combine(layoutDir, "assembly2.dll");
+ var file3 = Path.Combine(layoutDir, "assembly3.dll");
+
+ File.WriteAllText(file1, content);
+ File.WriteAllText(file2, content);
+ File.WriteAllText(file3, content);
+
+ var task = CreateTask(layoutDir, useHardLinks: true);
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+
+ // All files should still exist
+ File.Exists(file1).Should().BeTrue();
+ File.Exists(file2).Should().BeTrue();
+ File.Exists(file3).Should().BeTrue();
+
+ // All should have the same content
+ File.ReadAllText(file1).Should().Be(content);
+ File.ReadAllText(file2).Should().Be(content);
+ File.ReadAllText(file3).Should().Be(content);
+
+ // With hard links, all should point to the same inode/file index
+ var inode1 = GetInode(file1);
+ var inode2 = GetInode(file2);
+ var inode3 = GetInode(file3);
+
+ inode1.Should().Be(inode2);
+ inode2.Should().Be(inode3);
+ }
+
+ [Fact]
+ public void WhenDuplicatesExistItCreatesSymbolicLinks()
+ {
+ var layoutDir = TestAssetsManager.CreateTestDirectory().Path;
+
+ var content = "duplicate assembly content";
+ var file1 = Path.Combine(layoutDir, "assembly1.dll");
+ var file2 = Path.Combine(layoutDir, "assembly2.dll");
+
+ File.WriteAllText(file1, content);
+ File.WriteAllText(file2, content);
+
+ var task = CreateTask(layoutDir, useHardLinks: false);
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+
+ // Both files should exist
+ File.Exists(file1).Should().BeTrue();
+ File.Exists(file2).Should().BeTrue();
+
+ // One should be a symlink
+ var file1Info = new FileInfo(file1);
+ var file2Info = new FileInfo(file2);
+
+ var symlinksCreated = (file1Info.LinkTarget != null) || (file2Info.LinkTarget != null);
+ symlinksCreated.Should().BeTrue();
+ }
+
+ [Fact]
+ public void ItSelectsMasterByDepthThenAlphabetically()
+ {
+ var layoutDir = TestAssetsManager.CreateTestDirectory().Path;
+ var subDir1 = Path.Combine(layoutDir, "sub1");
+ var subDir2 = Path.Combine(layoutDir, "sub2");
+ var subSubDir = Path.Combine(subDir1, "nested");
+
+ Directory.CreateDirectory(subDir1);
+ Directory.CreateDirectory(subDir2);
+ Directory.CreateDirectory(subSubDir);
+
+ var content = "shared content";
+
+ // Create files at different depths and alphabetical positions
+ var rootFileZ = Path.Combine(layoutDir, "z.dll");
+ var rootFileA = Path.Combine(layoutDir, "a.dll");
+ var sub1File = Path.Combine(subDir1, "file.dll");
+ var sub2File = Path.Combine(subDir2, "file.dll");
+ var nestedFile = Path.Combine(subSubDir, "file.dll");
+
+ File.WriteAllText(rootFileZ, content);
+ File.WriteAllText(rootFileA, content);
+ File.WriteAllText(sub1File, content);
+ File.WriteAllText(sub2File, content);
+ File.WriteAllText(nestedFile, content);
+
+ var task = CreateTask(layoutDir, useHardLinks: true);
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+
+ // The primary should be the one at root level that's alphabetically first (a.dll)
+ // We can verify this by checking that all files are hard linked together
+ var primaryInode = GetInode(rootFileA);
+ GetInode(rootFileZ).Should().Be(primaryInode);
+ GetInode(sub1File).Should().Be(primaryInode);
+ GetInode(sub2File).Should().Be(primaryInode);
+ GetInode(nestedFile).Should().Be(primaryInode);
+ }
+
+ [Fact]
+ public void ItOnlyDeduplicatesAssemblies()
+ {
+ var layoutDir = TestAssetsManager.CreateTestDirectory().Path;
+
+ var content = "shared content";
+
+ // Create duplicate assemblies
+ var dll1 = Path.Combine(layoutDir, "assembly1.dll");
+ var dll2 = Path.Combine(layoutDir, "assembly2.dll");
+ var exe1 = Path.Combine(layoutDir, "program1.exe");
+ var exe2 = Path.Combine(layoutDir, "program2.exe");
+
+ // Create duplicate non-assemblies
+ var txt1 = Path.Combine(layoutDir, "file1.txt");
+ var txt2 = Path.Combine(layoutDir, "file2.txt");
+ var json1 = Path.Combine(layoutDir, "config1.json");
+ var json2 = Path.Combine(layoutDir, "config2.json");
+
+ File.WriteAllText(dll1, content);
+ File.WriteAllText(dll2, content);
+ File.WriteAllText(exe1, content);
+ File.WriteAllText(exe2, content);
+ File.WriteAllText(txt1, content);
+ File.WriteAllText(txt2, content);
+ File.WriteAllText(json1, content);
+ File.WriteAllText(json2, content);
+
+ var task = CreateTask(layoutDir, useHardLinks: true);
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+
+ // Assemblies should be deduplicated
+ var dllInode = GetInode(dll1);
+ GetInode(dll2).Should().Be(dllInode);
+
+ var exeInode = GetInode(exe1);
+ GetInode(exe2).Should().Be(exeInode);
+
+ // Non-assemblies should NOT be deduplicated
+ var txt1Inode = GetInode(txt1);
+ var txt2Inode = GetInode(txt2);
+ txt1Inode.Should().NotBe(txt2Inode);
+
+ var json1Inode = GetInode(json1);
+ var json2Inode = GetInode(json2);
+ json1Inode.Should().NotBe(json2Inode);
+ }
+
+ [Fact]
+ public void WhenLayoutDirectoryDoesNotExistItFails()
+ {
+ var nonExistentDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+
+ var task = CreateTask(nonExistentDir);
+ var result = task.Execute();
+
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void ItHandlesMultipleDuplicateGroups()
+ {
+ var layoutDir = TestAssetsManager.CreateTestDirectory().Path;
+
+ // Group 1: duplicates with content A
+ var contentA = "content A";
+ File.WriteAllText(Path.Combine(layoutDir, "a1.dll"), contentA);
+ File.WriteAllText(Path.Combine(layoutDir, "a2.dll"), contentA);
+ File.WriteAllText(Path.Combine(layoutDir, "a3.dll"), contentA);
+
+ // Group 2: duplicates with content B
+ var contentB = "content B";
+ File.WriteAllText(Path.Combine(layoutDir, "b1.dll"), contentB);
+ File.WriteAllText(Path.Combine(layoutDir, "b2.dll"), contentB);
+
+ // Unique file
+ File.WriteAllText(Path.Combine(layoutDir, "unique.dll"), "unique");
+
+ var task = CreateTask(layoutDir, useHardLinks: true);
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+
+ // Group A files should all be linked together
+ var inodeA1 = GetInode(Path.Combine(layoutDir, "a1.dll"));
+ GetInode(Path.Combine(layoutDir, "a2.dll")).Should().Be(inodeA1);
+ GetInode(Path.Combine(layoutDir, "a3.dll")).Should().Be(inodeA1);
+
+ // Group B files should all be linked together
+ var inodeB1 = GetInode(Path.Combine(layoutDir, "b1.dll"));
+ GetInode(Path.Combine(layoutDir, "b2.dll")).Should().Be(inodeB1);
+
+ // Groups should not be linked to each other
+ inodeA1.Should().NotBe(inodeB1);
+
+ // Unique file should not be linked
+ var inodeUnique = GetInode(Path.Combine(layoutDir, "unique.dll"));
+ inodeUnique.Should().NotBe(inodeA1);
+ inodeUnique.Should().NotBe(inodeB1);
+ }
+
+ [Fact]
+ public void ItCreatesRelativeSymbolicLinks()
+ {
+ var layoutDir = TestAssetsManager.CreateTestDirectory().Path;
+ var subDir = Path.Combine(layoutDir, "subdir");
+ Directory.CreateDirectory(subDir);
+
+ var content = "duplicate content";
+ var rootFile = Path.Combine(layoutDir, "primary.dll");
+ var subFile = Path.Combine(subDir, "duplicate.dll");
+
+ File.WriteAllText(rootFile, content);
+ File.WriteAllText(subFile, content);
+
+ var task = CreateTask(layoutDir, useHardLinks: false);
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+
+ // Check that the symlink is relative
+ var rootInfo = new FileInfo(rootFile);
+ var subInfo = new FileInfo(subFile);
+
+ // One should be a symlink (the one in subdir, since primary is at root)
+ if (subInfo.LinkTarget != null)
+ {
+ // Should be a relative path, not absolute
+ Path.IsPathRooted(subInfo.LinkTarget).Should().BeFalse();
+
+ // Normalize path separators for cross-platform compatibility
+ var normalizedLinkTarget = subInfo.LinkTarget.Replace('\\', '/');
+ normalizedLinkTarget.Should().Be("../primary.dll");
+ }
+ }
+
+ private static DeduplicateAssembliesWithLinks CreateTask(string layoutDir, bool useHardLinks = true)
+ {
+ var task = new DeduplicateAssembliesWithLinks
+ {
+ LayoutDirectory = layoutDir,
+ UseHardLinks = useHardLinks,
+ BuildEngine = new MockBuildEngine()
+ };
+ return task;
+ }
+
+ private long GetInode(string filePath)
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return GetWindowsFileIndex(filePath);
+ }
+ else
+ {
+ // Use stat to get inode number on Unix systems
+ // Linux uses GNU stat: -c %i
+ // macOS uses BSD stat: -f %i
+ var formatFlag = OperatingSystem.IsMacOS() ? "-f" : "-c";
+
+ var result = new RunExeCommand(Log, "stat")
+ .Execute(formatFlag, "%i", filePath);
+
+ result.Should().Pass();
+
+ return long.Parse(result.StdOut!.Trim());
+ }
+ }
+
+ private static long GetWindowsFileIndex(string filePath)
+ {
+ using var handle = File.OpenHandle(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+
+ if (!GetFileInformationByHandle(handle, out BY_HANDLE_FILE_INFORMATION fileInfo))
+ {
+ throw new System.ComponentModel.Win32Exception();
+ }
+
+ // Combine high and low parts of the file index
+ return ((long)fileInfo.nFileIndexHigh << 32) | fileInfo.nFileIndexLow;
+ }
+
+ [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool GetFileInformationByHandle(
+ Microsoft.Win32.SafeHandles.SafeFileHandle hFile,
+ out BY_HANDLE_FILE_INFORMATION lpFileInformation);
+
+ [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
+ private struct BY_HANDLE_FILE_INFORMATION
+ {
+ public uint dwFileAttributes;
+ public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime;
+ public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime;
+ public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime;
+ public uint dwVolumeSerialNumber;
+ public uint nFileSizeHigh;
+ public uint nFileSizeLow;
+ public uint nNumberOfLinks;
+ public uint nFileIndexHigh;
+ public uint nFileIndexLow;
+ }
+
+ private class MockBuildEngine : IBuildEngine
+ {
+ public bool ContinueOnError => false;
+ public int LineNumberOfTaskNode => 0;
+ public int ColumnNumberOfTaskNode => 0;
+ public string ProjectFileOfTaskNode => string.Empty;
+
+ public bool BuildProjectFile(string projectFileName, string[] targetNames,
+ System.Collections.IDictionary globalProperties, System.Collections.IDictionary targetOutputs)
+ {
+ return true;
+ }
+
+ public void LogCustomEvent(CustomBuildEventArgs e) { }
+ public void LogErrorEvent(BuildErrorEventArgs e) { }
+ public void LogMessageEvent(BuildMessageEventArgs e) { }
+ public void LogWarningEvent(BuildWarningEventArgs e) { }
+ }
+#endif
+}