diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAGenerateDepsFile.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAGenerateDepsFile.cs new file mode 100644 index 000000000000..8bcc63049b4f --- /dev/null +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAGenerateDepsFile.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using FluentAssertions; +using Microsoft.NET.TestFramework; +using Xunit; + +namespace Microsoft.NET.Build.Tasks.UnitTests +{ + public class GivenAGenerateDepsFile + { + private readonly string _depsFilePath; + + public GivenAGenerateDepsFile() + { + string testTempDir = Path.Combine(Path.GetTempPath(), "dotnetSdkTests"); + Directory.CreateDirectory(testTempDir); + _depsFilePath = Path.Combine(testTempDir, nameof(GivenAGenerateDepsFile) + ".deps.json"); + if (File.Exists(_depsFilePath)) + { + File.Delete(_depsFilePath); + } + } + + [Fact] + public void ItDoesNotOverwriteFileWithSameContent() + { + // Execute task first time + var task = CreateTestTask(); + task.PublicExecuteCore(); + var firstWriteTime = File.GetLastWriteTimeUtc(_depsFilePath); + + // Wait a bit to ensure timestamp would change if file is rewritten + Thread.Sleep(100); + + // Execute task again with same configuration + var task2 = CreateTestTask(); + task2.PublicExecuteCore(); + var secondWriteTime = File.GetLastWriteTimeUtc(_depsFilePath); + + // File should not have been rewritten when content is the same + secondWriteTime.Should().Be(firstWriteTime, "file should not be rewritten when content is unchanged"); + } + + private TestableGenerateDepsFile CreateTestTask() + { + return new TestableGenerateDepsFile + { + BuildEngine = new MockNeverCacheBuildEngine4(), + ProjectPath = "TestProject.csproj", + DepsFilePath = _depsFilePath, + TargetFramework = "net8.0", + AssemblyName = "TestProject", + AssemblyExtension = ".dll", + AssemblyVersion = "1.0.0.0", + IncludeMainProject = true, + CompileReferences = new MockTaskItem[0], + ResolvedNuGetFiles = new MockTaskItem[0], + ResolvedRuntimeTargetsFiles = new MockTaskItem[0], + RuntimeGraphPath = "" + }; + } + + private class TestableGenerateDepsFile : GenerateDepsFile + { + public void PublicExecuteCore() + { + base.ExecuteCore(); + } + } + } +} \ No newline at end of file diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAGenerateRuntimeConfigurationFiles.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAGenerateRuntimeConfigurationFiles.cs index 6da7349172c5..7c298a7e4ecb 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAGenerateRuntimeConfigurationFiles.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAGenerateRuntimeConfigurationFiles.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using FluentAssertions; +using Microsoft.Build.Utilities; using Microsoft.NET.TestFramework; using Xunit; @@ -218,6 +219,70 @@ public void GivenTargetMonikerItGeneratesShortName() }}"); } + [Fact] + public void ItDoesNotOverwriteFileWithSameContent() + { + // Execute task first time + var task = CreateBasicTestTask(); + task.PublicExecuteCore(); + var firstWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath); + + // Wait a bit to ensure timestamp would change if file is rewritten + Thread.Sleep(100); + + // Execute task again with same configuration + var task2 = CreateBasicTestTask(); + task2.PublicExecuteCore(); + var secondWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath); + + // File should not have been rewritten when content is the same + secondWriteTime.Should().Be(firstWriteTime, "file should not be rewritten when content is unchanged"); + } + + [Fact] + public void GivenDifferentRuntimeHostOptionsItWritesNewConfig() + { + // Execute task first time + var task = CreateBasicTestTask(); + task.PublicExecuteCore(); + var firstWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath); + + // Wait a bit to ensure timestamp would change if file is rewritten + Thread.Sleep(100); + + // Execute task again with different host options + var task2 = CreateBasicTestTask(); + task2.HostConfigurationOptions = [ + new TaskItem("System.Runtime.TieredCompilation", new Dictionary{{"Value", "false"}}), + new TaskItem("System.GC.Concurrent", new Dictionary{{"Value", "false"}}), + ]; + task2.PublicExecuteCore(); + var secondWriteTime = File.GetLastWriteTimeUtc(_runtimeConfigPath); + // File should have been rewritten when content is different + secondWriteTime.Should().BeAfter(firstWriteTime, "file should be rewritten when content is different"); + } + + private TestableGenerateRuntimeConfigurationFiles CreateBasicTestTask() + { + return new TestableGenerateRuntimeConfigurationFiles + { + BuildEngine = new MockNeverCacheBuildEngine4(), + TargetFrameworkMoniker = $".NETCoreApp,Version=v{ToolsetInfo.CurrentTargetFrameworkVersion}", + RuntimeConfigPath = _runtimeConfigPath, + RuntimeFrameworks = new[] + { + new MockTaskItem( + "Microsoft.NETCore.App", + new Dictionary + { + {"FrameworkName", "Microsoft.NETCore.App"}, {"Version", $"{ToolsetInfo.CurrentTargetFrameworkVersion}.0"} + } + ) + }, + RollForward = "LatestMinor" + }; + } + private class TestableGenerateRuntimeConfigurationFiles : GenerateRuntimeConfigurationFiles { public void PublicExecuteCore() diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs index 8b89525c9fd1..9927194e80cf 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs @@ -254,10 +254,54 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) DependencyContext dependencyContext = builder.Build(UserRuntimeAssemblies); var writer = new DependencyContextWriter(); - using (var fileStream = File.Create(depsFilePath)) + + bool shouldWriteFile = true; + var tempDepsFilePath = depsFilePath + ".tmp"; + + // Generate new content + using (var fileStream = File.Create(tempDepsFilePath)) { writer.Write(dependencyContext, fileStream); } + + // If file exists, check if content is different using streaming hash comparison + if (File.Exists(depsFilePath)) + { + Log.LogMessage("File {0} already exists, checking hash.", depsFilePath); + using var existingFileContentStream = File.OpenRead(depsFilePath); + var existingContentHash = HashingUtils.ComputeXXHash64(existingFileContentStream); + var existingContentHashRendered = BitConverter.ToString(existingContentHash).Replace("-", ""); + Log.LogMessage("Existing file hash: {0}", existingContentHashRendered); + using var newContentStream = File.OpenRead(tempDepsFilePath); + var newContentHash = HashingUtils.ComputeXXHash64(newContentStream); + var newContentHashRendered = BitConverter.ToString(newContentHash).Replace("-", ""); + Log.LogMessage("New content hash: {0}", newContentHashRendered); + // If hashes are equal, content is the same - don't write + if (existingContentHash.SequenceEqual(newContentHash)) + { + Log.LogMessage("File {0} is unchanged, skipping write.", depsFilePath); + shouldWriteFile = false; + } + } + + if (shouldWriteFile) + { + Log.LogMessage("Writing file {0}.", depsFilePath); +#if NET + File.Move(tempDepsFilePath, depsFilePath, overwrite: true); +#else + // For .NET Framework, we can't use File.Move because it doesn't overwrite the existing file + // so we delete the existing file first. + File.Delete(depsFilePath); + File.Move(tempDepsFilePath, depsFilePath); +#endif + } + else + { + // If we didn't write the file, delete the temporary file + Log.LogMessage("Deleting temporary file {0}.", tempDepsFilePath); + File.Delete(tempDepsFilePath); + } _filesWritten.Add(new TaskItem(depsFilePath)); if (ValidRuntimeIdentifierPlatformsForAssets != null) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateRuntimeConfigurationFiles.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateRuntimeConfigurationFiles.cs index d7eaad9c6fdb..b55e9ba073b6 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateRuntimeConfigurationFiles.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateRuntimeConfigurationFiles.cs @@ -176,7 +176,7 @@ private void WriteRuntimeConfig( AddAdditionalProbingPaths(config.RuntimeOptions, packageFolders); } - WriteToJsonFile(RuntimeConfigPath, config); + WriteToJsonFile(Log, RuntimeConfigPath, config); _filesWritten.Add(new TaskItem(RuntimeConfigPath)); } @@ -340,7 +340,7 @@ private void WriteDevRuntimeConfig(IList packageFolders) AddAdditionalProbingPaths(devConfig.RuntimeOptions, packageFolders); - WriteToJsonFile(RuntimeConfigDevPath, devConfig); + WriteToJsonFile(Log, RuntimeConfigDevPath, devConfig); _filesWritten.Add(new TaskItem(RuntimeConfigDevPath)); } @@ -381,7 +381,7 @@ private static string EnsureNoTrailingDirectorySeparator(string path) return path; } - private static void WriteToJsonFile(string fileName, object value) + private static void WriteToJsonFile(Logger log, string filePath, object value) { JsonSerializer serializer = new() { @@ -390,10 +390,55 @@ private static void WriteToJsonFile(string fileName, object value) DefaultValueHandling = DefaultValueHandling.Ignore }; - using (JsonTextWriter writer = new(new StreamWriter(File.Create(fileName)))) + bool shouldWriteFile = true; + var tempFilePath = filePath + ".tmp"; + + // Generate new content + using (JsonTextWriter writer = new(new StreamWriter(File.Create(tempFilePath)))) { serializer.Serialize(writer, value); } + + // If file exists, check if content is different using streaming hash comparison + if (File.Exists(filePath)) + { + log.LogMessage("File {0} already exists, checking hash.", filePath); + // stream positions are reset as part of these utility calls + using var existingContentStream = File.OpenRead(filePath); + var existingContentHash = HashingUtils.ComputeXXHash64(existingContentStream); + var existingContentHashRendered = BitConverter.ToString(existingContentHash).Replace("-", ""); + log.LogMessage("Existing file hash: {0}", existingContentHashRendered); + using var newContentStream = File.OpenRead(tempFilePath); + var newContentHash = HashingUtils.ComputeXXHash64(newContentStream); + var newContentHashRendered = BitConverter.ToString(newContentHash).Replace("-", ""); + log.LogMessage("New content hash: {0}", existingContentHashRendered); + // If hashes are equal, content is the same - don't write + if (existingContentHash.SequenceEqual(newContentHash)) + { + log.LogMessage("File {0} is unchanged, skipping write.", filePath); + shouldWriteFile = false; + } + + } + + if (shouldWriteFile) + { + log.LogMessage("Writing file {0}.", filePath); +#if NET + File.Move(tempFilePath, filePath, overwrite: true); +#else + // For .NET Framework, we can't use File.Move because it doesn't overwrite the existing file + // so we delete the existing file first. + File.Delete(filePath); + File.Move(tempFilePath, filePath); +#endif + } + else + { + // If we didn't write the file, delete the temporary file + log.LogMessage("Deleting temporary file {0}.", tempFilePath); + File.Delete(tempFilePath); + } } } } diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/HashingUtils.cs b/src/Tasks/Microsoft.NET.Build.Tasks/HashingUtils.cs new file mode 100644 index 000000000000..667ed4ff74d5 --- /dev/null +++ b/src/Tasks/Microsoft.NET.Build.Tasks/HashingUtils.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Hashing; + +namespace Microsoft.NET.Build.Tasks; + +public static class HashingUtils +{ + /// + /// Computes the XxHash64 hash of a file. + /// + /// A stream to read for the hash. If the stream is seekable it will be reset to its incoming position. + public static byte[] ComputeXXHash64(Stream content) + { + var initialPosition = content.CanSeek ? content.Position : 0; + var hasher = new XxHash64(); + hasher.Append(content); + if (content.CanSeek) + { + content.Position = initialPosition; + } + return hasher.GetCurrentHash(); + } +}