diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs index 00d4f39c69e1..c8483fca3427 100644 --- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs +++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs @@ -13,10 +13,14 @@ namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; // The manifest needs to always be case sensitive, since we don't know what the final runtime environment // will be. The runtime is responsible for merging the tree nodes in the manifest when the underlying OS // is case insensitive. -public class GenerateStaticWebAssetsDevelopmentManifest : Task +[MSBuildMultiThreadableTask] +public class GenerateStaticWebAssetsDevelopmentManifest : Task, IMultiThreadableTask { private static readonly char[] _separator = ['/']; + /// + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public string Source { get; set; } @@ -34,7 +38,8 @@ public class GenerateStaticWebAssetsDevelopmentManifest : Task public override bool Execute() { - if (File.Exists(ManifestPath) && File.GetLastWriteTimeUtc(ManifestPath) > File.GetLastWriteTimeUtc(CacheFilePath)) + AbsolutePath manifestPath = TaskEnvironment.GetAbsolutePath(ManifestPath); + if (File.Exists(manifestPath) && File.GetLastWriteTimeUtc(manifestPath) > File.GetLastWriteTimeUtc(TaskEnvironment.GetAbsolutePath(CacheFilePath))) { Log.LogMessage(MessageImportance.Low, "Skipping manifest generation because manifest file '{0}' is up to date.", ManifestPath); return true; @@ -49,10 +54,10 @@ public override bool Execute() } var manifest = ComputeDevelopmentManifest( - StaticWebAsset.FromTaskItemGroup(Assets), + StaticWebAsset.FromTaskItemGroup(Assets, TaskEnvironment), DiscoveryPatterns.Select(StaticWebAssetsDiscoveryPattern.FromTaskItem)); - PersistManifest(manifest); + PersistManifest(manifest, manifestPath); } catch (Exception ex) { @@ -125,7 +130,7 @@ private IEnumerable ComputeManifestAssets(IEnumerable().Object, + TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir), + Source = "CurrentProjectId", + Assets = [CreateAssetItem("css/site.css")], + DiscoveryPatterns = [], + ManifestPath = relativeManifestPath, + CacheFilePath = relativeCacheFilePath + }; + + task.Execute().Should().BeTrue(); + + var expectedPath = Path.Combine(projectDir, relativeManifestPath); + File.Exists(expectedPath).Should().BeTrue("the manifest should be written under the project dir, not the process CWD"); + + var incorrectPath = Path.Combine(spawnDir, relativeManifestPath); + File.Exists(incorrectPath).Should().BeFalse(); + } + finally + { + Directory.SetCurrentDirectory(originalCurrentDirectory); + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, recursive: true); + } + } + } + + [TestMethod] + public void ResolvesExistingManifestProbeRelativeToProjectDirectory_NotProcessCurrentDirectory() + { + // Verifies that the File.Exists/File.ReadAllBytes change-detection probe in PersistManifest (and + // the up-to-date check in Execute) is rooted against TaskEnvironment.ProjectDirectory rather than + // the process CWD. A decoy manifest is planted in the process CWD at the same relative path. If the + // task read that decoy as the "existing" manifest it would overwrite the decoy or skip writing the + // project-dir file. The correct behavior is to ignore the decoy entirely: create the manifest under + // the project dir and leave the decoy untouched. + var testRoot = Path.Combine(AppContext.BaseDirectory, nameof(GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest), Guid.NewGuid().ToString("N")); + var projectDir = Path.Combine(testRoot, "project"); + var spawnDir = Path.Combine(testRoot, "spawn"); + var relativeManifestPath = Path.Combine("obj", "staticwebassets.development.json"); + var relativeCacheFilePath = Path.Combine("obj", "staticwebassets.build.cache.json"); + Directory.CreateDirectory(Path.Combine(projectDir, "obj")); + Directory.CreateDirectory(Path.Combine(spawnDir, "obj")); + + const string decoyContents = "DECOY - must not be read or overwritten"; + var decoyPath = Path.Combine(spawnDir, relativeManifestPath); + File.WriteAllText(decoyPath, decoyContents); + + var originalCurrentDirectory = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(spawnDir); + + var task = new GenerateStaticWebAssetsDevelopmentManifest + { + BuildEngine = new Mock().Object, + TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir), + Source = "CurrentProjectId", + Assets = [CreateAssetItem("css/site.css")], + DiscoveryPatterns = [], + ManifestPath = relativeManifestPath, + CacheFilePath = relativeCacheFilePath + }; + + task.Execute().Should().BeTrue(); + + var expectedPath = Path.Combine(projectDir, relativeManifestPath); + File.Exists(expectedPath).Should().BeTrue("the existence probe must target the project dir, find nothing, and create the manifest there"); + + File.ReadAllText(decoyPath).Should().Be(decoyContents, "the decoy in the process CWD must be neither read nor overwritten"); + } + finally + { + Directory.SetCurrentDirectory(originalCurrentDirectory); + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, recursive: true); + } + } + } + + private static ITaskItem CreateAssetItem(string relativePath) + { + var asset = new StaticWebAsset + { + Identity = relativePath, + SourceId = "OtherPackage", + SourceType = StaticWebAsset.SourceTypes.Discovered, + ContentRoot = "wwwroot", + BasePath = "_content/Base", + RelativePath = relativePath, + AssetKind = StaticWebAsset.AssetKinds.All, + AssetMode = StaticWebAsset.AssetModes.All, + AssetRole = StaticWebAsset.AssetRoles.Primary, + RelatedAsset = "", + AssetTraitName = "", + AssetTraitValue = "", + CopyToOutputDirectory = "", + CopyToPublishDirectory = "", + OriginalItemSpec = relativePath, + Integrity = "integrity", + Fingerprint = "fingerprint", + LastWriteTime = DateTime.UtcNow, + FileLength = 10, + }; + + return asset.ToTaskItem(); + } +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestTest.cs index e16a9d7c3203..a6e711c76888 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestTest.cs @@ -28,11 +28,14 @@ public void SkipsManifestGenerationWhen_ThereAreNoAssetsNorDiscoveryPatterns() buildEngine.Setup(e => e.LogMessageEvent(It.IsAny())) .Callback(args => messages.Add(args.Message)); + var manifestDirectory = Path.Combine(AppContext.BaseDirectory, nameof(SkipsManifestGenerationWhen_ThereAreNoAssetsNorDiscoveryPatterns), Guid.NewGuid().ToString("N")); var task = new GenerateStaticWebAssetsDevelopmentManifest() { BuildEngine = buildEngine.Object, Assets = Array.Empty(), - DiscoveryPatterns = Array.Empty() + DiscoveryPatterns = Array.Empty(), + ManifestPath = Path.Combine(manifestDirectory, "staticwebassets.development.json"), + CacheFilePath = Path.Combine(manifestDirectory, "staticwebassets.build.cache.json") }; // Act