diff --git a/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/DotNetHelper.cs b/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/DotNetHelper.cs index bce9b5424..60c49d132 100644 --- a/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/DotNetHelper.cs +++ b/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/DotNetHelper.cs @@ -36,7 +36,7 @@ internal class DotNetHelper } Directory.CreateDirectory(Config.DotNetDirectory); - Utilities.ExtractTarball(Config.SdkTarballPath, Config.DotNetDirectory); + Utilities.ExtractTarball(Config.SdkTarballPath, Config.DotNetDirectory, outputHelper); } IsMonoRuntime = DetermineIsMonoRuntime(Config.DotNetDirectory); diff --git a/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/OmniSharpTests.cs b/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/OmniSharpTests.cs index fdcb48ad5..c56f443fb 100644 --- a/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/OmniSharpTests.cs +++ b/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/OmniSharpTests.cs @@ -65,7 +65,7 @@ public class OmniSharpTests : SmokeTests await client.DownloadFileAsync(omniSharpTarballUrl, omniSharpTarballFile, OutputHelper); Directory.CreateDirectory(OmniSharpDirectory); - Utilities.ExtractTarball(omniSharpTarballFile, OmniSharpDirectory); + Utilities.ExtractTarball(omniSharpTarballFile, OmniSharpDirectory, OutputHelper); } } } diff --git a/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/Utilities.cs b/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/Utilities.cs index 86fc70b4b..e73a29939 100644 --- a/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/Utilities.cs +++ b/src/SourceBuild/content/test/Microsoft.DotNet.SourceBuild.SmokeTests/Utilities.cs @@ -16,11 +16,11 @@ namespace Microsoft.DotNet.SourceBuild.SmokeTests; public static class Utilities { - public static void ExtractTarball(string tarballPath, string outputDir) + public static void ExtractTarball(string tarballPath, string outputDir, ITestOutputHelper outputHelper) { - using FileStream fileStream = File.OpenRead(tarballPath); - using GZipStream decompressorStream = new(fileStream, CompressionMode.Decompress); - TarFile.ExtractToDirectory(decompressorStream, outputDir, true); + // TarFile doesn't properly handle hard links (https://github.com/dotnet/runtime/pull/85378#discussion_r1221817490), + // use 'tar' instead. + ExecuteHelper.ExecuteProcessValidateExitCode("tar", $"xzf {tarballPath} -C {outputDir}", outputHelper); } public static void ExtractTarball(string tarballPath, string outputDir, string targetFilePath) diff --git a/src/core-sdk-tasks/ReplaceDuplicateFilesWithHardLinks.cs b/src/core-sdk-tasks/ReplaceDuplicateFilesWithHardLinks.cs new file mode 100644 index 000000000..7fe6559fc --- /dev/null +++ b/src/core-sdk-tasks/ReplaceDuplicateFilesWithHardLinks.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#if !NETFRAMEWORK +#nullable enable + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.IO.Enumeration; +using System.IO.MemoryMappedFiles; +using System.Linq; +using System.Runtime.InteropServices; +#endif +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.DotNet.Build.Tasks +{ + /// + /// Replaces files that have the same content with hard links. + /// + public sealed class ReplaceDuplicateFilesWithHardLinks : Task + { + /// + /// The path to the directory. + /// + [Required] + public string Directory { get; set; } = ""; + +#if NETFRAMEWORK + public override bool Execute() + { + Log.LogError($"{nameof(ReplaceDuplicateFilesWithHardLinks)} is not supported on .NET Framework."); + return false; + } +#else + public override bool Execute() + { + if (OperatingSystem.IsWindows()) + { + Log.LogError($"{nameof(ReplaceDuplicateFilesWithHardLinks)} is not supported on Windows."); + return false; + } + + if (!System.IO.Directory.Exists(Directory)) + { + Log.LogError($"'{Directory}' does not exist."); + return false; + } + + // Find all non-empty, non-symbolic link files. + IEnumerable fse = new FileSystemEnumerable( + Directory, + (ref FileSystemEntry entry) => (FileInfo)entry.ToFileSystemInfo(), + new EnumerationOptions() + { + AttributesToSkip = FileAttributes.ReparsePoint, + RecurseSubdirectories = true + }) + { + ShouldIncludePredicate = (ref FileSystemEntry entry) => !entry.IsDirectory + && entry.Length > 0 + }; + + // Group them by file size. + IEnumerable filesGroupedBySize = fse.GroupBy(file => file.Length, + file => file.FullName, + (size, files) => files.ToArray()); + + // Replace files with same content with hard link. + foreach (var files in filesGroupedBySize) + { + for (int i = 0; i < files.Length; i++) + { + string? path1 = files[i]; + if (path1 is null) + { + continue; // already linked. + } + for (int j = i + 1; j < files.Length; j++) + { + string? path2 = files[j]; + if (path2 is null) + { + continue; // already linked. + } + + // note: There's no public API we can use to see if paths are already linked. + // We treat those paths as unlinked files, and link them again. + if (FilesHaveSameContent(path1, path2)) + { + ReplaceByLink(path1, path2); + + files[j] = null; + } + } + } + } + + return true; + } + + private unsafe bool FilesHaveSameContent(string path1, string path2) + { + using var mappedFile1 = MemoryMappedFile.CreateFromFile(path1, FileMode.Open); + using var accessor1 = mappedFile1.CreateViewAccessor(); + byte* ptr1 = null; + + using var mappedFile2 = MemoryMappedFile.CreateFromFile(path2, FileMode.Open); + using var accessor2 = mappedFile2.CreateViewAccessor(); + byte* ptr2 = null; + + try + { + accessor1.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr1); + Span span1 = new Span(ptr1, checked((int)accessor1.SafeMemoryMappedViewHandle.ByteLength)); + + accessor2.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr2); + Span span2 = new Span(ptr2, checked((int)accessor2.SafeMemoryMappedViewHandle.ByteLength)); + + return span1.SequenceEqual(span2); + } + finally + { + if (ptr1 != null) + { + accessor1.SafeMemoryMappedViewHandle.ReleasePointer(); + ptr1 = null; + } + if (ptr2 != null) + { + accessor2.SafeMemoryMappedViewHandle.ReleasePointer(); + ptr2 = null; + } + } + } + + void ReplaceByLink(string path1, string path2) + { + // To link, the target mustn't exist. Make a backup, so we can restore it when linking fails. + string path2Backup = $"{path2}.pre_link_backup"; + File.Move(path2, path2Backup); + + int rv = SystemNative_Link(path1, path2); + if (rv != 0) + { + var ex = new Win32Exception(); // Captures the LastError. + + Log.LogError($"Unable to link '{path2}' to '{path1}.': {ex}"); + + File.Move(path2Backup, path2); + + throw ex; + } + else + { + File.Delete(path2Backup); + + Log.LogMessage(MessageImportance.Normal, $"Linked '{path1}' and '{path2}'."); + } + } + + // This native method is used by the runtime to create hard links. It is not exposed through a public .NET API. + [DllImport("libSystem.Native", SetLastError = true)] + static extern int SystemNative_Link(string source, string link); +#endif + } +} diff --git a/src/core-sdk-tasks/core-sdk-tasks.csproj b/src/core-sdk-tasks/core-sdk-tasks.csproj index b90bc55a9..a105248a7 100644 --- a/src/core-sdk-tasks/core-sdk-tasks.csproj +++ b/src/core-sdk-tasks/core-sdk-tasks.csproj @@ -6,6 +6,7 @@ Microsoft.DotNet.Cli.Build $(DefineConstants);SOURCE_BUILD true + true diff --git a/src/redist/targets/BuildCoreSdkTasks.targets b/src/redist/targets/BuildCoreSdkTasks.targets index e93fd0d06..afd5d21bd 100644 --- a/src/redist/targets/BuildCoreSdkTasks.targets +++ b/src/redist/targets/BuildCoreSdkTasks.targets @@ -40,5 +40,6 @@ + diff --git a/src/redist/targets/GenerateLayout.targets b/src/redist/targets/GenerateLayout.targets index 8d1cf16f8..f12dd495f 100644 --- a/src/redist/targets/GenerateLayout.targets +++ b/src/redist/targets/GenerateLayout.targets @@ -567,14 +567,8 @@ - - - - - - - + Condition="'$(BundleRuntimePacks)' == 'true' and !$([MSBuild]::IsOSPlatform('WINDOWS'))"> +