diff --git a/src/Tasks.UnitTests/ResourceHandling/GenerateResource_Tests.cs b/src/Tasks.UnitTests/ResourceHandling/GenerateResource_Tests.cs index e11e7c0d979..70ac4f19f7b 100644 --- a/src/Tasks.UnitTests/ResourceHandling/GenerateResource_Tests.cs +++ b/src/Tasks.UnitTests/ResourceHandling/GenerateResource_Tests.cs @@ -446,6 +446,55 @@ public void ForceOutOfDateLinked(bool usePreserialized) } } + /// + /// Force out-of-date with ShouldRebuildResgenOutputFile on a linked text file. + /// This is the regression test for the bug where linked text files + /// were not tracked in the dependency cache, so modifying them did not trigger + /// resource regeneration. + /// + [Fact] + public void ForceOutOfDateLinkedTextFile() + { + var folder = _env.CreateFolder(createFolder: true); + var linkedTextFile = folder.CreateFile("linked.txt", "original content"); + + // Build a resx that references the text file via ResXFileRef as System.String, + string linkedTextData = + " \xd\xa" + + " " + linkedTextFile.Path + ";System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8\xd\xa" + + " \xd\xa"; + + string resxFile = Utilities.WriteTestResX(false, null, linkedTextData, _env.CreateFile(folder, ".resx").Path, includeDefaultString: false); + string stateFile = _env.GetTempFile(".cache").Path; + + // First run: generate resources. + GenerateResource t = Utilities.CreateTask(_output, usePreserialized: true, _env); + t.Sources = new ITaskItem[] { new TaskItem(resxFile) }; + t.StateFile = new TaskItem(stateFile); + Utilities.ExecuteTask(t); + + Path.GetExtension(t.OutputResources[0].ItemSpec).ShouldBe(".resources"); + Utilities.AssertStateFileWasWritten(t); + + // Read back the compiled .resources and verify it contains the original content. + Utilities.ReadResourceValue(t.OutputResources[0].ItemSpec, "LinkedText").ShouldBe("original content"); + + // Update the linked text file with new content and set its timestamp + // unambiguously into the future so the task detects it as newer than the output, + // regardless of filesystem timestamp granularity. + File.WriteAllText(linkedTextFile.Path, "updated content"); + File.SetLastWriteTime(linkedTextFile.Path, DateTime.Now.AddDays(1)); + + // Second run: should detect the linked text file is newer and regenerate. + GenerateResource t2 = Utilities.CreateTask(_output, usePreserialized: true, _env); + t2.Sources = new ITaskItem[] { new TaskItem(resxFile) }; + t2.StateFile = new TaskItem(stateFile); + Utilities.ExecuteTask(t2); + + // Read back the compiled .resources and verify it contains the updated content. + Utilities.ReadResourceValue(t2.OutputResources[0].ItemSpec, "LinkedText").ShouldBe("updated content"); + } + [Fact] public void QuestionOutOfDateByDeletion() { @@ -4036,6 +4085,24 @@ public static void AssertLogNotContains(GenerateResource t, string message) Assert.DoesNotContain(message, ((MockEngine)t.BuildEngine).Log); } + /// + /// Reads a single resource value by key from a compiled .resources file. + /// + public static object ReadResourceValue(string resourcesFilePath, string resourceName) + { + using var reader = new System.Resources.ResourceReader(resourcesFilePath); + IDictionaryEnumerator enumerator = reader.GetEnumerator(); + while (enumerator.MoveNext()) + { + if ((string)enumerator.Key == resourceName) + { + return enumerator.Value; + } + } + + throw new KeyNotFoundException($"Resource '{resourceName}' not found in '{resourcesFilePath}'."); + } + /// /// Given an array of ITaskItems, checks to make sure that at least one read tlog and at least one /// write tlog exist, and that they were written to disk. If that is not true, asserts. @@ -4234,7 +4301,7 @@ public static string WriteTestText(string tagName, string oneLine) /// The name of a linked-in bitmap. use 'null' for no bitmap. /// The content of the resx blob as a string /// The name of the text file - public static string GetTestResXContent(bool useType, string linkedBitmap, string extraToken, bool useInvalidType) + public static string GetTestResXContent(bool useType, string linkedBitmap, string extraToken, bool useInvalidType, bool includeDefaultString = true) { StringBuilder resgenFileContents = new StringBuilder(); @@ -4253,11 +4320,14 @@ public static string GetTestResXContent(bool useType, string linkedBitmap, strin + " System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089\xd\xa" + " \xd\xa"); - resgenFileContents.Append( - // A plain old string value. - " \xd\xa" - + " MyValue\xd\xa" - + " \xd\xa"); + if (includeDefaultString) + { + resgenFileContents.Append( + // A plain old string value. + " \xd\xa" + + " MyValue\xd\xa" + + " \xd\xa"); + } if (extraToken != null) { @@ -4312,9 +4382,9 @@ public static string GetTestResXContent(bool useType, string linkedBitmap, strin /// Indicates whether to include an enum to test type-specific resource encoding with assembly references /// The name of a linked-in bitmap. use 'null' for no bitmap. /// The name of the resx file - public static string WriteTestResX(bool useType, string linkedBitmap, string extraToken, string resxFileToWrite = null, TestEnvironment env = null) + public static string WriteTestResX(bool useType, string linkedBitmap, string extraToken, string resxFileToWrite = null, TestEnvironment env = null, bool includeDefaultString = true) { - return WriteTestResX(useType, linkedBitmap, extraToken, useInvalidType: false, resxFileToWrite: resxFileToWrite); + return WriteTestResX(useType, linkedBitmap, extraToken, useInvalidType: false, resxFileToWrite: resxFileToWrite, includeDefaultString: includeDefaultString); } /// @@ -4323,11 +4393,11 @@ public static string WriteTestResX(bool useType, string linkedBitmap, string ext /// Indicates whether to include an enum to test type-specific resource encoding with assembly references /// The name of a linked-in bitmap. use 'null' for no bitmap. /// The name of the resx file - public static string WriteTestResX(bool useType, string linkedBitmap, string extraToken, bool useInvalidType, string resxFileToWrite = null, TestEnvironment env = null) + public static string WriteTestResX(bool useType, string linkedBitmap, string extraToken, bool useInvalidType, string resxFileToWrite = null, TestEnvironment env = null, bool includeDefaultString = true) { string resgenFile = resxFileToWrite; - string contents = GetTestResXContent(useType, linkedBitmap, extraToken, useInvalidType); + string contents = GetTestResXContent(useType, linkedBitmap, extraToken, useInvalidType, includeDefaultString); if (env == null) { diff --git a/src/Tasks.UnitTests/ResourceHandling/MSBuildResXReader_Tests.cs b/src/Tasks.UnitTests/ResourceHandling/MSBuildResXReader_Tests.cs index c161b862071..9ebe60a62ee 100644 --- a/src/Tasks.UnitTests/ResourceHandling/MSBuildResXReader_Tests.cs +++ b/src/Tasks.UnitTests/ResourceHandling/MSBuildResXReader_Tests.cs @@ -145,7 +145,7 @@ public void LoadsStringFromFileRefAsString(string stringType) ResourceHandling\TextFile1.txt;{stringType};utf-8 "), null, false); - AssertSingleStringResource(resxWithLinkedString, "TextFile1", "Contents of TextFile1"); + AssertSingleStringResourceFromFile(resxWithLinkedString, "TextFile1", "Contents of TextFile1"); } [Fact] @@ -179,7 +179,7 @@ public void LoadsStringFromFileRefAsStringWithShiftJISEncoding() Path.Combine(baseDir.Path, nameof(LoadsStringFromFileRefAsStringWithShiftJISEncoding) + ".resx"), useRelativePath: true); - AssertSingleStringResource(resxWithLinkedString, "TextFile1", JapaneseString); + AssertSingleStringResourceFromFile(resxWithLinkedString, "TextFile1", JapaneseString); } } @@ -193,6 +193,16 @@ private static void AssertSingleStringResource(IReadOnlyList resource .Value.ShouldBe(value); } + private static void AssertSingleStringResourceFromFile(IReadOnlyList resources, string name, string value) + { + resources.ShouldHaveSingleItem(); + + resources[0].Name.ShouldBe(name); + + resources[0].ShouldBeAssignableTo() + .Value.ShouldBe(value); + } + [Fact] public void PassesThroughBitmapInResx() { @@ -306,7 +316,7 @@ public void ResXFileRefToMemoryStream(string typeNameInResx) "), null, false); var resource = resources.ShouldHaveSingleItem() - .ShouldBeOfType(); + .ShouldBeOfType(); resource.Name.ShouldBe("Image1"); byte[] bytes = new byte[4]; @@ -333,6 +343,37 @@ public void AssemblyElementWithNoAliasInfersSimpleName() resource.StringRepresentation.ShouldBe("Blue"); } + [Fact] + public void InlineStringIsNotLinkedFileResource() + { + var resources = MSBuildResXReader.GetResourcesFromString( + ResXHelper.SurroundWithBoilerplate( + @" + StringValue + "), null, false); + + resources.ShouldHaveSingleItem(); + resources[0].ShouldBeOfType(); + } + + [Fact] + public void FileRefStringIsLinkedFileResource() + { + File.Exists(Path.Combine("ResourceHandling", "TextFile1.txt")).ShouldBeTrue("Test deployment is missing None files"); + + var resources = MSBuildResXReader.GetResourcesFromString( + ResXHelper.SurroundWithBoilerplate( +$@" + + ResourceHandling\TextFile1.txt;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8 + "), null, false); + + resources.ShouldHaveSingleItem(); + resources[0].ShouldBeOfType(); + resources[0].ShouldBeAssignableTo() + .LinkedFilePath.ShouldNotBeNull(); + } + // TODO: invalid resx xml // TODO: valid xml, but invalid resx-specific data diff --git a/src/Tasks.UnitTests/ResourceHandling/ResGenDependencies_Tests.cs b/src/Tasks.UnitTests/ResourceHandling/ResGenDependencies_Tests.cs index 6538fef25df..b676ff9817c 100644 --- a/src/Tasks.UnitTests/ResourceHandling/ResGenDependencies_Tests.cs +++ b/src/Tasks.UnitTests/ResourceHandling/ResGenDependencies_Tests.cs @@ -6,15 +6,33 @@ using System.Reflection; using Microsoft.Build.Shared; using Microsoft.Build.Tasks; +using Microsoft.Build.Tasks.ResourceHandling; +using Microsoft.Build.Tasks.UnitTests.ResourceHandling; +using Microsoft.Build.UnitTests; using Shouldly; using Xunit; +using Xunit.Abstractions; #nullable disable namespace Microsoft.Build.UnitTests { - public sealed class ResGenDependencies_Tests + public sealed class ResGenDependencies_Tests : IDisposable { + private readonly TestEnvironment _env; + private readonly ITestOutputHelper _output; + + public ResGenDependencies_Tests(ITestOutputHelper output) + { + _env = TestEnvironment.Create(output); + _output = output; + } + + public void Dispose() + { + _env.Dispose(); + } + [Theory] [MemberData(nameof(GenerateResource_Tests.Utilities.UsePreserializedResourceStates), MemberType = typeof(GenerateResource_Tests.Utilities))] @@ -86,6 +104,60 @@ public void DirtyCleanScenario(bool useMSBuildResXReader) } } + /// + /// When useMSBuildResXReader is true, GetResXFileInfo should track linked files + /// for all resource types that produce an ILinkedFileResource: System.String, + /// System.Byte[], System.IO.MemoryStream, and FileStreamResource types + /// (e.g. System.Drawing.Bitmap). + /// + [Fact] + public void LinkedFilesTrackedForAllResourceTypes() + { + var folder = _env.CreateFolder(createFolder: true); + + // Create four linked files representing each code path in AddLinkedResource. + var textFile = folder.CreateFile("linked.txt", "hello"); + var byteFile = folder.CreateFile("linked.bin", "bytes"); + var memStreamFile = folder.CreateFile("linked.dat", "stream"); + + // Create a minimal bitmap file inside the managed folder. + string bitmapPath = Path.Combine(folder.Path, "linked.bmp"); + byte[] bmp = new byte[66]; + bmp[0x00] = 0x42; bmp[0x01] = 0x4D; bmp[0x02] = 0x42; + bmp[0x0a] = 0x3E; bmp[0x0e] = 0x28; bmp[0x12] = 0x01; bmp[0x16] = 0x01; + bmp[0x1a] = 0x01; bmp[0x1c] = 0x01; bmp[0x22] = 0x04; + bmp[0x3a] = 0xFF; bmp[0x3b] = 0xFF; bmp[0x3c] = 0xFF; + bmp[0x3e] = 0x80; + File.WriteAllBytes(bitmapPath, bmp); + + string resxPath = Path.Combine(folder.Path, "test.resx"); + File.WriteAllText(resxPath, ResXHelper.SurroundWithBoilerplate( + $@" + + {textFile.Path};System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8 + + + {byteFile.Path};System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {memStreamFile.Path};System.IO.MemoryStream, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {bitmapPath};System.Drawing.Bitmap, System.Drawing, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + ")); + + var cache = new ResGenDependencies(); + ResGenDependencies.ResXFile resxFile = cache.GetResXFileInfo(resxPath, useMSBuildResXReader: true, log: null, logWarningForBinaryFormatter: false); + + resxFile.LinkedFiles.ShouldNotBeNull(); + resxFile.LinkedFiles.Length.ShouldBe(4); + resxFile.LinkedFiles.ShouldContain(textFile.Path); + resxFile.LinkedFiles.ShouldContain(byteFile.Path); + resxFile.LinkedFiles.ShouldContain(memStreamFile.Path); + resxFile.LinkedFiles.ShouldContain(bitmapPath); + } + /// /// Create a sample resx file on disk. Caller is responsible for deleting. /// diff --git a/src/Tasks/ResGenDependencies.cs b/src/Tasks/ResGenDependencies.cs index a593be6cffe..4c329b8824c 100644 --- a/src/Tasks/ResGenDependencies.cs +++ b/src/Tasks/ResGenDependencies.cs @@ -265,9 +265,9 @@ private static string[] GetLinkedFiles(string filename, string baseLinkedFileDir { foreach (IResource resource in MSBuildResXReader.GetResourcesFromFile(filename, pathsRelativeToBasePath: baseLinkedFileDirectory == null, log, logWarningForBinaryFormatter)) { - if (resource is FileStreamResource linkedResource) + if (resource is ILinkedFileResource linked) { - retVal.Add(linkedResource.FileName); + retVal.Add(linked.LinkedFilePath); } } } diff --git a/src/Tasks/ResourceHandling/FileStreamResource.cs b/src/Tasks/ResourceHandling/FileStreamResource.cs index 4e900e25111..bb995c849aa 100644 --- a/src/Tasks/ResourceHandling/FileStreamResource.cs +++ b/src/Tasks/ResourceHandling/FileStreamResource.cs @@ -8,7 +8,7 @@ namespace Microsoft.Build.Tasks.ResourceHandling { - internal class FileStreamResource : IResource + internal class FileStreamResource : ILinkedFileResource { public string Name { get; } @@ -16,7 +16,7 @@ internal class FileStreamResource : IResource public string OriginatingFile { get; } - public string FileName { get; } + public string LinkedFilePath { get; } public string TypeFullName => NameUtilities.FullNameFromAssemblyQualifiedName(TypeAssemblyQualifiedName); @@ -31,7 +31,7 @@ public FileStreamResource(string name, string assemblyQualifiedTypeName, string { Name = name; TypeAssemblyQualifiedName = assemblyQualifiedTypeName; - FileName = fileName; + LinkedFilePath = fileName; OriginatingFile = originatingFile; } @@ -40,7 +40,7 @@ public void AddTo(IResourceWriter writer) if (writer is PreserializedResourceWriter preserializedResourceWriter) { #pragma warning disable CA2000 // Dispose objects before losing scope the stream is expected to be disposed by the PreserializedResourceWriter.ResourceDataRecord - FileStream fileStream = new FileStream(FileName, FileMode.Open, FileAccess.Read, FileShare.Read); + FileStream fileStream = new FileStream(LinkedFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); #pragma warning restore CA2000 // Dispose objects before losing scope preserializedResourceWriter.AddActivatorResource(Name, fileStream, TypeAssemblyQualifiedName, closeAfterWrite: true); diff --git a/src/Tasks/ResourceHandling/ILinkedFileResource.cs b/src/Tasks/ResourceHandling/ILinkedFileResource.cs new file mode 100644 index 00000000000..835b98208c9 --- /dev/null +++ b/src/Tasks/ResourceHandling/ILinkedFileResource.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Build.Tasks.ResourceHandling +{ + /// + /// An that is backed by a linked file (originating + /// from a ResXFileRef entry) and exposes the path to that file. + /// + internal interface ILinkedFileResource : IResource + { + /// + /// The path of the file this resource was read from. This may be an absolute + /// or relative path depending on how the resource was resolved (for example, + /// whether paths were made relative to a base path). + /// + string LinkedFilePath { get; } + } +} diff --git a/src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs b/src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs new file mode 100644 index 00000000000..af17dadf814 --- /dev/null +++ b/src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Build.Tasks.ResourceHandling +{ + /// + /// A that is backed by a linked file + /// (originating from a ResXFileRef entry). + /// + internal class LinkedLiveObjectResource : LiveObjectResource, ILinkedFileResource + { + public string LinkedFilePath { get; } + + public LinkedLiveObjectResource(string name, object value, string linkedFilePath) + : base(name, value) + { + LinkedFilePath = linkedFilePath; + } + } +} diff --git a/src/Tasks/ResourceHandling/LinkedStringResource.cs b/src/Tasks/ResourceHandling/LinkedStringResource.cs new file mode 100644 index 00000000000..cbe261139fa --- /dev/null +++ b/src/Tasks/ResourceHandling/LinkedStringResource.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Build.Tasks.ResourceHandling +{ + /// + /// A that is backed by a linked file + /// (originating from a ResXFileRef entry). + /// + internal class LinkedStringResource : StringResource, ILinkedFileResource + { + public string LinkedFilePath { get; } + + public LinkedStringResource(string name, string value, string filename, string linkedFilePath) + : base(name, value, filename) + { + LinkedFilePath = linkedFilePath; + } + } +} diff --git a/src/Tasks/ResourceHandling/MSBuildResXReader.cs b/src/Tasks/ResourceHandling/MSBuildResXReader.cs index 90e0f34cbad..9de27e4c4b7 100644 --- a/src/Tasks/ResourceHandling/MSBuildResXReader.cs +++ b/src/Tasks/ResourceHandling/MSBuildResXReader.cs @@ -260,7 +260,7 @@ private static void AddLinkedResource(string resxFilename, bool pathsRelativeToB : Encoding.Default; using (StreamReader sr = new StreamReader(fileName, textFileEncoding)) { - resources.Add(new StringResource(name, sr.ReadToEnd(), resxFilename)); + resources.Add(new LinkedStringResource(name, sr.ReadToEnd(), resxFilename, fileName)); return; } @@ -269,7 +269,7 @@ private static void AddLinkedResource(string resxFilename, bool pathsRelativeToB { byte[] byteArray = FileSystems.Default.ReadFileAllBytes(fileName); - resources.Add(new LiveObjectResource(name, byteArray)); + resources.Add(new LinkedLiveObjectResource(name, byteArray, fileName)); return; } else if (IsMemoryStream(fileRefType)) @@ -278,7 +278,7 @@ private static void AddLinkedResource(string resxFilename, bool pathsRelativeToB // https://github.com/dotnet/winforms/blob/689cd9c69e632997bc85bf421af221d79b12ddd4/src/System.Windows.Forms/src/System/Resources/ResXFileRef.cs#L293-L297 byte[] byteArray = FileSystems.Default.ReadFileAllBytes(fileName); - resources.Add(new LiveObjectResource(name, new MemoryStream(byteArray))); + resources.Add(new LinkedLiveObjectResource(name, new MemoryStream(byteArray), fileName)); return; }