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;
}