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 +}