diff --git a/src/StaticWebAssetsSdk/Tasks/Utils/AssetToCompress.cs b/src/StaticWebAssetsSdk/Tasks/Utils/AssetToCompress.cs index 537feb3e48f2..54f0e531f22b 100644 --- a/src/StaticWebAssetsSdk/Tasks/Utils/AssetToCompress.cs +++ b/src/StaticWebAssetsSdk/Tasks/Utils/AssetToCompress.cs @@ -12,10 +12,35 @@ internal static class AssetToCompress { public static bool TryFindInputFilePath(ITaskItem assetToCompress, TaskLoggingHelper log, out string fullPath) { + var relatedAsset = assetToCompress.GetMetadata("RelatedAsset"); + var relatedAssetOriginalItemSpec = assetToCompress.GetMetadata("RelatedAssetOriginalItemSpec"); + + var relatedAssetExists = File.Exists(relatedAsset); + var originalItemSpecExists = File.Exists(relatedAssetOriginalItemSpec); + + // When both paths exist and point to different files, prefer the newer one. + // This handles incremental builds where the source file (OriginalItemSpec) may be + // newer than the destination (RelatedAsset), which hasn't been copied yet. + if (relatedAssetExists && originalItemSpecExists && + !string.Equals(relatedAsset, relatedAssetOriginalItemSpec, StringComparison.OrdinalIgnoreCase)) + { + var relatedAssetTime = File.GetLastWriteTimeUtc(relatedAsset); + var originalItemSpecTime = File.GetLastWriteTimeUtc(relatedAssetOriginalItemSpec); + + if (originalItemSpecTime > relatedAssetTime) + { + log.LogMessage(MessageImportance.Low, "Asset '{0}' using original item spec '{1}' because it is newer than '{2}'.", + assetToCompress.ItemSpec, + relatedAssetOriginalItemSpec, + relatedAsset); + fullPath = relatedAssetOriginalItemSpec; + return true; + } + } + // Check RelatedAsset first (the asset's Identity path) as it's more reliable. // RelatedAssetOriginalItemSpec may point to a project file (e.g., .esproj) rather than the actual asset. - var relatedAsset = assetToCompress.GetMetadata("RelatedAsset"); - if (File.Exists(relatedAsset)) + if (relatedAssetExists) { log.LogMessage(MessageImportance.Low, "Asset '{0}' found at path '{1}'.", assetToCompress.ItemSpec, @@ -24,8 +49,7 @@ public static bool TryFindInputFilePath(ITaskItem assetToCompress, TaskLoggingHe return true; } - var relatedAssetOriginalItemSpec = assetToCompress.GetMetadata("RelatedAssetOriginalItemSpec"); - if (File.Exists(relatedAssetOriginalItemSpec)) + if (originalItemSpecExists) { log.LogMessage(MessageImportance.Low, "Asset '{0}' found at original item spec '{1}'.", assetToCompress.ItemSpec, diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/AssetToCompressTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/AssetToCompressTest.cs index 7b18e8ffd89e..1c6a096af050 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/AssetToCompressTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/AssetToCompressTest.cs @@ -107,10 +107,17 @@ public void TryFindInputFilePath_ReturnsError_WhenNeitherPathExists() public void TryFindInputFilePath_PrefersRelatedAsset_OverRelatedAssetOriginalItemSpec_WhenBothExist() { // Arrange - create two files to simulate the scenario where both metadata values point to existing files + // Make RelatedAsset newer to ensure it's preferred in the normal case var relatedAssetPath = Path.Combine(_testDirectory, "correct-asset.js"); var originalItemSpecPath = Path.Combine(_testDirectory, "project-file.esproj"); - File.WriteAllText(relatedAssetPath, "// correct JavaScript content"); + + // Create originalItemSpec first (older) File.WriteAllText(originalItemSpecPath, ""); + File.SetLastWriteTimeUtc(originalItemSpecPath, DateTime.UtcNow.AddMinutes(-5)); + + // Create RelatedAsset second (newer) + File.WriteAllText(relatedAssetPath, "// correct JavaScript content"); + File.SetLastWriteTimeUtc(relatedAssetPath, DateTime.UtcNow); var assetToCompress = new TaskItem("test.js.gz"); assetToCompress.SetMetadata("RelatedAsset", relatedAssetPath); @@ -152,8 +159,14 @@ public void TryFindInputFilePath_HandlesEsprojScenario_WhereOriginalItemSpecPoin var actualJsFile = Path.Combine(_testDirectory, "dist", "app.min.js"); Directory.CreateDirectory(Path.GetDirectoryName(actualJsFile)); + + // Create esproj first (older) File.WriteAllText(esprojFile, ""); + File.SetLastWriteTimeUtc(esprojFile, DateTime.UtcNow.AddMinutes(-5)); + + // Create actual JS file second (newer) File.WriteAllText(actualJsFile, "// actual JavaScript content"); + File.SetLastWriteTimeUtc(actualJsFile, DateTime.UtcNow); var assetToCompress = new TaskItem(Path.Combine(_testDirectory, "compressed", "app.min.js.gz")); // RelatedAsset should contain the correct path to the actual JS file @@ -170,4 +183,92 @@ public void TryFindInputFilePath_HandlesEsprojScenario_WhereOriginalItemSpecPoin fullPath.Should().NotBe(esprojFile); _errorMessages.Should().BeEmpty(); } + + [Fact] + public void TryFindInputFilePath_PrefersNewerFile_WhenBothFilesExistAndOriginalItemSpecIsNewer() + { + // Arrange - simulate incremental build scenario where source file (OriginalItemSpec) + // is newer than destination file (RelatedAsset) because the copy hasn't happened yet. + // This is the scenario that causes SRI integrity failures in Blazor WASM (issue #65271). + var destinationFile = Path.Combine(_testDirectory, "wwwroot", "_framework", "dotnet.js"); + var sourceFile = Path.Combine(_testDirectory, "obj", "Debug", "net11.0", "dotnet.js"); + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)); + Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)); + + // Create destination file first (older) + File.WriteAllText(destinationFile, "// old content with stale fingerprints"); + var oldTime = DateTime.UtcNow.AddMinutes(-5); + File.SetLastWriteTimeUtc(destinationFile, oldTime); + + // Create source file second (newer) - this simulates a rebuild + File.WriteAllText(sourceFile, "// new content with updated fingerprints"); + File.SetLastWriteTimeUtc(sourceFile, DateTime.UtcNow); + + var assetToCompress = new TaskItem(Path.Combine(_testDirectory, "compressed", "dotnet.js.gz")); + assetToCompress.SetMetadata("RelatedAsset", destinationFile); + assetToCompress.SetMetadata("RelatedAssetOriginalItemSpec", sourceFile); + + // Act + var result = AssetToCompress.TryFindInputFilePath(assetToCompress, _log, out var fullPath); + + // Assert - should use the NEWER source file, not the stale destination + result.Should().BeTrue(); + fullPath.Should().Be(sourceFile); + fullPath.Should().NotBe(destinationFile); + _errorMessages.Should().BeEmpty(); + _logMessages.Should().Contain(m => m.Contains("newer")); + } + + [Fact] + public void TryFindInputFilePath_UsesRelatedAsset_WhenBothFilesExistButRelatedAssetIsNewer() + { + // Arrange - when destination file is newer (normal case after copy), use it + var destinationFile = Path.Combine(_testDirectory, "wwwroot", "_framework", "script.js"); + var sourceFile = Path.Combine(_testDirectory, "obj", "Debug", "script.js"); + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)); + Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)); + + // Create source file first (older) + File.WriteAllText(sourceFile, "// source content"); + var oldTime = DateTime.UtcNow.AddMinutes(-5); + File.SetLastWriteTimeUtc(sourceFile, oldTime); + + // Create destination file second (newer) - this simulates normal post-copy state + File.WriteAllText(destinationFile, "// destination content"); + File.SetLastWriteTimeUtc(destinationFile, DateTime.UtcNow); + + var assetToCompress = new TaskItem(Path.Combine(_testDirectory, "compressed", "script.js.gz")); + assetToCompress.SetMetadata("RelatedAsset", destinationFile); + assetToCompress.SetMetadata("RelatedAssetOriginalItemSpec", sourceFile); + + // Act + var result = AssetToCompress.TryFindInputFilePath(assetToCompress, _log, out var fullPath); + + // Assert - should use destination file since it's newer + result.Should().BeTrue(); + fullPath.Should().Be(destinationFile); + _errorMessages.Should().BeEmpty(); + } + + [Fact] + public void TryFindInputFilePath_UsesRelatedAsset_WhenBothPathsPointToSameFile() + { + // Arrange - when both paths point to the same file (case-insensitive), + // don't bother with timestamp comparison + var assetToCompress = new TaskItem("test.js.gz"); + assetToCompress.SetMetadata("RelatedAsset", _testFilePath); + assetToCompress.SetMetadata("RelatedAssetOriginalItemSpec", _testFilePath.ToUpperInvariant()); + + // Act + var result = AssetToCompress.TryFindInputFilePath(assetToCompress, _log, out var fullPath); + + // Assert + result.Should().BeTrue(); + fullPath.Should().Be(_testFilePath); + _errorMessages.Should().BeEmpty(); + // Should NOT log the "newer" message since paths are the same + _logMessages.Should().NotContain(m => m.Contains("newer")); + } }