From 9618f6cc702c2c3b2b2272c818d0047a5e4a66e6 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Wed, 4 Mar 2026 11:19:09 +0100 Subject: [PATCH 1/7] Track all ResXFileRef linked files --- .../MSBuildResXReader_Tests.cs | 33 +++++++++++++++++++ src/Tasks/ResGenDependencies.cs | 4 +-- .../ResourceHandling/FileStreamResource.cs | 8 ++--- .../ResourceHandling/ILinkedFileResource.cs | 17 ++++++++++ .../ResourceHandling/LiveObjectResource.cs | 6 ++-- .../ResourceHandling/MSBuildResXReader.cs | 6 ++-- src/Tasks/ResourceHandling/StringResource.cs | 4 +-- 7 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 src/Tasks/ResourceHandling/ILinkedFileResource.cs diff --git a/src/Tasks.UnitTests/ResourceHandling/MSBuildResXReader_Tests.cs b/src/Tasks.UnitTests/ResourceHandling/MSBuildResXReader_Tests.cs index c161b862071..4219773609e 100644 --- a/src/Tasks.UnitTests/ResourceHandling/MSBuildResXReader_Tests.cs +++ b/src/Tasks.UnitTests/ResourceHandling/MSBuildResXReader_Tests.cs @@ -333,6 +333,39 @@ 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(); + resources[0].ShouldBeAssignableTo() + .LinkedFilePath.ShouldBeNull(); + } + + [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/ResGenDependencies.cs b/src/Tasks/ResGenDependencies.cs index a593be6cffe..363938613ab 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 && linked.LinkedFilePath is not null) { - 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..65626dcd56f --- /dev/null +++ b/src/Tasks/ResourceHandling/ILinkedFileResource.cs @@ -0,0 +1,17 @@ +// 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 originated from a ResXFileRef entry + /// and carries the path to the linked file. + /// + internal interface ILinkedFileResource : IResource + { + /// + /// The absolute path of the file this resource was read from. + /// + string? LinkedFilePath { get; } + } +} diff --git a/src/Tasks/ResourceHandling/LiveObjectResource.cs b/src/Tasks/ResourceHandling/LiveObjectResource.cs index 5a051dc2097..2de2ef5af6c 100644 --- a/src/Tasks/ResourceHandling/LiveObjectResource.cs +++ b/src/Tasks/ResourceHandling/LiveObjectResource.cs @@ -10,16 +10,18 @@ namespace Microsoft.Build.Tasks.ResourceHandling /// /// Name value resource pair to go in resources list /// - internal class LiveObjectResource : IResource + internal class LiveObjectResource : ILinkedFileResource { - public LiveObjectResource(string name, object value) + public LiveObjectResource(string name, object value, string linkedFilePath = null) { Name = name; Value = value; + LinkedFilePath = linkedFilePath; } public string Name { get; } public object Value { get; } + public string LinkedFilePath { get; } public string TypeAssemblyQualifiedName => Value.GetType().AssemblyQualifiedName; diff --git a/src/Tasks/ResourceHandling/MSBuildResXReader.cs b/src/Tasks/ResourceHandling/MSBuildResXReader.cs index 90e0f34cbad..275e9c6231e 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 StringResource(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 LiveObjectResource(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 LiveObjectResource(name, new MemoryStream(byteArray), fileName)); return; } diff --git a/src/Tasks/ResourceHandling/StringResource.cs b/src/Tasks/ResourceHandling/StringResource.cs index 7b082804b7b..325ce786cd7 100644 --- a/src/Tasks/ResourceHandling/StringResource.cs +++ b/src/Tasks/ResourceHandling/StringResource.cs @@ -13,8 +13,8 @@ internal class StringResource : LiveObjectResource public new string TypeFullName => typeof(string).FullName; - public StringResource(string name, string value, string filename) : - base(name, value) + public StringResource(string name, string value, string filename, string linkedFilePath = null) : + base(name, value, linkedFilePath) { OriginatingFile = filename; } From a84777e79f52e15bc8c8cfa77cf25569b2cada36 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova <150850103+OvesN@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:57:11 +0100 Subject: [PATCH 2/7] Update src/Tasks/ResourceHandling/ILinkedFileResource.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Tasks/ResourceHandling/ILinkedFileResource.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Tasks/ResourceHandling/ILinkedFileResource.cs b/src/Tasks/ResourceHandling/ILinkedFileResource.cs index 65626dcd56f..6964bc3b35a 100644 --- a/src/Tasks/ResourceHandling/ILinkedFileResource.cs +++ b/src/Tasks/ResourceHandling/ILinkedFileResource.cs @@ -4,13 +4,19 @@ namespace Microsoft.Build.Tasks.ResourceHandling { /// - /// An that originated from a ResXFileRef entry - /// and carries the path to the linked file. + /// An that may be backed by a linked file and can expose + /// the path to that file when applicable (for example, resources originating + /// from a ResXFileRef entry). Non-linked resources will have + /// set to . /// internal interface ILinkedFileResource : IResource { /// - /// The absolute path of the file this resource was read from. + /// 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). A value of + /// indicates that this resource is not linked + /// to an external file. /// string? LinkedFilePath { get; } } From 4c6a1f5fa46fa3a45c3c92ff127bd1eb4bbb3cb1 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Wed, 4 Mar 2026 16:21:12 +0100 Subject: [PATCH 3/7] Add new resGenDependencies test --- .../ResGenDependencies_Tests.cs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/Tasks.UnitTests/ResourceHandling/ResGenDependencies_Tests.cs b/src/Tasks.UnitTests/ResourceHandling/ResGenDependencies_Tests.cs index 6538fef25df..1a6cb5b4eeb 100644 --- a/src/Tasks.UnitTests/ResourceHandling/ResGenDependencies_Tests.cs +++ b/src/Tasks.UnitTests/ResourceHandling/ResGenDependencies_Tests.cs @@ -6,8 +6,12 @@ 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 @@ -15,6 +19,13 @@ namespace Microsoft.Build.UnitTests { public sealed class ResGenDependencies_Tests { + private readonly ITestOutputHelper _output; + + public ResGenDependencies_Tests(ITestOutputHelper output) + { + _output = output; + } + [Theory] [MemberData(nameof(GenerateResource_Tests.Utilities.UsePreserializedResourceStates), MemberType = typeof(GenerateResource_Tests.Utilities))] @@ -86,6 +97,62 @@ 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() + { + using TestEnvironment env = TestEnvironment.Create(_output); + + 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. /// From a0861373c926f0ef79bc68f07cf01af9b13c6030 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Thu, 5 Mar 2026 10:37:55 +0100 Subject: [PATCH 4/7] New test added --- .../GenerateResource_Tests.cs | 90 ++++++++++++++++--- .../ResGenDependencies_Tests.cs | 13 ++- 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/Tasks.UnitTests/ResourceHandling/GenerateResource_Tests.cs b/src/Tasks.UnitTests/ResourceHandling/GenerateResource_Tests.cs index e11e7c0d979..f98570a8d6d 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() { @@ -4040,6 +4089,24 @@ public static void AssertLogNotContains(GenerateResource t, string message) /// 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. /// + /// + /// 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}'."); + } + public static void AssertStateFileWasWritten(GenerateResource t) { Assert.NotNull(t.FilesWritten); // "The state file should have been written, but there aren't any." @@ -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/ResGenDependencies_Tests.cs b/src/Tasks.UnitTests/ResourceHandling/ResGenDependencies_Tests.cs index 1a6cb5b4eeb..b676ff9817c 100644 --- a/src/Tasks.UnitTests/ResourceHandling/ResGenDependencies_Tests.cs +++ b/src/Tasks.UnitTests/ResourceHandling/ResGenDependencies_Tests.cs @@ -17,15 +17,22 @@ 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))] @@ -106,9 +113,7 @@ public void DirtyCleanScenario(bool useMSBuildResXReader) [Fact] public void LinkedFilesTrackedForAllResourceTypes() { - using TestEnvironment env = TestEnvironment.Create(_output); - - var folder = env.CreateFolder(createFolder: true); + var folder = _env.CreateFolder(createFolder: true); // Create four linked files representing each code path in AddLinkedResource. var textFile = folder.CreateFile("linked.txt", "hello"); From 31963e69f5f543496633cd9603f792034a11d3cf Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Thu, 5 Mar 2026 12:14:27 +0100 Subject: [PATCH 5/7] Refactor LiveObjectResource constructor and update usages to include linkedFilePath parameter --- .../ResourceHandling/GenerateResource_Tests.cs | 10 +++++----- src/Tasks/GenerateResource.cs | 2 +- src/Tasks/ResourceHandling/LiveObjectResource.cs | 2 +- src/Tasks/ResourceHandling/MSBuildResXReader.cs | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Tasks.UnitTests/ResourceHandling/GenerateResource_Tests.cs b/src/Tasks.UnitTests/ResourceHandling/GenerateResource_Tests.cs index f98570a8d6d..70ac4f19f7b 100644 --- a/src/Tasks.UnitTests/ResourceHandling/GenerateResource_Tests.cs +++ b/src/Tasks.UnitTests/ResourceHandling/GenerateResource_Tests.cs @@ -4085,10 +4085,6 @@ public static void AssertLogNotContains(GenerateResource t, string message) Assert.DoesNotContain(message, ((MockEngine)t.BuildEngine).Log); } - /// - /// 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. - /// /// /// Reads a single resource value by key from a compiled .resources file. /// @@ -4106,7 +4102,11 @@ public static object ReadResourceValue(string resourcesFilePath, string resource 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. + /// public static void AssertStateFileWasWritten(GenerateResource t) { Assert.NotNull(t.FilesWritten); // "The state file should have been written, but there aren't any." diff --git a/src/Tasks/GenerateResource.cs b/src/Tasks/GenerateResource.cs index 326587686b4..8b8d9f4c963 100644 --- a/src/Tasks/GenerateResource.cs +++ b/src/Tasks/GenerateResource.cs @@ -3841,7 +3841,7 @@ private void WriteTextResources(ReaderInfo reader, String fileName) /// Column number for messages private void AddResource(ReaderInfo reader, string name, object value, String inputFileName, int lineNumber, int linePosition) { - LiveObjectResource entry = new LiveObjectResource(name, value); + LiveObjectResource entry = new LiveObjectResource(name, value, linkedFilePath: null); AddResource(reader, entry, inputFileName, lineNumber, linePosition); } diff --git a/src/Tasks/ResourceHandling/LiveObjectResource.cs b/src/Tasks/ResourceHandling/LiveObjectResource.cs index 2de2ef5af6c..a8bb0650984 100644 --- a/src/Tasks/ResourceHandling/LiveObjectResource.cs +++ b/src/Tasks/ResourceHandling/LiveObjectResource.cs @@ -12,7 +12,7 @@ namespace Microsoft.Build.Tasks.ResourceHandling /// internal class LiveObjectResource : ILinkedFileResource { - public LiveObjectResource(string name, object value, string linkedFilePath = null) + public LiveObjectResource(string name, object value, string linkedFilePath) { Name = name; Value = value; diff --git a/src/Tasks/ResourceHandling/MSBuildResXReader.cs b/src/Tasks/ResourceHandling/MSBuildResXReader.cs index 275e9c6231e..68b2f937fb2 100644 --- a/src/Tasks/ResourceHandling/MSBuildResXReader.cs +++ b/src/Tasks/ResourceHandling/MSBuildResXReader.cs @@ -167,7 +167,7 @@ private static void ParseData( if (typename.StartsWith("System.Resources.ResXNullRef", StringComparison.Ordinal)) { - resources.Add(new LiveObjectResource(name, null)); + resources.Add(new LiveObjectResource(name, value: null, linkedFilePath: null)); return; } @@ -180,7 +180,7 @@ private static void ParseData( // Handle byte[]'s, which are stored as base-64 encoded strings. byte[] byteArray = Convert.FromBase64String(value); - resources.Add(new LiveObjectResource(name, byteArray)); + resources.Add(new LiveObjectResource(name, byteArray, linkedFilePath: null)); return; } From 89afeb4ff78638258542d81ba4a6af7ce33e1929 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Thu, 5 Mar 2026 15:11:56 +0100 Subject: [PATCH 6/7] Refacor ILinkedResource, make linkedFilePath not nullable --- .../MSBuildResXReader_Tests.cs | 20 ++++++++++++----- src/Tasks/GenerateResource.cs | 2 +- src/Tasks/ResGenDependencies.cs | 2 +- .../ResourceHandling/ILinkedFileResource.cs | 12 ++++------ .../LinkedLiveObjectResource.cs | 22 +++++++++++++++++++ .../ResourceHandling/LinkedStringResource.cs | 22 +++++++++++++++++++ .../ResourceHandling/LiveObjectResource.cs | 6 ++--- .../ResourceHandling/MSBuildResXReader.cs | 10 ++++----- src/Tasks/ResourceHandling/StringResource.cs | 4 ++-- 9 files changed, 73 insertions(+), 27 deletions(-) create mode 100644 src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs create mode 100644 src/Tasks/ResourceHandling/LinkedStringResource.cs diff --git a/src/Tasks.UnitTests/ResourceHandling/MSBuildResXReader_Tests.cs b/src/Tasks.UnitTests/ResourceHandling/MSBuildResXReader_Tests.cs index 4219773609e..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]; @@ -344,8 +354,6 @@ public void InlineStringIsNotLinkedFileResource() resources.ShouldHaveSingleItem(); resources[0].ShouldBeOfType(); - resources[0].ShouldBeAssignableTo() - .LinkedFilePath.ShouldBeNull(); } [Fact] @@ -361,7 +369,7 @@ public void FileRefStringIsLinkedFileResource() "), null, false); resources.ShouldHaveSingleItem(); - resources[0].ShouldBeOfType(); + resources[0].ShouldBeOfType(); resources[0].ShouldBeAssignableTo() .LinkedFilePath.ShouldNotBeNull(); } diff --git a/src/Tasks/GenerateResource.cs b/src/Tasks/GenerateResource.cs index 8b8d9f4c963..326587686b4 100644 --- a/src/Tasks/GenerateResource.cs +++ b/src/Tasks/GenerateResource.cs @@ -3841,7 +3841,7 @@ private void WriteTextResources(ReaderInfo reader, String fileName) /// Column number for messages private void AddResource(ReaderInfo reader, string name, object value, String inputFileName, int lineNumber, int linePosition) { - LiveObjectResource entry = new LiveObjectResource(name, value, linkedFilePath: null); + LiveObjectResource entry = new LiveObjectResource(name, value); AddResource(reader, entry, inputFileName, lineNumber, linePosition); } diff --git a/src/Tasks/ResGenDependencies.cs b/src/Tasks/ResGenDependencies.cs index 363938613ab..4c329b8824c 100644 --- a/src/Tasks/ResGenDependencies.cs +++ b/src/Tasks/ResGenDependencies.cs @@ -265,7 +265,7 @@ private static string[] GetLinkedFiles(string filename, string baseLinkedFileDir { foreach (IResource resource in MSBuildResXReader.GetResourcesFromFile(filename, pathsRelativeToBasePath: baseLinkedFileDirectory == null, log, logWarningForBinaryFormatter)) { - if (resource is ILinkedFileResource linked && linked.LinkedFilePath is not null) + if (resource is ILinkedFileResource linked) { retVal.Add(linked.LinkedFilePath); } diff --git a/src/Tasks/ResourceHandling/ILinkedFileResource.cs b/src/Tasks/ResourceHandling/ILinkedFileResource.cs index 6964bc3b35a..835b98208c9 100644 --- a/src/Tasks/ResourceHandling/ILinkedFileResource.cs +++ b/src/Tasks/ResourceHandling/ILinkedFileResource.cs @@ -4,20 +4,16 @@ namespace Microsoft.Build.Tasks.ResourceHandling { /// - /// An that may be backed by a linked file and can expose - /// the path to that file when applicable (for example, resources originating - /// from a ResXFileRef entry). Non-linked resources will have - /// set to . + /// 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). A value of - /// indicates that this resource is not linked - /// to an external file. + /// whether paths were made relative to a base path). /// - string? LinkedFilePath { get; } + string LinkedFilePath { get; } } } diff --git a/src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs b/src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs new file mode 100644 index 00000000000..f7225cfb8c3 --- /dev/null +++ b/src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +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..abc51ec4e3d --- /dev/null +++ b/src/Tasks/ResourceHandling/LinkedStringResource.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +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/LiveObjectResource.cs b/src/Tasks/ResourceHandling/LiveObjectResource.cs index a8bb0650984..5a051dc2097 100644 --- a/src/Tasks/ResourceHandling/LiveObjectResource.cs +++ b/src/Tasks/ResourceHandling/LiveObjectResource.cs @@ -10,18 +10,16 @@ namespace Microsoft.Build.Tasks.ResourceHandling /// /// Name value resource pair to go in resources list /// - internal class LiveObjectResource : ILinkedFileResource + internal class LiveObjectResource : IResource { - public LiveObjectResource(string name, object value, string linkedFilePath) + public LiveObjectResource(string name, object value) { Name = name; Value = value; - LinkedFilePath = linkedFilePath; } public string Name { get; } public object Value { get; } - public string LinkedFilePath { get; } public string TypeAssemblyQualifiedName => Value.GetType().AssemblyQualifiedName; diff --git a/src/Tasks/ResourceHandling/MSBuildResXReader.cs b/src/Tasks/ResourceHandling/MSBuildResXReader.cs index 68b2f937fb2..9de27e4c4b7 100644 --- a/src/Tasks/ResourceHandling/MSBuildResXReader.cs +++ b/src/Tasks/ResourceHandling/MSBuildResXReader.cs @@ -167,7 +167,7 @@ private static void ParseData( if (typename.StartsWith("System.Resources.ResXNullRef", StringComparison.Ordinal)) { - resources.Add(new LiveObjectResource(name, value: null, linkedFilePath: null)); + resources.Add(new LiveObjectResource(name, null)); return; } @@ -180,7 +180,7 @@ private static void ParseData( // Handle byte[]'s, which are stored as base-64 encoded strings. byte[] byteArray = Convert.FromBase64String(value); - resources.Add(new LiveObjectResource(name, byteArray, linkedFilePath: null)); + resources.Add(new LiveObjectResource(name, byteArray)); return; } @@ -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, fileName)); + 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, fileName)); + 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), fileName)); + resources.Add(new LinkedLiveObjectResource(name, new MemoryStream(byteArray), fileName)); return; } diff --git a/src/Tasks/ResourceHandling/StringResource.cs b/src/Tasks/ResourceHandling/StringResource.cs index 325ce786cd7..7b082804b7b 100644 --- a/src/Tasks/ResourceHandling/StringResource.cs +++ b/src/Tasks/ResourceHandling/StringResource.cs @@ -13,8 +13,8 @@ internal class StringResource : LiveObjectResource public new string TypeFullName => typeof(string).FullName; - public StringResource(string name, string value, string filename, string linkedFilePath = null) : - base(name, value, linkedFilePath) + public StringResource(string name, string value, string filename) : + base(name, value) { OriginatingFile = filename; } From 408e78ef5c5ccd4d460102fb150d7046814669ca Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Thu, 5 Mar 2026 16:01:01 +0100 Subject: [PATCH 7/7] Remove nullable disable --- src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs | 2 -- src/Tasks/ResourceHandling/LinkedStringResource.cs | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs b/src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs index f7225cfb8c3..af17dadf814 100644 --- a/src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs +++ b/src/Tasks/ResourceHandling/LinkedLiveObjectResource.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - namespace Microsoft.Build.Tasks.ResourceHandling { /// diff --git a/src/Tasks/ResourceHandling/LinkedStringResource.cs b/src/Tasks/ResourceHandling/LinkedStringResource.cs index abc51ec4e3d..cbe261139fa 100644 --- a/src/Tasks/ResourceHandling/LinkedStringResource.cs +++ b/src/Tasks/ResourceHandling/LinkedStringResource.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - namespace Microsoft.Build.Tasks.ResourceHandling { ///