diff --git a/src/Tasks.UnitTests/CreateItem_Tests.cs b/src/Tasks.UnitTests/CreateItem_Tests.cs index d75da37b32b..a16a1f94745 100644 --- a/src/Tasks.UnitTests/CreateItem_Tests.cs +++ b/src/Tasks.UnitTests/CreateItem_Tests.cs @@ -444,5 +444,218 @@ public void LogUnixWarningUponItemCreationWithDriveEnumeration(string content, s Helpers.ExpectedBuildResult.SucceedWithWarning, _testOutput); } + + /// + /// Relative wildcard Include resolves against TaskEnvironment.ProjectDirectory, not cwd. + /// + [Fact] + public void WildcardInclude_ResolvesAgainstProjectDirectory() + { + using TestEnvironment env = TestEnvironment.Create(_testOutput); + + // Create a project directory with a file. + TransientTestFolder projectDir = env.CreateFolder(createFolder: true); + env.CreateFile(projectDir, "alpha.txt", "alpha"); + + // Create a separate directory that is NOT the project directory and put a different file there. + TransientTestFolder otherDir = env.CreateFolder(createFolder: true); + env.CreateFile(otherDir, "beta.txt", "beta"); + + // Set cwd to otherDir so that if the task used cwd, it would find beta.txt instead. + env.SetCurrentDirectory(otherDir.Path); + + CreateItem t = new CreateItem + { + BuildEngine = new MockEngine(_testOutput), + Include = new ITaskItem[] { new TaskItem("*.txt") }, + TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir.Path), + }; + + t.Execute().ShouldBeTrue(); + t.Include.Length.ShouldBe(1); + t.Include[0].ItemSpec.ShouldBe("alpha.txt"); + } + + /// + /// Relative wildcard Exclude resolves against TaskEnvironment.ProjectDirectory, not cwd. + /// The *.log exclude should match SampleFile.log in the project directory and filter it out. + /// + [Fact] + public void WildcardExclude_ResolvesAgainstProjectDirectory() + { + using TestEnvironment env = TestEnvironment.Create(_testOutput); + + TransientTestFolder projectDir = env.CreateFolder(createFolder: true); + env.CreateFile(projectDir, "SampleFile.log", "SampleFile"); + + // cwd points elsewhere — Exclude must still resolve *.log against projectDir. + TransientTestFolder otherDir = env.CreateFolder(createFolder: true); + env.SetCurrentDirectory(otherDir.Path); + + CreateItem t = new CreateItem + { + BuildEngine = new MockEngine(_testOutput), + Include = new ITaskItem[] { new TaskItem("*.log") }, + Exclude = new ITaskItem[] { new TaskItem("*.log") }, + TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir.Path), + }; + + t.Execute().ShouldBeTrue(); + t.Include.ShouldBeEmpty(); + } + + /// + /// RecursiveDir metadata is correctly set when wildcard expansion uses a custom ProjectDirectory. + /// + [Fact] + public void RecursiveDir_WithCustomProjectDirectory() + { + using TestEnvironment env = TestEnvironment.Create(_testOutput); + + TransientTestFolder projectDir = env.CreateFolder(createFolder: true); + string sampleSubdirectory = Path.Combine(projectDir.Path, "SampleSubdirectory"); + Directory.CreateDirectory(sampleSubdirectory); + File.WriteAllText(Path.Combine(sampleSubdirectory, "SampleFile.txt"), "content"); + + // Set cwd somewhere else to prove ProjectDirectory is used. + TransientTestFolder otherDir = env.CreateFolder(createFolder: true); + env.SetCurrentDirectory(otherDir.Path); + + CreateItem t = new CreateItem + { + BuildEngine = new MockEngine(_testOutput), + Include = new ITaskItem[] { new TaskItem($"**{Path.DirectorySeparatorChar}*.txt") }, + TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir.Path), + }; + + t.Execute().ShouldBeTrue(); + t.Include.Length.ShouldBe(1); + + string recursiveDir = t.Include[0].GetMetadata("RecursiveDir"); + recursiveDir.ShouldBe("SampleSubdirectory" + Path.DirectorySeparatorChar); + } + + /// + /// Absolute wildcard patterns are unaffected by ProjectDirectory — the same files are returned + /// regardless of what ProjectDirectory is set to. + /// + [Fact] + public void AbsoluteWildcard_IgnoresProjectDirectory() + { + using TestEnvironment env = TestEnvironment.Create(_testOutput); + + TransientTestFolder targetDir = env.CreateFolder(createFolder: true); + env.CreateFile(targetDir, "SampleFile.txt", "content"); + + // Use an unrelated ProjectDirectory. + TransientTestFolder unrelatedDir = env.CreateFolder(createFolder: true); + + string absolutePattern = Path.Combine(targetDir.Path, "*.txt"); + CreateItem t = new CreateItem + { + BuildEngine = new MockEngine(_testOutput), + Include = new ITaskItem[] { new TaskItem(absolutePattern) }, + TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(unrelatedDir.Path), + }; + + t.Execute().ShouldBeTrue(); + t.Include.Length.ShouldBe(1); + // Absolute pattern returns absolute path. + Path.GetFileName(t.Include[0].ItemSpec).ShouldBe("SampleFile.txt"); + } + + /// + /// When a relative wildcard ItemSpec has a multi-segment fixed directory containing a tilde + /// (e.g. "SubDir/ALONGD~1/*.txt"), GetLongPathName inside SplitFileSpec resolves the tilde + /// segment against CWD instead of TaskEnvironment.ProjectDirectory. If CWD contains a + /// directory whose 8.3 short name matches but whose long name differs, the resolution + /// produces the wrong long name, the absolutized path doesn't exist in the project directory, + /// and GetFiles returns no results. + /// + /// This test sets CWD to a directory whose "SubDir" contains a long-named child that maps + /// to the same 8.3 short name as the project directory's child. It then verifies that + /// CreateItem still finds the file in the project directory — which currently fails. + /// + [WindowsOnlyFact(additionalMessage: "8.3 short names are Windows-only.")] + public void WildcardWithShortName_ResolvesAgainstProjectDirectory_NotCwd() + { + using TestEnvironment env = TestEnvironment.Create(_testOutput); + + // Project directory: SubDir/ALongDirectoryNameHere/test.txt + TransientTestFolder projectDir = env.CreateFolder(createFolder: true); + string projectSubDir = Path.Combine(projectDir.Path, "SubDir"); + string projectChild = Path.Combine(projectSubDir, "ALongDirectoryNameHere"); + Directory.CreateDirectory(projectChild); + File.WriteAllText(Path.Combine(projectChild, "test.txt"), "project-content"); + + // Determine the 8.3 short name for the child directory. + string shortChildPath = NativeMethodsShared.GetShortFilePath(projectChild); + string shortChildName = Path.GetFileName(shortChildPath); + + // If 8.3 names are disabled on this volume, the short name won't contain '~' — skip. + if (!shortChildName.Contains("~")) + { + _testOutput.WriteLine($"Skipping: 8.3 names appear disabled (short name was '{shortChildName}')."); + return; + } + + // CWD directory: SubDir/ALongDirectoryNameThere/decoy.txt + // "ALongDirectoryNameThere" shares the first 6 characters with "ALongDirectoryNameHere", + // so in its own parent it also gets short name ALONGD~1. + TransientTestFolder cwdDir = env.CreateFolder(createFolder: true); + string cwdSubDir = Path.Combine(cwdDir.Path, "SubDir"); + string cwdChild = Path.Combine(cwdSubDir, "ALongDirectoryNameThere"); + Directory.CreateDirectory(cwdChild); + File.WriteAllText(Path.Combine(cwdChild, "decoy.txt"), "decoy-content"); + + // Verify the decoy also gets the same short name. + string shortDecoyPath = NativeMethodsShared.GetShortFilePath(cwdChild); + string shortDecoyName = Path.GetFileName(shortDecoyPath); + shortDecoyName.ShouldBe(shortChildName, customMessage: + "Both directories should have the same 8.3 short name since they share the first 6 characters.", + options: StringCompareShould.IgnoreCase); + + // Set CWD to cwdDir — GetLongPathName will resolve the short name against this directory. + env.SetCurrentDirectory(cwdDir.Path); + + // ItemSpec uses the 8.3 short name in a multi-segment relative path. + // SplitFileSpec splits into fixedDir = "SubDir/ALONGD~1/", GetLongPathName resolves + // "ALONGD~1" under "SubDir" in CWD → gets "ALongDirectoryNameThere" (WRONG). + // GetFileSearchData then absolutizes to "{projectDir}/SubDir/ALongDirectoryNameThere/" + // which doesn't exist → returns empty. + string itemSpec = Path.Combine("SubDir", shortChildName, "*.txt"); + CreateItem t = new CreateItem + { + BuildEngine = new MockEngine(_testOutput), + Include = new ITaskItem[] { new TaskItem(itemSpec) }, + TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir.Path), + }; + + t.Execute().ShouldBeTrue(); + t.Include.Length.ShouldBe(1, $"Expected 1 file from project directory, but got {t.Include.Length}. " + + "GetLongPathName likely resolved the short name against CWD instead of ProjectDirectory."); + Path.GetFileName(t.Include[0].ItemSpec).ShouldBe("test.txt"); + } + + /// + /// Pre-expanded literal items (no wildcards) produce identical output regardless of + /// ProjectDirectory, Exclude, and AdditionalMetadata settings. + /// + [Fact] + public void LiteralItems_UnaffectedByProjectDirectory() + { + CreateItem t = new CreateItem + { + BuildEngine = new MockEngine(_testOutput), + Include = new ITaskItem[] { new TaskItem("A.txt"), new TaskItem("B.txt") }, + Exclude = new ITaskItem[] { new TaskItem("B.txt") }, + AdditionalMetadata = new string[] { "Tag=Value" }, + }; + + t.Execute().ShouldBeTrue(); + t.Include.Length.ShouldBe(1); + t.Include[0].ItemSpec.ShouldBe("A.txt"); + t.Include[0].GetMetadata("Tag").ShouldBe("Value"); + } } } diff --git a/src/Tasks/CreateItem.cs b/src/Tasks/CreateItem.cs index cfb61b0919f..2107e091e5b 100644 --- a/src/Tasks/CreateItem.cs +++ b/src/Tasks/CreateItem.cs @@ -14,10 +14,14 @@ namespace Microsoft.Build.Tasks /// /// Forward a list of items from input to output. This allows dynamic item lists. /// - public class CreateItem : TaskExtension + [MSBuildMultiThreadableTask] + public class CreateItem : TaskExtension, IMultiThreadableTask { #region Properties + /// + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Output] public ITaskItem[] Include { get; set; } @@ -155,7 +159,12 @@ private List CreateOutputItems(Dictionary metadataTab { if (FileMatcher.HasWildcards(i.ItemSpec)) { - FileMatcher.Default.GetFileSpecInfo(i.ItemSpec, out string directoryPart, out string wildcardPart, out string filenamePart, out bool needsRecursion, out bool isLegalFileSpec); + // Absolutize against TaskEnvironment.ProjectDirectory so that any short-name (e.g. "ALONGD~1") + // segments inside the fixed directory portion are resolved against the project directory + // rather than the process current directory (which FileMatcher.GetLongPathName would otherwise use). + string absoluteItemSpec = TaskEnvironment.GetAbsolutePath(i.ItemSpec); + + FileMatcher.Default.GetFileSpecInfo(absoluteItemSpec, out string directoryPart, out string wildcardPart, out string filenamePart, out bool needsRecursion, out bool isLegalFileSpec); bool logDriveEnumeratingWildcard = FileMatcher.IsDriveEnumeratingWildcardPattern(directoryPart, wildcardPart); if (logDriveEnumeratingWildcard) { @@ -178,7 +187,7 @@ private List CreateOutputItems(Dictionary metadataTab } else if (isLegalFileSpec) { - (files, _, _, string? globFailure) = FileMatcher.Default.GetFiles(null /* use current directory */, i.ItemSpec); + (files, _, _, string? globFailure) = FileMatcher.Default.GetFiles(TaskEnvironment.ProjectDirectory, absoluteItemSpec); if (globFailure != null) { Log.LogMessage(MessageImportance.Low, globFailure); @@ -189,7 +198,7 @@ private List CreateOutputItems(Dictionary metadataTab TaskItem newItem = new TaskItem(i) { ItemSpec = file }; // Compute the RecursiveDir portion. - FileMatcher.Result match = FileMatcher.Default.FileMatch(i.ItemSpec, file); + FileMatcher.Result match = FileMatcher.Default.FileMatch(absoluteItemSpec, file); if (match.isLegalFileSpec && match.isMatch) { if (!string.IsNullOrEmpty(match.wildcardDirectoryPart))