From 7e233d1657ef2795942c195b4a340cf944e42efb Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Thu, 4 Dec 2025 16:47:32 +0000 Subject: [PATCH 01/11] Add layout logic to replace duplicate files with links (linux only) --- .../targets/GenerateInstallerLayout.targets | 5 +- .../DeduplicateAssembliesWithLinks.cs | 183 +++++++++ .../ReplaceFilesWithSymbolicLinks.cs | 2 +- src/Tasks/sdk-tasks/sdk-tasks.InTree.targets | 6 + src/Tasks/sdk-tasks/sdk-tasks.csproj | 1 + test/EndToEnd.Tests/GivenSdkArchives.cs | 158 ++++++++ .../Utilities/SymbolicLinkHelpers.cs | 103 +++++ .../TestContext.cs | 34 +- .../DeduplicateAssembliesWithLinksTests.cs | 370 ++++++++++++++++++ 9 files changed, 859 insertions(+), 3 deletions(-) create mode 100644 src/Tasks/sdk-tasks/DeduplicateAssembliesWithLinks.cs create mode 100644 test/EndToEnd.Tests/GivenSdkArchives.cs create mode 100644 test/EndToEnd.Tests/Utilities/SymbolicLinkHelpers.cs create mode 100644 test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs diff --git a/src/Layout/redist/targets/GenerateInstallerLayout.targets b/src/Layout/redist/targets/GenerateInstallerLayout.targets index 069c6a2fde2a..878c5256d5d7 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..59f8258f8c32 --- /dev/null +++ b/src/Tasks/sdk-tasks/DeduplicateAssembliesWithLinks.cs @@ -0,0 +1,183 @@ +// 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 "master" 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 + var sorted = group.OrderBy(f => f.Depth).ThenBy(f => f.Path).ToList(); + + // First file is the "master" + var master = sorted[0]; + var duplicates = sorted.Skip(1).ToList(); + + foreach (var duplicate in duplicates) + { + try + { + CreateLink(duplicate.Path, master.Path); + totalFilesDeduped++; + totalBytesSaved += duplicate.Size; + Log.LogMessage(MessageImportance.Low, $" Linked: {duplicate.Path} -> {master.Path}"); + } + catch (Exception ex) + { + Log.LogError($"Failed to create {LinkType} from '{duplicate.Path}' to '{master.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 masterFilePath) + { + // Delete the duplicate file before creating the link + File.Delete(duplicateFilePath); + + if (UseHardLinks) + { + File.CreateHardLink(duplicateFilePath, masterFilePath); + } + else + { + // Create relative symlink so it works when directory is moved/archived + var duplicateDirectory = Path.GetDirectoryName(duplicateFilePath)!; + var relativePath = Path.GetRelativePath(duplicateDirectory, masterFilePath); + 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..32f118e06a37 100644 --- a/src/Tasks/sdk-tasks/sdk-tasks.InTree.targets +++ b/src/Tasks/sdk-tasks/sdk-tasks.InTree.targets @@ -33,6 +33,12 @@ + + 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..b5231914ee83 --- /dev/null +++ b/test/EndToEnd.Tests/GivenSdkArchives.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Runtime.InteropServices; +using EndToEnd.Tests.Utilities; +using Microsoft.Win32.SafeHandles; + +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 links + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + VerifyWindowsHardLinks(extractedPath); + } + else + { + VerifyLinuxSymbolicLinks(extractedPath); + } + } + + 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; + } + + private void VerifyWindowsHardLinks(string extractedPath) + { + var assemblies = Directory.GetFiles(extractedPath, "*", SearchOption.AllDirectories) + .Where(f => IsAssembly(f)) + .ToList(); + + Log.WriteLine($"Found {assemblies.Count} total assemblies in archive"); + + int hardLinkCount = 0; + foreach (var assembly in assemblies) + { + try + { + if (IsHardLinked(assembly)) + { + hardLinkCount++; + } + } + catch (Exception ex) + { + Log.WriteLine($"Warning: Failed to check {assembly}: {ex.Message}"); + } + } + + Log.WriteLine($"Found {hardLinkCount} hard linked assemblies"); + + Assert.True(hardLinkCount > SymbolicLinkHelpers.MinExpectedDeduplicatedLinks, + $"Expected more than {SymbolicLinkHelpers.MinExpectedDeduplicatedLinks} hard linked assemblies, but found only {hardLinkCount}. " + + "This suggests deduplication did not run correctly."); + } + + private void VerifyLinuxSymbolicLinks(string extractedPath) + { + SymbolicLinkHelpers.VerifyDirectoryHasRelativeSymlinks(extractedPath, Log, "archive"); + } + + private static bool IsAssembly(string filePath) + { + var ext = Path.GetExtension(filePath); + return ext.Equals(".dll", StringComparison.OrdinalIgnoreCase) || + ext.Equals(".exe", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsHardLinked(string filePath) + { + using var handle = CreateFile( + filePath, + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + IntPtr.Zero, + OPEN_EXISTING, + 0, + IntPtr.Zero); + + if (handle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + $"Failed to open file: {filePath}"); + } + + if (!GetFileInformationByHandle(handle, out var fileInfo)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + $"Failed to get file information: {filePath}"); + } + + // Hard link if NumberOfLinks > 1 + return fileInfo.NumberOfLinks > 1; + } + + // Windows P/Invoke declarations + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", 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.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle hFile, + out BY_HANDLE_FILE_INFORMATION lpFileInformation); + + private const uint GENERIC_READ = 0x80000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint FILE_SHARE_DELETE = 0x00000004; + private const uint OPEN_EXISTING = 3; + } +} diff --git a/test/EndToEnd.Tests/Utilities/SymbolicLinkHelpers.cs b/test/EndToEnd.Tests/Utilities/SymbolicLinkHelpers.cs new file mode 100644 index 000000000000..673d9692a5d7 --- /dev/null +++ b/test/EndToEnd.Tests/Utilities/SymbolicLinkHelpers.cs @@ -0,0 +1,103 @@ +// 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..fdc92d05e37e 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,28 @@ 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; + 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 +256,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 +286,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..ff6e669c633c --- /dev/null +++ b/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs @@ -0,0 +1,370 @@ +// 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; + +namespace Microsoft.CoreSdkTasks.Tests +{ + public class DeduplicateAssembliesWithLinksTests(ITestOutputHelper log) : SdkTest(log) + { +#if !NETFRAMEWORK + [Fact] + public void WhenDuplicatesExistItCreatesHardLinks() + { + var layoutDir = CreateTempDirectory(); + + 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 = CreateTempDirectory(); + + 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 = CreateTempDirectory(); + 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 master 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 masterInode = GetInode(rootFileA); + GetInode(rootFileZ).Should().Be(masterInode); + GetInode(sub1File).Should().Be(masterInode); + GetInode(sub2File).Should().Be(masterInode); + GetInode(nestedFile).Should().Be(masterInode); + } + + [Fact] + public void ItOnlyDeduplicatesAssemblies() + { + var layoutDir = CreateTempDirectory(); + + 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 = CreateTempDirectory(); + + // 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 = CreateTempDirectory(); + var subDir = Path.Combine(layoutDir, "subdir"); + Directory.CreateDirectory(subDir); + + var content = "duplicate content"; + var rootFile = Path.Combine(layoutDir, "master.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 master 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("../master.dll"); + } + } + + private static DeduplicateAssembliesWithLinks CreateTask(string layoutDir, bool useHardLinks = true) + { + var task = new DeduplicateAssembliesWithLinks + { + LayoutDirectory = layoutDir, + UseHardLinks = useHardLinks, + BuildEngine = new MockBuildEngine() + }; + return task; + } + + private static string CreateTempDirectory() + { + var tempDir = Path.Combine(Path.GetTempPath(), "DeduplicateTests_" + Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + return tempDir; + } + + private static 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 statFormat = OperatingSystem.IsMacOS() ? "-f %i" : "-c %i"; + + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "stat", + Arguments = $"{statFormat} \"{filePath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.Start(); + var output = process.StandardOutput.ReadToEnd().Trim(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output)) + { + throw new InvalidOperationException( + $"Failed to get inode for '{filePath}'. Exit code: {process.ExitCode}, Error: {error}, Output: '{output}'"); + } + + return long.Parse(output); + } + } + + 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 + } +} From 4be229e6bc6c9de5b49a22a7252fb5e060e8ed05 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Fri, 30 Jan 2026 14:28:11 +0000 Subject: [PATCH 02/11] Use file-scoped namespaces --- .../DeduplicateAssembliesWithLinks.cs | 257 ++++---- test/EndToEnd.Tests/GivenSdkArchives.cs | 239 ++++--- .../Utilities/SymbolicLinkHelpers.cs | 153 +++-- .../DeduplicateAssembliesWithLinksTests.cs | 593 +++++++++--------- 4 files changed, 619 insertions(+), 623 deletions(-) diff --git a/src/Tasks/sdk-tasks/DeduplicateAssembliesWithLinks.cs b/src/Tasks/sdk-tasks/DeduplicateAssembliesWithLinks.cs index 59f8258f8c32..06d5df31ab03 100644 --- a/src/Tasks/sdk-tasks/DeduplicateAssembliesWithLinks.cs +++ b/src/Tasks/sdk-tasks/DeduplicateAssembliesWithLinks.cs @@ -8,176 +8,175 @@ using System.IO.Hashing; using System.Linq; -namespace Microsoft.DotNet.Build.Tasks +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 "master" 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 { /// - /// 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 "master" file is selected (closest to root, alphabetically - /// first) which duplicates are linked to. Text-based files (config, json, xml, etc.) are not deduplicated. + /// The root directory to scan for duplicate assemblies. /// - public sealed class DeduplicateAssembliesWithLinks : Task + [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() { - /// - /// The root directory to scan for duplicate assemblies. - /// - [Required] - public string LayoutDirectory { get; set; } = null!; + 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)..."); - /// - /// If true, creates hard links. If false, creates symbolic links. - /// - public bool UseHardLinks { get; set; } = false; + // 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(); - private string LinkType => UseHardLinks ? "hard link" : "symbolic link"; + Log.LogMessage(MessageImportance.Normal, $"Found {files.Count} assemblies eligible for deduplication."); - public override bool Execute() + var (filesByHash, hashingSuccess) = HashAndGroupFiles(files); + if (!hashingSuccess) { - if (!Directory.Exists(LayoutDirectory)) - { - Log.LogError($"LayoutDirectory '{LayoutDirectory}' does not exist."); - return false; - } + return false; + } - Log.LogMessage(MessageImportance.High, $"Scanning for duplicate assemblies in '{LayoutDirectory}' (using {LinkType}s)..."); + var duplicateGroups = filesByHash.Values.Where(g => g.Count > 1).ToList(); + Log.LogMessage(MessageImportance.Normal, $"Found {duplicateGroups.Count} groups of duplicate assemblies."); + return DeduplicateFileGroups(duplicateGroups); + } - // 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(); + private (Dictionary> filesByHash, bool success) HashAndGroupFiles(List files) + { + var filesByHash = new Dictionary>(); + bool hasErrors = false; - Log.LogMessage(MessageImportance.Normal, $"Found {files.Count} assemblies eligible for deduplication."); + 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(); + } - var (filesByHash, hashingSuccess) = HashAndGroupFiles(files); - if (!hashingSuccess) + filesByHash[hash].Add(entry); + } + catch (Exception ex) { - return false; + Log.LogError($"Failed to hash file '{filePath}': {ex.Message}"); + hasErrors = true; } - - 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) + return (filesByHash, !hasErrors); + } + + private bool DeduplicateFileGroups(List> duplicateGroups) + { + int totalFilesDeduped = 0; + long totalBytesSaved = 0; + bool hasErrors = false; + + foreach (var group in duplicateGroups) { - var filesByHash = new Dictionary>(); - bool hasErrors = false; + // Sort deterministically: by depth (ascending), then alphabetically + var sorted = group.OrderBy(f => f.Depth).ThenBy(f => f.Path).ToList(); + + // First file is the "master" + var master = sorted[0]; + var duplicates = sorted.Skip(1).ToList(); - foreach (var filePath in files) + foreach (var duplicate in duplicates) { 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); + CreateLink(duplicate.Path, master.Path); + totalFilesDeduped++; + totalBytesSaved += duplicate.Size; + Log.LogMessage(MessageImportance.Low, $" Linked: {duplicate.Path} -> {master.Path}"); } catch (Exception ex) { - Log.LogError($"Failed to hash file '{filePath}': {ex.Message}"); + Log.LogError($"Failed to create {LinkType} from '{duplicate.Path}' to '{master.Path}': {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 - var sorted = group.OrderBy(f => f.Depth).ThenBy(f => f.Path).ToList(); - - // First file is the "master" - var master = sorted[0]; - var duplicates = sorted.Skip(1).ToList(); + Log.LogMessage(MessageImportance.High, + $"Deduplication complete: {totalFilesDeduped} files replaced with {LinkType}s, saving {totalBytesSaved / (1024.0 * 1024.0):F2} MB."); - foreach (var duplicate in duplicates) - { - try - { - CreateLink(duplicate.Path, master.Path); - totalFilesDeduped++; - totalBytesSaved += duplicate.Size; - Log.LogMessage(MessageImportance.Low, $" Linked: {duplicate.Path} -> {master.Path}"); - } - catch (Exception ex) - { - Log.LogError($"Failed to create {LinkType} from '{duplicate.Path}' to '{master.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; + } - return !hasErrors; - } + private void CreateLink(string duplicateFilePath, string masterFilePath) + { + // Delete the duplicate file before creating the link + File.Delete(duplicateFilePath); - private void CreateLink(string duplicateFilePath, string masterFilePath) + if (UseHardLinks) { - // Delete the duplicate file before creating the link - File.Delete(duplicateFilePath); - - if (UseHardLinks) - { - File.CreateHardLink(duplicateFilePath, masterFilePath); - } - else - { - // Create relative symlink so it works when directory is moved/archived - var duplicateDirectory = Path.GetDirectoryName(duplicateFilePath)!; - var relativePath = Path.GetRelativePath(duplicateDirectory, masterFilePath); - File.CreateSymbolicLink(duplicateFilePath, relativePath); - } + File.CreateHardLink(duplicateFilePath, masterFilePath); } - - private static string ComputeFileHash(string filePath) + else { - 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()); + // Create relative symlink so it works when directory is moved/archived + var duplicateDirectory = Path.GetDirectoryName(duplicateFilePath)!; + var relativePath = Path.GetRelativePath(duplicateDirectory, masterFilePath); + File.CreateSymbolicLink(duplicateFilePath, relativePath); } + } - 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 string ComputeFileHash(string filePath) + { + var xxHash = new XxHash64(); + using var stream = File.OpenRead(filePath); - private static bool IsAssembly(string filePath) + byte[] buffer = new byte[65536]; // 64KB buffer + int bytesRead; + while ((bytesRead = stream.Read(buffer)) > 0) { - var extension = Path.GetExtension(filePath); - return extension.Equals(".dll", StringComparison.OrdinalIgnoreCase) || - extension.Equals(".exe", StringComparison.OrdinalIgnoreCase); + xxHash.Append(buffer[..bytesRead]); } - private record FileEntry(string Path, string Hash, long Size, int Depth); + 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/test/EndToEnd.Tests/GivenSdkArchives.cs b/test/EndToEnd.Tests/GivenSdkArchives.cs index b5231914ee83..3cf7d5cb11fd 100644 --- a/test/EndToEnd.Tests/GivenSdkArchives.cs +++ b/test/EndToEnd.Tests/GivenSdkArchives.cs @@ -6,153 +6,152 @@ using EndToEnd.Tests.Utilities; using Microsoft.Win32.SafeHandles; -namespace EndToEnd.Tests +namespace EndToEnd.Tests; + +public class GivenSdkArchives(ITestOutputHelper log) : SdkTest(log) { - public class GivenSdkArchives(ITestOutputHelper log) : SdkTest(log) + [Fact] + public void ItHasDeduplicatedAssemblies() { - [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)) { - // TODO: Windows is not supported yet - blocked on signing support (https://github.com/dotnet/sdk/issues/52182). - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return; - } + 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); + // 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 links - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - VerifyWindowsHardLinks(extractedPath); - } - else - { - VerifyLinuxSymbolicLinks(extractedPath); - } + // Verify deduplication worked by checking for links + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + VerifyWindowsHardLinks(extractedPath); } - - private string ExtractArchive(string archivePath) + else { - var testDir = _testAssetsManager.CreateTestDirectory(); - string extractPath = Path.Combine(testDir.Path, "sdk-extracted"); - Directory.CreateDirectory(extractPath); + VerifyLinuxSymbolicLinks(extractedPath); + } + } - Log.WriteLine($"Extracting archive to: {extractPath}"); + private string ExtractArchive(string archivePath) + { + var testDir = _testAssetsManager.CreateTestDirectory(); + string extractPath = Path.Combine(testDir.Path, "sdk-extracted"); + Directory.CreateDirectory(extractPath); - SymbolicLinkHelpers.ExtractTarGz(archivePath, extractPath, Log); + Log.WriteLine($"Extracting archive to: {extractPath}"); - return extractPath; - } + SymbolicLinkHelpers.ExtractTarGz(archivePath, extractPath, Log); - private void VerifyWindowsHardLinks(string extractedPath) - { - var assemblies = Directory.GetFiles(extractedPath, "*", SearchOption.AllDirectories) - .Where(f => IsAssembly(f)) - .ToList(); + return extractPath; + } + + private void VerifyWindowsHardLinks(string extractedPath) + { + var assemblies = Directory.GetFiles(extractedPath, "*", SearchOption.AllDirectories) + .Where(f => IsAssembly(f)) + .ToList(); - Log.WriteLine($"Found {assemblies.Count} total assemblies in archive"); + Log.WriteLine($"Found {assemblies.Count} total assemblies in archive"); - int hardLinkCount = 0; - foreach (var assembly in assemblies) + int hardLinkCount = 0; + foreach (var assembly in assemblies) + { + try { - try - { - if (IsHardLinked(assembly)) - { - hardLinkCount++; - } - } - catch (Exception ex) + if (IsHardLinked(assembly)) { - Log.WriteLine($"Warning: Failed to check {assembly}: {ex.Message}"); + hardLinkCount++; } } - - Log.WriteLine($"Found {hardLinkCount} hard linked assemblies"); - - Assert.True(hardLinkCount > SymbolicLinkHelpers.MinExpectedDeduplicatedLinks, - $"Expected more than {SymbolicLinkHelpers.MinExpectedDeduplicatedLinks} hard linked assemblies, but found only {hardLinkCount}. " + - "This suggests deduplication did not run correctly."); + catch (Exception ex) + { + Log.WriteLine($"Warning: Failed to check {assembly}: {ex.Message}"); + } } - private void VerifyLinuxSymbolicLinks(string extractedPath) - { - SymbolicLinkHelpers.VerifyDirectoryHasRelativeSymlinks(extractedPath, Log, "archive"); - } + Log.WriteLine($"Found {hardLinkCount} hard linked assemblies"); - private static bool IsAssembly(string filePath) - { - var ext = Path.GetExtension(filePath); - return ext.Equals(".dll", StringComparison.OrdinalIgnoreCase) || - ext.Equals(".exe", StringComparison.OrdinalIgnoreCase); - } + Assert.True(hardLinkCount > SymbolicLinkHelpers.MinExpectedDeduplicatedLinks, + $"Expected more than {SymbolicLinkHelpers.MinExpectedDeduplicatedLinks} hard linked assemblies, but found only {hardLinkCount}. " + + "This suggests deduplication did not run correctly."); + } - private static bool IsHardLinked(string filePath) - { - using var handle = CreateFile( - filePath, - GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - IntPtr.Zero, - OPEN_EXISTING, - 0, - IntPtr.Zero); - - if (handle.IsInvalid) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), - $"Failed to open file: {filePath}"); - } + private void VerifyLinuxSymbolicLinks(string extractedPath) + { + SymbolicLinkHelpers.VerifyDirectoryHasRelativeSymlinks(extractedPath, Log, "archive"); + } - if (!GetFileInformationByHandle(handle, out var fileInfo)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), - $"Failed to get file information: {filePath}"); - } + private static bool IsAssembly(string filePath) + { + var ext = Path.GetExtension(filePath); + return ext.Equals(".dll", StringComparison.OrdinalIgnoreCase) || + ext.Equals(".exe", StringComparison.OrdinalIgnoreCase); + } - // Hard link if NumberOfLinks > 1 - return fileInfo.NumberOfLinks > 1; + private static bool IsHardLinked(string filePath) + { + using var handle = CreateFile( + filePath, + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + IntPtr.Zero, + OPEN_EXISTING, + 0, + IntPtr.Zero); + + if (handle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + $"Failed to open file: {filePath}"); } - // Windows P/Invoke declarations - [StructLayout(LayoutKind.Sequential)] - private struct BY_HANDLE_FILE_INFORMATION + if (!GetFileInformationByHandle(handle, out var fileInfo)) { - public uint FileAttributes; - public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; - public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; - public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; - public uint VolumeSerialNumber; - public uint FileSizeHigh; - public uint FileSizeLow; - public uint NumberOfLinks; - public uint FileIndexHigh; - public uint FileIndexLow; + throw new Win32Exception(Marshal.GetLastWin32Error(), + $"Failed to get file information: {filePath}"); } - [DllImport("kernel32.dll", 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.dll", SetLastError = true)] - private static extern bool GetFileInformationByHandle( - SafeFileHandle hFile, - out BY_HANDLE_FILE_INFORMATION lpFileInformation); - - private const uint GENERIC_READ = 0x80000000; - private const uint FILE_SHARE_READ = 0x00000001; - private const uint FILE_SHARE_WRITE = 0x00000002; - private const uint FILE_SHARE_DELETE = 0x00000004; - private const uint OPEN_EXISTING = 3; + // Hard link if NumberOfLinks > 1 + return fileInfo.NumberOfLinks > 1; } + + // Windows P/Invoke declarations + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", 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.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle hFile, + out BY_HANDLE_FILE_INFORMATION lpFileInformation); + + private const uint GENERIC_READ = 0x80000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint FILE_SHARE_DELETE = 0x00000004; + private const uint OPEN_EXISTING = 3; } diff --git a/test/EndToEnd.Tests/Utilities/SymbolicLinkHelpers.cs b/test/EndToEnd.Tests/Utilities/SymbolicLinkHelpers.cs index 673d9692a5d7..03e04049d6e5 100644 --- a/test/EndToEnd.Tests/Utilities/SymbolicLinkHelpers.cs +++ b/test/EndToEnd.Tests/Utilities/SymbolicLinkHelpers.cs @@ -1,103 +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 +namespace EndToEnd.Tests.Utilities; + +/// +/// Shared helpers for verifying symbolic links and extracting archives in tests. +/// +internal static class SymbolicLinkHelpers { /// - /// Shared helpers for verifying symbolic links and extracting archives in tests. + /// 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. /// - internal static class SymbolicLinkHelpers + /// 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) { - /// - /// Minimum number of deduplicated links expected in an SDK layout. - /// Used by both symbolic link and hard link validation. - /// - public const int MinExpectedDeduplicatedLinks = 100; + var tempDir = Path.Combine(Path.GetTempPath(), $"{packageType}-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); - /// - /// 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) + try { - new RunExeCommand(log, "tar") - .Execute("-xzf", tarGzPath, "-C", destinationDirectory) - .Should().Pass(); + extractPackage(tempDir); + VerifyDirectoryHasRelativeSymlinks(tempDir, log, $"{packageType} package"); } - - /// - /// 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) + finally { - var tempDir = Path.Combine(Path.GetTempPath(), $"{packageType}-test-{Guid.NewGuid()}"); - Directory.CreateDirectory(tempDir); - - try + if (Directory.Exists(tempDir)) { - extractPackage(tempDir); - VerifyDirectoryHasRelativeSymlinks(tempDir, log, $"{packageType} package"); - } - finally - { - if (Directory.Exists(tempDir)) - { - Directory.Delete(tempDir, recursive: true); - } + 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"); + /// + /// 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(); + findResult.Should().Pass(); - var symlinkPaths = (findResult.StdOut ?? string.Empty) - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .ToList(); + var symlinkPaths = (findResult.StdOut ?? string.Empty) + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .ToList(); - log.WriteLine($"Found {symlinkPaths.Count} symbolic links in {contextName}"); + 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."); + 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); + // 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(); + readlinkResult.Should().Pass(); - var target = (readlinkResult.StdOut ?? string.Empty).Trim(); - if (target.StartsWith("/")) - { - absoluteSymlinks.Add($"{symlinkPath} -> {target}"); - } + 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"); } + + Assert.Empty(absoluteSymlinks); + log.WriteLine($"Verified all {symlinkPaths.Count} symbolic links use relative paths"); } } diff --git a/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs b/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs index ff6e669c633c..92b6fc570b26 100644 --- a/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs +++ b/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs @@ -5,366 +5,365 @@ using Microsoft.Build.Utilities; using Microsoft.DotNet.Build.Tasks; -namespace Microsoft.CoreSdkTasks.Tests +namespace Microsoft.CoreSdkTasks.Tests; + +public class DeduplicateAssembliesWithLinksTests(ITestOutputHelper log) : SdkTest(log) { - public class DeduplicateAssembliesWithLinksTests(ITestOutputHelper log) : SdkTest(log) - { #if !NETFRAMEWORK - [Fact] - public void WhenDuplicatesExistItCreatesHardLinks() - { - var layoutDir = CreateTempDirectory(); + [Fact] + public void WhenDuplicatesExistItCreatesHardLinks() + { + var layoutDir = CreateTempDirectory(); - 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"); + 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); + File.WriteAllText(file1, content); + File.WriteAllText(file2, content); + File.WriteAllText(file3, content); - var task = CreateTask(layoutDir, useHardLinks: true); - var result = task.Execute(); + var task = CreateTask(layoutDir, useHardLinks: true); + var result = task.Execute(); - result.Should().BeTrue(); + result.Should().BeTrue(); - // All files should still exist - File.Exists(file1).Should().BeTrue(); - File.Exists(file2).Should().BeTrue(); - File.Exists(file3).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); + // 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); + // 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); - } + inode1.Should().Be(inode2); + inode2.Should().Be(inode3); + } - [Fact] - public void WhenDuplicatesExistItCreatesSymbolicLinks() - { - var layoutDir = CreateTempDirectory(); + [Fact] + public void WhenDuplicatesExistItCreatesSymbolicLinks() + { + var layoutDir = CreateTempDirectory(); - var content = "duplicate assembly content"; - var file1 = Path.Combine(layoutDir, "assembly1.dll"); - var file2 = Path.Combine(layoutDir, "assembly2.dll"); + 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); + File.WriteAllText(file1, content); + File.WriteAllText(file2, content); - var task = CreateTask(layoutDir, useHardLinks: false); - var result = task.Execute(); + var task = CreateTask(layoutDir, useHardLinks: false); + var result = task.Execute(); - result.Should().BeTrue(); + result.Should().BeTrue(); - // Both files should exist - File.Exists(file1).Should().BeTrue(); - File.Exists(file2).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); + // 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(); - } + var symlinksCreated = (file1Info.LinkTarget != null) || (file2Info.LinkTarget != null); + symlinksCreated.Should().BeTrue(); + } - [Fact] - public void ItSelectsMasterByDepthThenAlphabetically() - { - var layoutDir = CreateTempDirectory(); - 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 master 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 masterInode = GetInode(rootFileA); - GetInode(rootFileZ).Should().Be(masterInode); - GetInode(sub1File).Should().Be(masterInode); - GetInode(sub2File).Should().Be(masterInode); - GetInode(nestedFile).Should().Be(masterInode); - } + [Fact] + public void ItSelectsMasterByDepthThenAlphabetically() + { + var layoutDir = CreateTempDirectory(); + 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 master 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 masterInode = GetInode(rootFileA); + GetInode(rootFileZ).Should().Be(masterInode); + GetInode(sub1File).Should().Be(masterInode); + GetInode(sub2File).Should().Be(masterInode); + GetInode(nestedFile).Should().Be(masterInode); + } - [Fact] - public void ItOnlyDeduplicatesAssemblies() - { - var layoutDir = CreateTempDirectory(); - - 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 ItOnlyDeduplicatesAssemblies() + { + var layoutDir = CreateTempDirectory(); + + 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()); + [Fact] + public void WhenLayoutDirectoryDoesNotExistItFails() + { + var nonExistentDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - var task = CreateTask(nonExistentDir); - var result = task.Execute(); + var task = CreateTask(nonExistentDir); + var result = task.Execute(); - result.Should().BeFalse(); - } + result.Should().BeFalse(); + } - [Fact] - public void ItHandlesMultipleDuplicateGroups() - { - var layoutDir = CreateTempDirectory(); + [Fact] + public void ItHandlesMultipleDuplicateGroups() + { + var layoutDir = CreateTempDirectory(); - // 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 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); + // 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"); + // Unique file + File.WriteAllText(Path.Combine(layoutDir, "unique.dll"), "unique"); - var task = CreateTask(layoutDir, useHardLinks: true); - var result = task.Execute(); + var task = CreateTask(layoutDir, useHardLinks: true); + var result = task.Execute(); - result.Should().BeTrue(); + 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 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); + // 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); + // 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); - } + // 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 = CreateTempDirectory(); - var subDir = Path.Combine(layoutDir, "subdir"); - Directory.CreateDirectory(subDir); + [Fact] + public void ItCreatesRelativeSymbolicLinks() + { + var layoutDir = CreateTempDirectory(); + var subDir = Path.Combine(layoutDir, "subdir"); + Directory.CreateDirectory(subDir); - var content = "duplicate content"; - var rootFile = Path.Combine(layoutDir, "master.dll"); - var subFile = Path.Combine(subDir, "duplicate.dll"); + var content = "duplicate content"; + var rootFile = Path.Combine(layoutDir, "master.dll"); + var subFile = Path.Combine(subDir, "duplicate.dll"); - File.WriteAllText(rootFile, content); - File.WriteAllText(subFile, content); + File.WriteAllText(rootFile, content); + File.WriteAllText(subFile, content); - var task = CreateTask(layoutDir, useHardLinks: false); - var result = task.Execute(); + var task = CreateTask(layoutDir, useHardLinks: false); + var result = task.Execute(); - result.Should().BeTrue(); + result.Should().BeTrue(); - // Check that the symlink is relative - var rootInfo = new FileInfo(rootFile); - var subInfo = new FileInfo(subFile); + // 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 master is at root) - if (subInfo.LinkTarget != null) - { - // Should be a relative path, not absolute - Path.IsPathRooted(subInfo.LinkTarget).Should().BeFalse(); + // One should be a symlink (the one in subdir, since master 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("../master.dll"); - } + // Normalize path separators for cross-platform compatibility + var normalizedLinkTarget = subInfo.LinkTarget.Replace('\\', '/'); + normalizedLinkTarget.Should().Be("../master.dll"); } + } - private static DeduplicateAssembliesWithLinks CreateTask(string layoutDir, bool useHardLinks = true) + private static DeduplicateAssembliesWithLinks CreateTask(string layoutDir, bool useHardLinks = true) + { + var task = new DeduplicateAssembliesWithLinks { - var task = new DeduplicateAssembliesWithLinks - { - LayoutDirectory = layoutDir, - UseHardLinks = useHardLinks, - BuildEngine = new MockBuildEngine() - }; - return task; - } + LayoutDirectory = layoutDir, + UseHardLinks = useHardLinks, + BuildEngine = new MockBuildEngine() + }; + return task; + } - private static string CreateTempDirectory() + private static string CreateTempDirectory() + { + var tempDir = Path.Combine(Path.GetTempPath(), "DeduplicateTests_" + Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + return tempDir; + } + + private static long GetInode(string filePath) + { + if (OperatingSystem.IsWindows()) { - var tempDir = Path.Combine(Path.GetTempPath(), "DeduplicateTests_" + Guid.NewGuid().ToString()); - Directory.CreateDirectory(tempDir); - return tempDir; + return GetWindowsFileIndex(filePath); } - - private static long GetInode(string filePath) + else { - 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 statFormat = OperatingSystem.IsMacOS() ? "-f %i" : "-c %i"; + // Use stat to get inode number on Unix systems + // Linux uses GNU stat: -c %i + // macOS uses BSD stat: -f %i + var statFormat = OperatingSystem.IsMacOS() ? "-f %i" : "-c %i"; - var process = new System.Diagnostics.Process - { - StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "stat", - Arguments = $"{statFormat} \"{filePath}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; - process.Start(); - var output = process.StandardOutput.ReadToEnd().Trim(); - var error = process.StandardError.ReadToEnd(); - process.WaitForExit(); - - if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output)) + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo { - throw new InvalidOperationException( - $"Failed to get inode for '{filePath}'. Exit code: {process.ExitCode}, Error: {error}, Output: '{output}'"); + FileName = "stat", + Arguments = $"{statFormat} \"{filePath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true } + }; + process.Start(); + var output = process.StandardOutput.ReadToEnd().Trim(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); - return long.Parse(output); - } - } - - 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)) + if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output)) { - throw new System.ComponentModel.Win32Exception(); + throw new InvalidOperationException( + $"Failed to get inode for '{filePath}'. Exit code: {process.ExitCode}, Error: {error}, Output: '{output}'"); } - // Combine high and low parts of the file index - return ((long)fileInfo.nFileIndexHigh << 32) | fileInfo.nFileIndexLow; + return long.Parse(output); } + } - [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] - private static extern bool GetFileInformationByHandle( - Microsoft.Win32.SafeHandles.SafeFileHandle hFile, - out BY_HANDLE_FILE_INFORMATION lpFileInformation); + private static long GetWindowsFileIndex(string filePath) + { + using var handle = File.OpenHandle(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] - private struct BY_HANDLE_FILE_INFORMATION + if (!GetFileInformationByHandle(handle, out BY_HANDLE_FILE_INFORMATION fileInfo)) { - 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; + throw new System.ComponentModel.Win32Exception(); } - private class MockBuildEngine : IBuildEngine - { - public bool ContinueOnError => false; - public int LineNumberOfTaskNode => 0; - public int ColumnNumberOfTaskNode => 0; - public string ProjectFileOfTaskNode => string.Empty; + // Combine high and low parts of the file index + return ((long)fileInfo.nFileIndexHigh << 32) | fileInfo.nFileIndexLow; + } - public bool BuildProjectFile(string projectFileName, string[] targetNames, - System.Collections.IDictionary globalProperties, System.Collections.IDictionary targetOutputs) - { - return true; - } + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + Microsoft.Win32.SafeHandles.SafeFileHandle hFile, + out BY_HANDLE_FILE_INFORMATION lpFileInformation); - public void LogCustomEvent(CustomBuildEventArgs e) { } - public void LogErrorEvent(BuildErrorEventArgs e) { } - public void LogMessageEvent(BuildMessageEventArgs e) { } - public void LogWarningEvent(BuildWarningEventArgs e) { } + [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; } -#endif + + public void LogCustomEvent(CustomBuildEventArgs e) { } + public void LogErrorEvent(BuildErrorEventArgs e) { } + public void LogMessageEvent(BuildMessageEventArgs e) { } + public void LogWarningEvent(BuildWarningEventArgs e) { } } +#endif } From 248d61f55d64892aae2f8d787806fb8c36ecb86b Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Fri, 30 Jan 2026 08:32:01 -0600 Subject: [PATCH 03/11] Update test/Microsoft.NET.TestFramework/TestContext.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/Microsoft.NET.TestFramework/TestContext.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/Microsoft.NET.TestFramework/TestContext.cs b/test/Microsoft.NET.TestFramework/TestContext.cs index fdc92d05e37e..685cc2946a50 100644 --- a/test/Microsoft.NET.TestFramework/TestContext.cs +++ b/test/Microsoft.NET.TestFramework/TestContext.cs @@ -68,8 +68,12 @@ public string TestAssetsDirectory public static string FindSdkAcquisitionArtifact(string filePattern) { string? shippingDir = Current.ShippingPackagesDirectory; - var files = Directory.GetFiles(shippingDir!, filePattern); + 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( From 120d4c08fad07b73596d4cfd298de4c9ae895728 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Fri, 30 Jan 2026 14:39:57 +0000 Subject: [PATCH 04/11] Utilize _testAssetsManager.CreateTestDirectory --- .../DeduplicateAssembliesWithLinksTests.cs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs b/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs index 92b6fc570b26..f2ee708ab229 100644 --- a/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs +++ b/test/sdk-tasks.Tests/DeduplicateAssembliesWithLinksTests.cs @@ -13,7 +13,7 @@ public class DeduplicateAssembliesWithLinksTests(ITestOutputHelper log) : SdkTes [Fact] public void WhenDuplicatesExistItCreatesHardLinks() { - var layoutDir = CreateTempDirectory(); + var layoutDir = _testAssetsManager.CreateTestDirectory().Path; var content = "duplicate assembly content"; var file1 = Path.Combine(layoutDir, "assembly1.dll"); @@ -51,7 +51,7 @@ public void WhenDuplicatesExistItCreatesHardLinks() [Fact] public void WhenDuplicatesExistItCreatesSymbolicLinks() { - var layoutDir = CreateTempDirectory(); + var layoutDir = _testAssetsManager.CreateTestDirectory().Path; var content = "duplicate assembly content"; var file1 = Path.Combine(layoutDir, "assembly1.dll"); @@ -80,7 +80,7 @@ public void WhenDuplicatesExistItCreatesSymbolicLinks() [Fact] public void ItSelectsMasterByDepthThenAlphabetically() { - var layoutDir = CreateTempDirectory(); + var layoutDir = _testAssetsManager.CreateTestDirectory().Path; var subDir1 = Path.Combine(layoutDir, "sub1"); var subDir2 = Path.Combine(layoutDir, "sub2"); var subSubDir = Path.Combine(subDir1, "nested"); @@ -121,7 +121,7 @@ public void ItSelectsMasterByDepthThenAlphabetically() [Fact] public void ItOnlyDeduplicatesAssemblies() { - var layoutDir = CreateTempDirectory(); + var layoutDir = _testAssetsManager.CreateTestDirectory().Path; var content = "shared content"; @@ -182,7 +182,7 @@ public void WhenLayoutDirectoryDoesNotExistItFails() [Fact] public void ItHandlesMultipleDuplicateGroups() { - var layoutDir = CreateTempDirectory(); + var layoutDir = _testAssetsManager.CreateTestDirectory().Path; // Group 1: duplicates with content A var contentA = "content A"; @@ -224,7 +224,7 @@ public void ItHandlesMultipleDuplicateGroups() [Fact] public void ItCreatesRelativeSymbolicLinks() { - var layoutDir = CreateTempDirectory(); + var layoutDir = _testAssetsManager.CreateTestDirectory().Path; var subDir = Path.Combine(layoutDir, "subdir"); Directory.CreateDirectory(subDir); @@ -267,13 +267,6 @@ private static DeduplicateAssembliesWithLinks CreateTask(string layoutDir, bool return task; } - private static string CreateTempDirectory() - { - var tempDir = Path.Combine(Path.GetTempPath(), "DeduplicateTests_" + Guid.NewGuid().ToString()); - Directory.CreateDirectory(tempDir); - return tempDir; - } - private static long GetInode(string filePath) { if (OperatingSystem.IsWindows()) From 472b8187fc2d5bfa7c3ef2c79cf60d403467b85b Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Fri, 30 Jan 2026 15:01:16 +0000 Subject: [PATCH 05/11] Remove unused test code for windows - will be coming later --- test/EndToEnd.Tests/GivenSdkArchives.cs | 120 +----------------------- 1 file changed, 2 insertions(+), 118 deletions(-) diff --git a/test/EndToEnd.Tests/GivenSdkArchives.cs b/test/EndToEnd.Tests/GivenSdkArchives.cs index 3cf7d5cb11fd..0ef1d0e001b9 100644 --- a/test/EndToEnd.Tests/GivenSdkArchives.cs +++ b/test/EndToEnd.Tests/GivenSdkArchives.cs @@ -1,10 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.ComponentModel; using System.Runtime.InteropServices; using EndToEnd.Tests.Utilities; -using Microsoft.Win32.SafeHandles; namespace EndToEnd.Tests; @@ -24,15 +22,8 @@ public void ItHasDeduplicatedAssemblies() Log.WriteLine($"Found SDK archive: {Path.GetFileName(archivePath)}"); string extractedPath = ExtractArchive(archivePath); - // Verify deduplication worked by checking for links - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - VerifyWindowsHardLinks(extractedPath); - } - else - { - VerifyLinuxSymbolicLinks(extractedPath); - } + // Verify deduplication worked by checking for symbolic links + SymbolicLinkHelpers.VerifyDirectoryHasRelativeSymlinks(extractedPath, Log, "archive"); } private string ExtractArchive(string archivePath) @@ -47,111 +38,4 @@ private string ExtractArchive(string archivePath) return extractPath; } - - private void VerifyWindowsHardLinks(string extractedPath) - { - var assemblies = Directory.GetFiles(extractedPath, "*", SearchOption.AllDirectories) - .Where(f => IsAssembly(f)) - .ToList(); - - Log.WriteLine($"Found {assemblies.Count} total assemblies in archive"); - - int hardLinkCount = 0; - foreach (var assembly in assemblies) - { - try - { - if (IsHardLinked(assembly)) - { - hardLinkCount++; - } - } - catch (Exception ex) - { - Log.WriteLine($"Warning: Failed to check {assembly}: {ex.Message}"); - } - } - - Log.WriteLine($"Found {hardLinkCount} hard linked assemblies"); - - Assert.True(hardLinkCount > SymbolicLinkHelpers.MinExpectedDeduplicatedLinks, - $"Expected more than {SymbolicLinkHelpers.MinExpectedDeduplicatedLinks} hard linked assemblies, but found only {hardLinkCount}. " + - "This suggests deduplication did not run correctly."); - } - - private void VerifyLinuxSymbolicLinks(string extractedPath) - { - SymbolicLinkHelpers.VerifyDirectoryHasRelativeSymlinks(extractedPath, Log, "archive"); - } - - private static bool IsAssembly(string filePath) - { - var ext = Path.GetExtension(filePath); - return ext.Equals(".dll", StringComparison.OrdinalIgnoreCase) || - ext.Equals(".exe", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsHardLinked(string filePath) - { - using var handle = CreateFile( - filePath, - GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - IntPtr.Zero, - OPEN_EXISTING, - 0, - IntPtr.Zero); - - if (handle.IsInvalid) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), - $"Failed to open file: {filePath}"); - } - - if (!GetFileInformationByHandle(handle, out var fileInfo)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), - $"Failed to get file information: {filePath}"); - } - - // Hard link if NumberOfLinks > 1 - return fileInfo.NumberOfLinks > 1; - } - - // Windows P/Invoke declarations - [StructLayout(LayoutKind.Sequential)] - private struct BY_HANDLE_FILE_INFORMATION - { - public uint FileAttributes; - public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; - public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; - public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; - public uint VolumeSerialNumber; - public uint FileSizeHigh; - public uint FileSizeLow; - public uint NumberOfLinks; - public uint FileIndexHigh; - public uint FileIndexLow; - } - - [DllImport("kernel32.dll", 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.dll", SetLastError = true)] - private static extern bool GetFileInformationByHandle( - SafeFileHandle hFile, - out BY_HANDLE_FILE_INFORMATION lpFileInformation); - - private const uint GENERIC_READ = 0x80000000; - private const uint FILE_SHARE_READ = 0x00000001; - private const uint FILE_SHARE_WRITE = 0x00000002; - private const uint FILE_SHARE_DELETE = 0x00000004; - private const uint OPEN_EXISTING = 3; } From 46ba7caa0de7f5a666a27ea26b9185362be3605f Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Fri, 30 Jan 2026 11:20:52 -0600 Subject: [PATCH 06/11] Apply suggestion from @baronfel Co-authored-by: Chet Husk --- src/Tasks/sdk-tasks/sdk-tasks.InTree.targets | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Tasks/sdk-tasks/sdk-tasks.InTree.targets b/src/Tasks/sdk-tasks/sdk-tasks.InTree.targets index 32f118e06a37..e7c318676b77 100644 --- a/src/Tasks/sdk-tasks/sdk-tasks.InTree.targets +++ b/src/Tasks/sdk-tasks/sdk-tasks.InTree.targets @@ -36,8 +36,7 @@ + TaskFactory="TaskHostFactory" /> From d13d44defde967950c161ef5d85761294ddc811f Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Tue, 3 Feb 2026 20:02:25 +0000 Subject: [PATCH 07/11] Change from using OSName to IsOSPlatform --- src/Layout/redist/targets/GenerateInstallerLayout.targets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Layout/redist/targets/GenerateInstallerLayout.targets b/src/Layout/redist/targets/GenerateInstallerLayout.targets index 878c5256d5d7..046baf402339 100644 --- a/src/Layout/redist/targets/GenerateInstallerLayout.targets +++ b/src/Layout/redist/targets/GenerateInstallerLayout.targets @@ -97,7 +97,7 @@ ReplaceBundledRuntimePackFilesWithSymbolicLinks" AfterTargets="AfterBuild"> - +