From 9113ed5d128a2f3a49f55b24e833dd58b7d2d758 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 30 Jul 2026 02:06:55 +0100
Subject: [PATCH 01/10] feat(mocks): experimental compile-time internals access
(#6514 Tier 2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Runtime auto-stubs (#6519) satisfy SDK-internal generic requests with
functional defaults, but the test can never configure or verify such a
type — it cannot write the type's name. This prototype removes that
constraint: behind TUnitMocksExperimentalInternalsAccess, internal
types of selected referenced assemblies become first-class mockable —
nameable in test code, source-generated typed mocks, setups, matchers,
verification — with zero InternalsVisibleTo required from the target
assembly.
Mechanism (the established publicizer pattern, wired for TUnit.Mocks):
a Mono.Cecil MSBuild task rewrites the selected references so internals
are public, preserving assembly identity, and swaps them into
ReferencePathWithRefAssemblies only — the compiler's view. deps.json
and copy-local keep the originals, so the runtime loads the real
assembly, where the task-emitted IgnoresAccessChecksTo attribute makes
the compiled IL (including generated mock classes implementing internal
interfaces) valid. The mocks source generator needs no changes: through
the publicized reference the types simply look public.
Validated end to end against a fake-SDK library that grants no IVT to
anyone (the exact case runtime stubs cannot reach): naming, mocking,
typed setup/matcher/verification of an internal interface requested by
SDK-internal code, and a hand-written implementation proving the
runtime honors the attribute at type load. Incremental builds stay
no-op; cold builds order correctly via project references.
Experimental caveats documented in the project README: undocumented
(but ecosystem-established) runtime attribute, inert on .NET Framework,
AOT unverified, packaging not yet done.
---
Directory.Packages.props | 1 +
TUnit.slnx | 3 +
.../PublicizeAssemblyReferences.cs | 159 ++++++++++++++++++
.../README.md | 75 +++++++++
.../TUnit.Mocks.InternalsAccess.Tasks.csproj | 19 +++
.../TUnit.Mocks.InternalsAccess.targets | 58 +++++++
.../SdkRuntime.cs | 38 +++++
...nit.Mocks.InternalsAccess.TargetLib.csproj | 12 ++
.../InternalsAccessTests.cs | 75 +++++++++
.../TUnit.Mocks.InternalsAccess.Tests.csproj | 35 ++++
10 files changed, 475 insertions(+)
create mode 100644 src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
create mode 100644 src/TUnit.Mocks.InternalsAccess.Tasks/README.md
create mode 100644 src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj
create mode 100644 src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.targets
create mode 100644 tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs
create mode 100644 tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj
create mode 100644 tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs
create mode 100644 tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 71ed51660d..0c91c1c747 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -54,6 +54,7 @@
+
diff --git a/TUnit.slnx b/TUnit.slnx
index 17bf9a5b92..ac3754c10a 100644
--- a/TUnit.slnx
+++ b/TUnit.slnx
@@ -27,6 +27,7 @@
+
@@ -77,6 +78,8 @@
+
+
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
new file mode 100644
index 0000000000..3d49b56060
--- /dev/null
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -0,0 +1,159 @@
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+using Mono.Cecil;
+
+namespace TUnit.Mocks.InternalsAccess.Tasks;
+
+///
+/// Experimental (#6514 "Tier 2"). For each requested assembly reference, produces a
+/// compile-time-only copy whose internal types and members are rewritten to public, and emits a
+/// source file applying IgnoresAccessChecksToAttribute so the runtime skips accessibility
+/// checks from the test assembly to those assemblies.
+///
+/// The publicized copy keeps the original assembly identity (name, version, public key), so IL
+/// compiled against it binds to the ORIGINAL assembly at runtime — the rewritten copy never
+/// ships and is never loaded. Compiler sees public; runtime sees the real thing and is told not
+/// to check. This is the established "publicizer" pattern (Krafs.Publicizer,
+/// IgnoresAccessChecksToGenerator), applied here so the TUnit.Mocks source generator can treat
+/// another assembly's internal interfaces as first-class mockable types.
+///
+public sealed class PublicizeAssemblyReferences : Microsoft.Build.Utilities.Task
+{
+ /// All resolved compile-time references (@(ReferencePath)).
+ [Required]
+ public ITaskItem[] ReferencePaths { get; set; } = [];
+
+ /// Simple assembly names to publicize (@(TUnitMocksInternalsAccess)).
+ [Required]
+ public ITaskItem[] AssembliesToPublicize { get; set; } = [];
+
+ /// Directory for the rewritten compile-time copies.
+ [Required]
+ public string OutputDirectory { get; set; } = "";
+
+ /// Path of the generated IgnoresAccessChecksTo source file.
+ [Required]
+ public string GeneratedSourceFile { get; set; } = "";
+
+ ///
+ /// Publicized references. ItemSpec = rewritten copy; %(Original) = the ReferencePath item it
+ /// replaces, for the targets file to Remove.
+ ///
+ [Output]
+ public ITaskItem[] PublicizedReferences { get; set; } = [];
+
+ public override bool Execute()
+ {
+ Directory.CreateDirectory(OutputDirectory);
+
+ var outputs = new List();
+ var publicizedNames = new List();
+
+ foreach (var requested in AssembliesToPublicize)
+ {
+ var name = requested.ItemSpec;
+ var reference = ReferencePaths.FirstOrDefault(r =>
+ string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase));
+
+ if (reference is null)
+ {
+ Log.LogError($"TUnitMocksInternalsAccess: no resolved reference named '{name}' was found. " +
+ "The value must be the simple assembly name of a direct or transitive reference.");
+ return false;
+ }
+
+ // The compiler consumes the reference assembly when one exists (project references
+ // produce one under obj/ref; packages may ship ref/ assemblies) — that is the file
+ // that must be publicized.
+ var referenceAssembly = reference.GetMetadata("ReferenceAssembly");
+ var source = string.IsNullOrEmpty(referenceAssembly) ? reference.ItemSpec : referenceAssembly;
+ var destination = Path.Combine(OutputDirectory, Path.GetFileName(source));
+
+ if (!File.Exists(destination) || File.GetLastWriteTimeUtc(destination) < File.GetLastWriteTimeUtc(source))
+ {
+ Publicize(source, destination);
+ Log.LogMessage(MessageImportance.Normal, $"TUnitMocksInternalsAccess: publicized '{source}' -> '{destination}'.");
+ }
+
+ var item = new TaskItem(destination);
+ item.SetMetadata("Original", reference.ItemSpec);
+ // Compile-time only: never copy the rewritten assembly to the output directory.
+ item.SetMetadata("Private", "false");
+ item.SetMetadata("CopyLocal", "false");
+ outputs.Add(item);
+ publicizedNames.Add(name);
+ }
+
+ WriteIgnoresAccessChecksToSource(publicizedNames);
+ PublicizedReferences = outputs.ToArray();
+ return !Log.HasLoggedErrors;
+ }
+
+ private static void Publicize(string source, string destination)
+ {
+ using var module = ModuleDefinition.ReadModule(source);
+
+ foreach (var type in module.GetTypes())
+ {
+ if (type.Name == "")
+ {
+ continue;
+ }
+
+ type.Attributes = type.IsNested
+ ? (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NestedPublic
+ : (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.Public;
+
+ foreach (var method in type.Methods)
+ {
+ // Explicit interface implementations stay private — IL requires it, and making
+ // them public would surface dotted names the compiler cannot bind anyway.
+ if (method.Overrides.Count > 0 && method.IsPrivate)
+ {
+ continue;
+ }
+
+ method.Attributes = (method.Attributes & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Public;
+ }
+ }
+
+ // Identity (name/version/public key) is preserved so compiled IL binds to the original
+ // assembly at runtime; only the signature is invalidated, which nothing validates for a
+ // compile-time reference. Clear the signed flag so nothing is tempted to try.
+ module.Attributes &= ~ModuleAttributes.StrongNameSigned;
+ module.Write(destination);
+ }
+
+ private void WriteIgnoresAccessChecksToSource(List assemblyNames)
+ {
+ var writer = new StringWriter();
+ writer.WriteLine("// ");
+ writer.WriteLine("// Generated by TUnit.Mocks internals access (experimental). The runtime honors");
+ writer.WriteLine("// IgnoresAccessChecksToAttribute and skips accessibility checks from this assembly");
+ writer.WriteLine("// to the assemblies named below, matching the publicized compile-time references.");
+ writer.WriteLine("// ");
+ foreach (var name in assemblyNames)
+ {
+ writer.WriteLine($"[assembly: System.Runtime.CompilerServices.IgnoresAccessChecksTo(\"{name}\")]");
+ }
+
+ writer.WriteLine();
+ writer.WriteLine("namespace System.Runtime.CompilerServices");
+ writer.WriteLine("{");
+ writer.WriteLine(" [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]");
+ writer.WriteLine(" internal sealed class IgnoresAccessChecksToAttribute : Attribute");
+ writer.WriteLine(" {");
+ writer.WriteLine(" public IgnoresAccessChecksToAttribute(string assemblyName) => AssemblyName = assemblyName;");
+ writer.WriteLine();
+ writer.WriteLine(" public string AssemblyName { get; }");
+ writer.WriteLine(" }");
+ writer.WriteLine("}");
+
+ var content = writer.ToString();
+ Directory.CreateDirectory(Path.GetDirectoryName(GeneratedSourceFile)!);
+ if (!File.Exists(GeneratedSourceFile) || File.ReadAllText(GeneratedSourceFile) != content)
+ {
+ File.WriteAllText(GeneratedSourceFile, content);
+ }
+ }
+}
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
new file mode 100644
index 0000000000..f8f00a14cc
--- /dev/null
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
@@ -0,0 +1,75 @@
+# TUnit.Mocks internals access (experimental)
+
+Prototype for [#6514](https://github.com/thomhurst/TUnit/issues/6514) "Tier 2": make types that
+are `internal` to another assembly first-class mockable — nameable in test code, source-generated
+typed mocks, setups, matchers, and verification — even when that assembly grants **no**
+`InternalsVisibleTo` at all.
+
+This complements the runtime auto-stubs from PR #6519 (Tier 1). Stubs are zero-config but
+anonymous: they satisfy an SDK's internal `Get()` calls with functional defaults, yet the test
+can never configure or verify them, because it cannot write the type's name. Internals access
+removes that constraint entirely.
+
+## Usage
+
+```xml
+
+ true
+
+
+
+
+
+
+
+
+```
+
+Then internal types of that assembly are usable in test code like public ones:
+
+```csharp
+var bindings = IFunctionBindingsFeature.Mock(); // internal to the SDK — now mockable
+features.Get().Returns(bindings.Object);
+features.Get().WasCalled(Times.Once);
+```
+
+## How it works
+
+The established "publicizer" pattern (Krafs.Publicizer, IgnoresAccessChecksToGenerator), wired
+for TUnit.Mocks:
+
+1. After reference resolution, the `PublicizeAssemblyReferences` task rewrites each requested
+ reference (Mono.Cecil) so its internal types and members are public, preserving the assembly
+ identity (name, version, public key). The copy lives under `obj/` only.
+2. The swap happens on `ReferencePathWithRefAssemblies` — the item group that feeds the compiler
+ and nothing else. `ReferencePath` is untouched, so copy-local output and `deps.json` keep the
+ original assembly and the runtime loads the real thing.
+3. The task emits `[assembly: IgnoresAccessChecksTo("...")]` (plus the attribute definition) into
+ the compilation. CoreCLR honors it and skips accessibility checks from the test assembly to
+ the named assemblies, so the compiled IL — including generated mock classes implementing
+ internal interfaces — loads and runs against the original assembly.
+4. The TUnit.Mocks source generator needs no changes: through the publicized reference the
+ internal types simply look public, so discovery, TM007 accessibility checks, and mock
+ emission all behave as for any public type.
+
+## Caveats (why this is experimental)
+
+- `IgnoresAccessChecksToAttribute` is honored by CoreCLR but is not a documented public contract.
+ It is the foundation of several long-lived OSS packages, so breakage risk is low but nonzero.
+- .NET Framework does not honor the attribute — the pipeline is inert there (warning emitted).
+- Native AOT / trimming behavior is unverified.
+- The publicizer currently rewrites type and method accessibility (fields are left alone) and
+ skips explicit interface implementations.
+- MSBuild loads the task assembly in-proc and holds a file lock across builds in the same node;
+ after editing the task itself, run `dotnet build-server shutdown` before rebuilding it.
+- Not packaged yet: consumers import the `.targets` file directly and the task assembly is
+ resolved from this project's build output. Packaging (`tasks/` folder + `buildTransitive`)
+ is the productionization step.
+
+## Validation
+
+`tests/TUnit.Mocks.InternalsAccess.TargetLib` stands in for a third-party SDK: an internal
+interface, a public generic accessor requested from SDK-internal code, and deliberately **zero**
+IVT grants — the exact case Tier 1 cannot reach. `tests/TUnit.Mocks.InternalsAccess.Tests`
+exercises naming, mocking, typed setup/matcher/verification, and a hand-written implementation
+of the internal interface (type-load proof).
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj b/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj
new file mode 100644
index 0000000000..d41d1aa7d4
--- /dev/null
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+ net10.0
+ false
+
+ true
+ true
+
+
+
+
+
+
+
+
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.targets b/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.targets
new file mode 100644
index 0000000000..255ca89258
--- /dev/null
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.targets
@@ -0,0 +1,58 @@
+
+
+
+
+ $(MSBuildThisFileDirectory)bin\$(Configuration)\net10.0\TUnit.Mocks.InternalsAccess.Tasks.dll
+
+
+
+
+
+
+
+
+
+
+
+
+ <_TUnitMocksIactSourceFile>$(IntermediateOutputPath)TUnitMocksIgnoresAccessChecksTo.g.cs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs b/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs
new file mode 100644
index 0000000000..20fe5c4321
--- /dev/null
+++ b/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs
@@ -0,0 +1,38 @@
+namespace FakeSdk;
+
+///
+/// Mirrors the shape from issue #6514: a public generic accessor whose type arguments are chosen
+/// by SDK-internal code, using types the consuming assembly cannot normally name.
+///
+public interface IFeatureCollection
+{
+ T Get();
+}
+
+///
+/// The unnameable type. Internal, and this assembly grants no InternalsVisibleTo whatsoever.
+///
+internal interface IInternalBindingsFeature
+{
+ string InvocationResult { get; set; }
+
+ int Compute(int seed);
+}
+
+///
+/// Simulates SDK-internal call sites the test has no control over.
+///
+public static class SdkRuntime
+{
+ public static string DescribeInvocation(IFeatureCollection features)
+ {
+ var bindings = features.Get();
+ return bindings is null ? "" : bindings.InvocationResult;
+ }
+
+ public static int RunComputation(IFeatureCollection features, int seed)
+ {
+ var bindings = features.Get();
+ return bindings?.Compute(seed) ?? -1;
+ }
+}
diff --git a/tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj b/tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj
new file mode 100644
index 0000000000..a0c04ac755
--- /dev/null
+++ b/tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj
@@ -0,0 +1,12 @@
+
+
+
+
+ net10.0
+ false
+
+
+
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs
new file mode 100644
index 0000000000..c69927ae89
--- /dev/null
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs
@@ -0,0 +1,75 @@
+using FakeSdk;
+using TUnit.Mocks;
+using TUnit.Mocks.Verification;
+
+namespace TUnit.Mocks.InternalsAccess.Tests;
+
+// Experimental #6514 "Tier 2": IInternalBindingsFeature is internal to the FakeSdk assembly,
+// which grants NO InternalsVisibleTo (not to this assembly, not to DynamicProxyGenAssembly2).
+// With TUnitMocksExperimentalInternalsAccess enabled, the compiler sees a publicized copy of the
+// reference and IgnoresAccessChecksTo makes it valid at runtime — so the type is nameable here,
+// the source generator mocks it like any public interface, and setups/verification are fully
+// typed. This exceeds what runtime-proxy libraries offer: they can auto-substitute such a type
+// but can never let the test configure or verify it, because the test cannot write its name.
+
+public class InternalsAccessTests
+{
+ [Test]
+ public async Task Internal_Interface_Is_Nameable_And_Mockable()
+ {
+ var bindings = IInternalBindingsFeature.Mock();
+ bindings.InvocationResult.Returns("configured");
+
+ await Assert.That(bindings.Object.InvocationResult).IsEqualTo("configured");
+ }
+
+ [Test]
+ public async Task Sdk_Internal_Generic_Request_Receives_The_Configured_Mock()
+ {
+ var bindings = IInternalBindingsFeature.Mock();
+ bindings.InvocationResult.Returns("from-tier2");
+
+ var features = IFeatureCollection.Mock();
+ features.Get().Returns(bindings.Object);
+
+ // The generic request happens inside the SDK, not in this assembly.
+ await Assert.That(SdkRuntime.DescribeInvocation(features.Object)).IsEqualTo("from-tier2");
+
+ // Typed verification of a call whose type argument is internal to another assembly.
+ features.Get().WasCalled(Times.Once);
+ }
+
+ [Test]
+ public async Task Typed_Setup_With_Matchers_On_Internal_Member()
+ {
+ var bindings = IInternalBindingsFeature.Mock();
+ bindings.Compute(Any()).Returns(seed => seed * 2);
+
+ var features = IFeatureCollection.Mock();
+ features.Get().Returns(bindings.Object);
+
+ await Assert.That(SdkRuntime.RunComputation(features.Object, 21)).IsEqualTo(42);
+
+ bindings.Compute(21).WasCalled(Times.Once);
+ bindings.Compute(99).WasNeverCalled();
+ }
+
+ [Test]
+ public async Task Manual_Implementation_Of_The_Internal_Interface_Loads_And_Runs()
+ {
+ // Proves IgnoresAccessChecksTo is honored at type-load time for hand-written
+ // implementations too, not just generated mocks.
+ var features = IFeatureCollection.Mock();
+ features.Get().Returns(new ManualBindings());
+
+ await Assert.That(SdkRuntime.DescribeInvocation(features.Object)).IsEqualTo("manual");
+ await Assert.That(SdkRuntime.RunComputation(features.Object, 5)).IsEqualTo(6);
+ }
+
+ private sealed class ManualBindings : IInternalBindingsFeature
+ {
+ public string InvocationResult { get; set; } = "manual";
+
+ public int Compute(int seed) => seed + 1;
+ }
+}
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj b/tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj
new file mode 100644
index 0000000000..9f3fafe12a
--- /dev/null
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj
@@ -0,0 +1,35 @@
+
+
+
+
+
+ net10.0
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From b3b321588a5061c667d2f18abdeab8293f160ba5 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 30 Jul 2026 02:49:01 +0100
Subject: [PATCH 02/10] =?UTF-8?q?feat(mocks):=20productionize=20internals?=
=?UTF-8?q?=20access=20=E2=80=94=20packaging,=20dual-runtime=20task,=20tes?=
=?UTF-8?q?ts,=20CI,=20docs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Graduates the #6514 Tier 2 prototype into the TUnit.Mocks package:
- The publicizer task multi-targets net472 (Visual Studio's .NET
Framework MSBuild) and net8.0 (dotnet msbuild), selected via
$(MSBuildRuntimeType); MSBuild references are compile-time only and
pinned to a version shipping both assets. Task and Mono.Cecil ship
under tasks// in the nupkg (NU5100 suppressed — task assemblies
are exactly the thing that must not live in lib/).
- TUnit.Mocks.InternalsAccess.targets moved into src/TUnit.Mocks, packed
to buildTransitive// and build/, and imported by
TUnit.Mocks.targets — consumers need only the opt-in property and
item. Fully inert otherwise (verified: full TUnit.Mocks.Tests suite
unaffected). Dual-path task resolution covers packaged and repo-local
layouts.
- Correctness fix surfaced by new coverage: publicize the
IMPLEMENTATION assembly (%(OriginalPath)), not the reference assembly
— Roslyn ref assemblies strip internal members when no IVT exists, so
internal constructors vanished and internal classes emitted TM006.
The compiler-facing item (the ref assembly) is still what gets
swapped out.
- Coverage expanded: internal class partial mocks (internal ctor),
closed-generic internal interfaces, strong-named target assembly
(identity preservation asserted down to the public key token), and
unit tests of the task itself — publicizing, incrementality, the
generated IgnoresAccessChecksTo source, and TUMIA001 for unresolved
names. Build-order edges use SetTargetFramework per task TFM
(SkipGetTargetFrameworkProperties alone leaks the referencing
TargetFramework and breaks the outer build).
- Verified against the produced nupkg: extracted-package buildTransitive
import compiles and runs a consumer implementing an internal
interface, including under PublishTrimmed (full trim mode). Native
AOT remains unverified.
- CI: projects added to TUnit.CI.slnx and a RunMockInternalsAccessTests
pipeline module runs the suite; docs gain an "Internals Access
(experimental)" section.
---
TUnit.CI.slnx | 3 +
docs/docs/writing-tests/mocking/advanced.md | 51 ++++++
.../PublicizeAssemblyReferences.cs | 43 +++--
.../README.md | 93 ++++++-----
.../TUnit.Mocks.InternalsAccess.Tasks.csproj | 12 +-
.../TUnit.Mocks.InternalsAccess.targets | 22 ++-
src/TUnit.Mocks/TUnit.Mocks.csproj | 37 +++++
src/TUnit.Mocks/TUnit.Mocks.targets | 4 +
.../SdkRuntime.cs | 34 ++++
...nit.Mocks.InternalsAccess.TargetLib.csproj | 5 +-
.../InternalsAccessTests.cs | 27 +++
.../PublicizeAssemblyReferencesTaskTests.cs | 154 ++++++++++++++++++
.../TUnit.Mocks.InternalsAccess.Tests.csproj | 12 +-
.../RunMockInternalsAccessTestsModule.cs | 33 ++++
14 files changed, 457 insertions(+), 73 deletions(-)
rename src/{TUnit.Mocks.InternalsAccess.Tasks => TUnit.Mocks}/TUnit.Mocks.InternalsAccess.targets (66%)
create mode 100644 tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
create mode 100644 tools/TUnit.Pipeline/Modules/RunMockInternalsAccessTestsModule.cs
diff --git a/TUnit.CI.slnx b/TUnit.CI.slnx
index b62cc54ae3..d5cd80e607 100644
--- a/TUnit.CI.slnx
+++ b/TUnit.CI.slnx
@@ -27,6 +27,7 @@
+
@@ -76,6 +77,8 @@
+
+
diff --git a/docs/docs/writing-tests/mocking/advanced.md b/docs/docs/writing-tests/mocking/advanced.md
index 261cb495d4..1e8ce29bc6 100644
--- a/docs/docs/writing-tests/mocking/advanced.md
+++ b/docs/docs/writing-tests/mocking/advanced.md
@@ -238,3 +238,54 @@ mock.Invocations.Count; // 0 (history cleared)
```
The `SetupAllProperties()` flag is preserved across resets.
+
+## Internals Access (experimental)
+
+Some SDKs route behavior through types that are `internal` to their own assembly — the classic
+example is `Microsoft.Azure.Functions.Worker`, whose `IInvocationFeatures.Get()` is called
+inside the SDK with `T = IFunctionBindingsFeature`, a type your test assembly cannot even name.
+Runtime-proxy libraries can auto-substitute such types (when the SDK grants `InternalsVisibleTo`
+to Castle's proxy assembly), but they can never let you *configure* one.
+
+TUnit.Mocks can, behind an experimental opt-in:
+
+```xml
+
+ true
+
+
+
+
+
+
+```
+
+Internal types of the listed assemblies then behave like public ones in your test project —
+nameable, source-generator mocked, with fully typed setups, matchers, and verification. No
+`InternalsVisibleTo` is required from the target assembly:
+
+```csharp
+var bindings = IFunctionBindingsFeature.Mock(); // internal to the SDK
+bindings.InvocationResult.Returns(myResult);
+
+features.Get().Returns(bindings.Object);
+features.Get().WasCalled(Times.Once);
+```
+
+### How it works
+
+At build time, each listed reference is swapped — for the compiler only — with a copy whose
+internals are rewritten to public, preserving the assembly identity. The original assembly still
+ships and loads; an `IgnoresAccessChecksTo` attribute (honored by the .NET runtime) makes the
+compiled IL valid against it at execution time. This is the established "publicizer" pattern
+used by several long-lived OSS tools, wired into the TUnit.Mocks package.
+
+### Caveats
+
+- **Experimental.** `IgnoresAccessChecksToAttribute` is honored by the runtime but is not a
+ documented public contract.
+- Not supported on .NET Framework test targets (the runtime there does not honor the attribute);
+ a build warning is emitted and the pipeline stays inert.
+- Works under trimmed publishes; Native AOT is not yet verified.
+- Internal APIs are internal for a reason: they can change in any release of the target package.
+ Prefer public seams when they exist.
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
index 3d49b56060..8214b05d6f 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -1,3 +1,7 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Mono.Cecil;
@@ -19,7 +23,10 @@ namespace TUnit.Mocks.InternalsAccess.Tasks;
///
public sealed class PublicizeAssemblyReferences : Microsoft.Build.Utilities.Task
{
- /// All resolved compile-time references (@(ReferencePath)).
+ ///
+ /// The compiler's resolved references (@(ReferencePathWithRefAssemblies)) — each ItemSpec is
+ /// exactly the file the compiler would consume, reference assemblies included.
+ ///
[Required]
public ITaskItem[] ReferencePaths { get; set; } = [];
@@ -36,7 +43,7 @@ public sealed class PublicizeAssemblyReferences : Microsoft.Build.Utilities.Task
public string GeneratedSourceFile { get; set; } = "";
///
- /// Publicized references. ItemSpec = rewritten copy; %(Original) = the ReferencePath item it
+ /// Publicized references. ItemSpec = rewritten copy; %(Original) = the reference item it
/// replaces, for the targets file to Remove.
///
[Output]
@@ -57,16 +64,22 @@ public override bool Execute()
if (reference is null)
{
- Log.LogError($"TUnitMocksInternalsAccess: no resolved reference named '{name}' was found. " +
+ Log.LogError(
+ subcategory: null, errorCode: "TUMIA001", helpKeyword: null,
+ file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0,
+ message: $"TUnitMocksInternalsAccess: no resolved reference named '{name}' was found. " +
"The value must be the simple assembly name of a direct or transitive reference.");
- return false;
+ continue;
}
- // The compiler consumes the reference assembly when one exists (project references
- // produce one under obj/ref; packages may ship ref/ assemblies) — that is the file
- // that must be publicized.
- var referenceAssembly = reference.GetMetadata("ReferenceAssembly");
- var source = string.IsNullOrEmpty(referenceAssembly) ? reference.ItemSpec : referenceAssembly;
+ // Publicize the IMPLEMENTATION assembly, not the reference assembly the compiler
+ // would normally consume: Roslyn ref assemblies strip internal members when the
+ // assembly grants no InternalsVisibleTo (internal types survive as empty shells —
+ // e.g. an internal constructor would be gone). ReferencePathWithRefAssemblies items
+ // carry the implementation path as %(OriginalPath) when a ref assembly was
+ // substituted.
+ var originalPath = reference.GetMetadata("OriginalPath");
+ var source = string.IsNullOrEmpty(originalPath) ? reference.ItemSpec : originalPath;
var destination = Path.Combine(OutputDirectory, Path.GetFileName(source));
if (!File.Exists(destination) || File.GetLastWriteTimeUtc(destination) < File.GetLastWriteTimeUtc(source))
@@ -74,8 +87,14 @@ public override bool Execute()
Publicize(source, destination);
Log.LogMessage(MessageImportance.Normal, $"TUnitMocksInternalsAccess: publicized '{source}' -> '{destination}'.");
}
+ else
+ {
+ Log.LogMessage(MessageImportance.Low, $"TUnitMocksInternalsAccess: '{destination}' is up to date.");
+ }
var item = new TaskItem(destination);
+ // "Original" is what the targets file Removes — the reference item as the compiler
+ // knew it (the ref assembly when one existed), not the implementation path.
item.SetMetadata("Original", reference.ItemSpec);
// Compile-time only: never copy the rewritten assembly to the output directory.
item.SetMetadata("Private", "false");
@@ -84,7 +103,11 @@ public override bool Execute()
publicizedNames.Add(name);
}
- WriteIgnoresAccessChecksToSource(publicizedNames);
+ if (!Log.HasLoggedErrors)
+ {
+ WriteIgnoresAccessChecksToSource(publicizedNames);
+ }
+
PublicizedReferences = outputs.ToArray();
return !Log.HasLoggedErrors;
}
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
index f8f00a14cc..bf3c35246a 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
@@ -1,17 +1,16 @@
# TUnit.Mocks internals access (experimental)
-Prototype for [#6514](https://github.com/thomhurst/TUnit/issues/6514) "Tier 2": make types that
-are `internal` to another assembly first-class mockable — nameable in test code, source-generated
+The Tier 2 answer to [#6514](https://github.com/thomhurst/TUnit/issues/6514): make types that are
+`internal` to another assembly first-class mockable — nameable in test code, source-generated
typed mocks, setups, matchers, and verification — even when that assembly grants **no**
-`InternalsVisibleTo` at all.
-
-This complements the runtime auto-stubs from PR #6519 (Tier 1). Stubs are zero-config but
-anonymous: they satisfy an SDK's internal `Get()` calls with functional defaults, yet the test
-can never configure or verify them, because it cannot write the type's name. Internals access
-removes that constraint entirely.
+`InternalsVisibleTo` at all. Complements the runtime auto-stubs from PR #6519 (Tier 1), which are
+zero-config but anonymous: a stub satisfies an SDK's internal `Get()` calls, yet the test can
+never configure or verify a type it cannot name.
## Usage
+Ships inside the `TUnit.Mocks` package; consumers only need the opt-in:
+
```xml
true
@@ -21,16 +20,6 @@ removes that constraint entirely.
-
-
-```
-
-Then internal types of that assembly are usable in test code like public ones:
-
-```csharp
-var bindings = IFunctionBindingsFeature.Mock(); // internal to the SDK — now mockable
-features.Get().Returns(bindings.Object);
-features.Get().WasCalled(Times.Once);
```
## How it works
@@ -38,38 +27,48 @@ features.Get().WasCalled(Times.Once);
The established "publicizer" pattern (Krafs.Publicizer, IgnoresAccessChecksToGenerator), wired
for TUnit.Mocks:
-1. After reference resolution, the `PublicizeAssemblyReferences` task rewrites each requested
- reference (Mono.Cecil) so its internal types and members are public, preserving the assembly
- identity (name, version, public key). The copy lives under `obj/` only.
-2. The swap happens on `ReferencePathWithRefAssemblies` — the item group that feeds the compiler
- and nothing else. `ReferencePath` is untouched, so copy-local output and `deps.json` keep the
- original assembly and the runtime loads the real thing.
-3. The task emits `[assembly: IgnoresAccessChecksTo("...")]` (plus the attribute definition) into
- the compilation. CoreCLR honors it and skips accessibility checks from the test assembly to
- the named assemblies, so the compiled IL — including generated mock classes implementing
- internal interfaces — loads and runs against the original assembly.
+1. `PublicizeAssemblyReferences` (Mono.Cecil) rewrites each requested reference so its internal
+ types and members are public, preserving the assembly identity (name, version, public key).
+ The **implementation** assembly is used as the source — Roslyn reference assemblies strip
+ internal members (e.g. internal constructors) when no `InternalsVisibleTo` exists, so
+ publicizing a ref assembly would yield empty shells. Copies live under `obj/` only.
+2. `TUnit.Mocks.InternalsAccess.targets` (imported by `TUnit.Mocks.targets`, fully inert without
+ the opt-in) swaps the copies into `ReferencePathWithRefAssemblies` — the item group that feeds
+ the compiler and nothing else. `ReferencePath` is untouched, so copy-local output and
+ `deps.json` keep the original assembly and the runtime binds to it.
+3. The task emits `[assembly: IgnoresAccessChecksTo(...)]` (plus the attribute definition) into
+ the compilation; the runtime honors it and skips accessibility checks, so generated mock
+ classes implementing internal interfaces load and run against the original assembly.
4. The TUnit.Mocks source generator needs no changes: through the publicized reference the
- internal types simply look public, so discovery, TM007 accessibility checks, and mock
- emission all behave as for any public type.
+ internal types simply look public — discovery, TM007 accessibility checks, and emission all
+ behave as for any public type.
+
+## Layout
+
+- Task assembly: `tasks/net472/` (Visual Studio's .NET Framework MSBuild) and `tasks/net8.0/`
+ (`dotnet msbuild`), each with `Mono.Cecil.dll` beside it; selected via `$(MSBuildRuntimeType)`.
+- Targets: `buildTransitive//TUnit.Mocks.InternalsAccess.targets`, imported from
+ `TUnit.Mocks.targets`. Repo-local builds resolve the task from this project's bin instead.
-## Caveats (why this is experimental)
+## Caveats
-- `IgnoresAccessChecksToAttribute` is honored by CoreCLR but is not a documented public contract.
- It is the foundation of several long-lived OSS packages, so breakage risk is low but nonzero.
-- .NET Framework does not honor the attribute — the pipeline is inert there (warning emitted).
-- Native AOT / trimming behavior is unverified.
-- The publicizer currently rewrites type and method accessibility (fields are left alone) and
- skips explicit interface implementations.
-- MSBuild loads the task assembly in-proc and holds a file lock across builds in the same node;
- after editing the task itself, run `dotnet build-server shutdown` before rebuilding it.
-- Not packaged yet: consumers import the `.targets` file directly and the task assembly is
- resolved from this project's build output. Packaging (`tasks/` folder + `buildTransitive`)
- is the productionization step.
+- `IgnoresAccessChecksToAttribute` is honored by CoreCLR but is not a documented public contract
+ (it underpins several long-lived OSS packages; breakage risk is low but nonzero).
+- .NET Framework test targets are unsupported (warning `TUMIA002`, pipeline stays inert).
+- Verified under `PublishTrimmed` (full trim mode); Native AOT not yet verified.
+- Publicizer scope: types and methods (constructors and accessors included); fields are left
+ alone; explicit interface implementations stay private as IL requires.
+- Dev loop: MSBuild nodes hold the task assembly's file lock across builds — run
+ `dotnet build-server shutdown` after changing this project.
## Validation
-`tests/TUnit.Mocks.InternalsAccess.TargetLib` stands in for a third-party SDK: an internal
-interface, a public generic accessor requested from SDK-internal code, and deliberately **zero**
-IVT grants — the exact case Tier 1 cannot reach. `tests/TUnit.Mocks.InternalsAccess.Tests`
-exercises naming, mocking, typed setup/matcher/verification, and a hand-written implementation
-of the internal interface (type-load proof).
+- `tests/TUnit.Mocks.InternalsAccess.TargetLib` — a strong-named stand-in SDK with an internal
+ interface, internal generic interface, internal class (internal constructor), and public
+ generic accessors called from SDK-internal code; deliberately **zero** IVT grants.
+- `tests/TUnit.Mocks.InternalsAccess.Tests` — end-to-end pipeline tests (naming, mocking, typed
+ setup/matcher/verification, partial-mocking the internal class, manual implementations) plus
+ unit tests of the task itself (publicizing, identity preservation, incrementality, generated
+ source, `TUMIA001` errors). Runs in CI via `RunMockInternalsAccessTestsModule`.
+- Packaged-layout and trimmed-publish verification were exercised against the produced nupkg
+ (extracted `buildTransitive` import + `PublishTrimmed=true` console consumer).
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj b/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj
index d41d1aa7d4..e402b29b0e 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj
@@ -1,9 +1,11 @@
-
- net10.0
+
+ net472;net8.0
false
@@ -13,7 +15,9 @@
-
+
+
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.targets b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets
similarity index 66%
rename from src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.targets
rename to src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets
index 255ca89258..8884d1c446 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.targets
+++ b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets
@@ -6,15 +6,22 @@
Selected references are swapped (for the compiler only) with copies whose internals are
public, and IgnoresAccessChecksTo is applied so CoreCLR skips accessibility checks at
runtime. The original assemblies still ship and load; the rewritten copies never leave
- the intermediate directory. -->
+ the intermediate directory. Fully inert unless the property is set. -->
-
- $(MSBuildThisFileDirectory)bin\$(Configuration)\net10.0\TUnit.Mocks.InternalsAccess.Tasks.dll
+
+ <_TUnitMocksInternalsAccessTasksTfm Condition="'$(MSBuildRuntimeType)' == 'Core'">net8.0
+ <_TUnitMocksInternalsAccessTasksTfm Condition="'$(_TUnitMocksInternalsAccessTasksTfm)' == ''">net472
+
+
+ $(MSBuildThisFileDirectory)..\..\tasks\$(_TUnitMocksInternalsAccessTasksTfm)\TUnit.Mocks.InternalsAccess.Tasks.dll
+
+
+ $(MSBuildThisFileDirectory)..\TUnit.Mocks.InternalsAccess.Tasks\bin\$(Configuration)\$(_TUnitMocksInternalsAccessTasksTfm)\TUnit.Mocks.InternalsAccess.Tasks.dll
-
@@ -22,7 +29,8 @@
-
+
true
$(BuildPath)
+
+
+ true
+ $(BuildTransitivePath)
+
+
+
+ true
+ $(BuildPath)
+
+
+
+
+
+ $(NoWarn);NU5100
+
+
+
+
+
+
+
+
+
+
diff --git a/src/TUnit.Mocks/TUnit.Mocks.targets b/src/TUnit.Mocks/TUnit.Mocks.targets
index 96c60220a6..be51c2ba0e 100644
--- a/src/TUnit.Mocks/TUnit.Mocks.targets
+++ b/src/TUnit.Mocks/TUnit.Mocks.targets
@@ -5,4 +5,8 @@
+
+
+
diff --git a/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs b/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs
index 20fe5c4321..e9c3641313 100644
--- a/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs
@@ -19,11 +19,45 @@ internal interface IInternalBindingsFeature
int Compute(int seed);
}
+///
+/// An internal generic interface: closed instantiations must be mockable too.
+///
+internal interface IInternalRepository
+{
+ T Load(int id);
+}
+
+///
+/// An internal class with virtual members and an internal constructor — the partial-mock shape.
+///
+internal class InternalWidget
+{
+ internal InternalWidget()
+ {
+ }
+
+ public virtual string Name => "real-widget";
+
+ public virtual int Weight() => 100;
+}
+
///
/// Simulates SDK-internal call sites the test has no control over.
///
public static class SdkRuntime
{
+ public static string DescribeRepository(IFeatureCollection features)
+ {
+ var repository = features.Get>();
+ return repository is null ? "" : repository.Load(1);
+ }
+
+ public static string DescribeWidget(IFeatureCollection features)
+ {
+ var widget = features.Get();
+ return widget is null ? "" : $"{widget.Name}:{widget.Weight()}";
+ }
+
public static string DescribeInvocation(IFeatureCollection features)
{
var bindings = features.Get();
diff --git a/tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj b/tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj
index a0c04ac755..8aa5d27690 100644
--- a/tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj
+++ b/tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj
@@ -4,9 +4,12 @@
+ cover). Strong-named like real SDK assemblies: the publicized compile-time copy must
+ preserve the signed identity so the compiled IL binds to this original at runtime. -->
net10.0
false
+ true
+ ..\..\eng\strongname.snk
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs
index c69927ae89..7d97d13bd6 100644
--- a/tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs
@@ -54,6 +54,33 @@ public async Task Typed_Setup_With_Matchers_On_Internal_Member()
bindings.Compute(99).WasNeverCalled();
}
+ [Test]
+ public async Task Closed_Generic_Internal_Interface_Is_Mockable()
+ {
+ var repository = IInternalRepository.Mock();
+ repository.Load(Any()).Returns(id => $"row-{id}");
+
+ var features = IFeatureCollection.Mock();
+ features.Get>().Returns(repository.Object);
+
+ await Assert.That(SdkRuntime.DescribeRepository(features.Object)).IsEqualTo("row-1");
+ }
+
+ [Test]
+ public async Task Internal_Class_Is_Partial_Mockable()
+ {
+ // Partial-mock shape: internal class, virtual members, internal constructor — all
+ // publicized for the compiler, honored by IgnoresAccessChecksTo at runtime.
+ var widget = InternalWidget.Mock();
+ widget.Name.Returns("mocked");
+
+ var features = IFeatureCollection.Mock();
+ features.Get().Returns(widget.Object);
+
+ // Weight() is unconfigured, so the virtual base implementation runs.
+ await Assert.That(SdkRuntime.DescribeWidget(features.Object)).IsEqualTo("mocked:100");
+ }
+
[Test]
public async Task Manual_Implementation_Of_The_Internal_Interface_Loads_And_Runs()
{
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
new file mode 100644
index 0000000000..5ef030a6d4
--- /dev/null
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
@@ -0,0 +1,154 @@
+using System.Collections;
+using System.Reflection;
+using System.Runtime.Loader;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+using TUnit.Mocks.InternalsAccess.Tasks;
+using Task = System.Threading.Tasks.Task;
+
+namespace TUnit.Mocks.InternalsAccess.Tests;
+
+// Unit tests for the publicizer task itself: rewriting, incrementality, the generated
+// IgnoresAccessChecksTo source, and error behavior. The end-to-end pipeline (targets wiring,
+// compiler swap, runtime behavior) is covered by InternalsAccessTests.
+
+public class PublicizeAssemblyReferencesTaskTests
+{
+ private const string TargetLibName = "TUnit.Mocks.InternalsAccess.TargetLib";
+
+ private static string TargetLibPath =>
+ Path.Combine(AppContext.BaseDirectory, TargetLibName + ".dll");
+
+ private static PublicizeAssemblyReferences CreateTask(string outputDirectory, params string[] names)
+ => new()
+ {
+ BuildEngine = new StubBuildEngine(),
+ ReferencePaths = [new TaskItem(TargetLibPath), new TaskItem(Path.Combine(AppContext.BaseDirectory, "TUnit.Mocks.dll"))],
+ AssembliesToPublicize = names.Select(ITaskItem (n) => new TaskItem(n)).ToArray(),
+ OutputDirectory = outputDirectory,
+ GeneratedSourceFile = Path.Combine(outputDirectory, "iact.g.cs"),
+ };
+
+ private static string NewScratchDirectory()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "tunit-mocks-ia-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ [Test]
+ public async Task Publicizes_Internal_Types_And_Preserves_Identity()
+ {
+ var dir = NewScratchDirectory();
+ var task = CreateTask(dir, TargetLibName);
+
+ await Assert.That(task.Execute()).IsTrue();
+
+ var rewritten = Path.Combine(dir, TargetLibName + ".dll");
+ await Assert.That(File.Exists(rewritten)).IsTrue();
+
+ var context = new AssemblyLoadContext("publicized-probe", isCollectible: true);
+ try
+ {
+ var assembly = context.LoadFromAssemblyPath(rewritten);
+
+ var internalInterface = assembly.GetType("FakeSdk.IInternalBindingsFeature", throwOnError: true)!;
+ await Assert.That(internalInterface.IsPublic).IsTrue();
+
+ var internalClass = assembly.GetType("FakeSdk.InternalWidget", throwOnError: true)!;
+ await Assert.That(internalClass.IsPublic).IsTrue();
+ var constructor = internalClass.GetConstructors(BindingFlags.Instance | BindingFlags.Public);
+ await Assert.That(constructor.Length).IsEqualTo(1);
+
+ // Identity must match the original, strong-name public key included, so compiled IL
+ // binds to the real assembly at runtime.
+ var original = AssemblyName.GetAssemblyName(TargetLibPath);
+ var publicized = assembly.GetName();
+ await Assert.That(publicized.FullName).IsEqualTo(original.FullName);
+ await Assert.That(publicized.GetPublicKeyToken()).IsEquivalentTo(original.GetPublicKeyToken()!);
+ }
+ finally
+ {
+ context.Unload();
+ }
+ }
+
+ [Test]
+ public async Task Generates_IgnoresAccessChecksTo_Source_For_All_Requested_Assemblies()
+ {
+ var dir = NewScratchDirectory();
+ var task = CreateTask(dir, TargetLibName, "TUnit.Mocks");
+
+ await Assert.That(task.Execute()).IsTrue();
+
+ var source = await File.ReadAllTextAsync(task.GeneratedSourceFile);
+ await Assert.That(source).Contains($"IgnoresAccessChecksTo(\"{TargetLibName}\")");
+ await Assert.That(source).Contains("IgnoresAccessChecksTo(\"TUnit.Mocks\")");
+ await Assert.That(source).Contains("class IgnoresAccessChecksToAttribute");
+
+ await Assert.That(task.PublicizedReferences.Length).IsEqualTo(2);
+ await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(TargetLibPath);
+ await Assert.That(task.PublicizedReferences[0].GetMetadata("Private")).IsEqualTo("false");
+ }
+
+ [Test]
+ public async Task Second_Run_Is_Incremental()
+ {
+ var dir = NewScratchDirectory();
+
+ var first = CreateTask(dir, TargetLibName);
+ await Assert.That(first.Execute()).IsTrue();
+
+ var rewritten = Path.Combine(dir, TargetLibName + ".dll");
+ var firstWrite = File.GetLastWriteTimeUtc(rewritten);
+ var firstSourceWrite = File.GetLastWriteTimeUtc(first.GeneratedSourceFile);
+
+ var second = CreateTask(dir, TargetLibName);
+ await Assert.That(second.Execute()).IsTrue();
+
+ await Assert.That(File.GetLastWriteTimeUtc(rewritten)).IsEqualTo(firstWrite);
+ await Assert.That(File.GetLastWriteTimeUtc(second.GeneratedSourceFile)).IsEqualTo(firstSourceWrite);
+ }
+
+ [Test]
+ public async Task Unresolved_Assembly_Name_Fails_With_TUMIA001()
+ {
+ var dir = NewScratchDirectory();
+ var engine = new StubBuildEngine();
+ var task = CreateTask(dir, "No.Such.Assembly");
+ task.BuildEngine = engine;
+
+ await Assert.That(task.Execute()).IsFalse();
+ await Assert.That(engine.Errors.Count).IsEqualTo(1);
+ await Assert.That(engine.Errors[0].Code).IsEqualTo("TUMIA001");
+ }
+
+ private sealed class StubBuildEngine : IBuildEngine
+ {
+ public List Errors { get; } = [];
+
+ public bool ContinueOnError => false;
+
+ public int LineNumberOfTaskNode => 0;
+
+ public int ColumnNumberOfTaskNode => 0;
+
+ public string ProjectFileOfTaskNode => "";
+
+ public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs) => false;
+
+ public void LogCustomEvent(CustomBuildEventArgs e)
+ {
+ }
+
+ public void LogErrorEvent(BuildErrorEventArgs e) => Errors.Add(e);
+
+ public void LogMessageEvent(BuildMessageEventArgs e)
+ {
+ }
+
+ public void LogWarningEvent(BuildWarningEventArgs e)
+ {
+ }
+ }
+}
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj b/tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj
index 9f3fafe12a..93923485c6 100644
--- a/tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj
@@ -22,14 +22,18 @@
-
-
+
+
+
+
+
+
-
diff --git a/tools/TUnit.Pipeline/Modules/RunMockInternalsAccessTestsModule.cs b/tools/TUnit.Pipeline/Modules/RunMockInternalsAccessTestsModule.cs
new file mode 100644
index 0000000000..cee8287418
--- /dev/null
+++ b/tools/TUnit.Pipeline/Modules/RunMockInternalsAccessTestsModule.cs
@@ -0,0 +1,33 @@
+using ModularPipelines.Context;
+using ModularPipelines.DotNet.Options;
+using ModularPipelines.Extensions;
+using ModularPipelines.Git.Extensions;
+using ModularPipelines.Options;
+using TUnit.Pipeline.Modules.Abstract;
+
+namespace TUnit.Pipeline.Modules;
+
+public class RunMockInternalsAccessTestsModule : TestBaseModule
+{
+ protected override Task<(DotNetRunOptions Options, CommandExecutionOptions? ExecutionOptions)> GetTestOptions(IModuleContext context, string framework, CancellationToken cancellationToken)
+ {
+ var project = context.Git().RootDirectory.FindFile(x => x.Name == "TUnit.Mocks.InternalsAccess.Tests.csproj").AssertExists();
+
+ return Task.FromResult<(DotNetRunOptions, CommandExecutionOptions?)>((
+ new DotNetRunOptions
+ {
+ NoBuild = true,
+ Configuration = "Release",
+ Framework = framework,
+ },
+ new CommandExecutionOptions
+ {
+ WorkingDirectory = project.Folder!.Path,
+ EnvironmentVariables = new Dictionary
+ {
+ ["DISABLE_GITHUB_REPORTER"] = "true",
+ }
+ }
+ ));
+ }
+}
From 5ea2559eaeeab8d90e6e2542558bc2769fe78895 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 30 Jul 2026 09:15:36 +0100
Subject: [PATCH 03/10] fix(mocks): harden internals-access task per review
- Preserve original reference metadata (Aliases, EmbedInteropTypes) on the
swapped compiler reference via CopyMetadataTo
- Content-hash (.sig) incrementality instead of timestamp comparison, so
downgrades/equal-timestamp replacements re-publicize; sig files in FileWrites
- Resolve implementation assembly from runtime/copy-local assets when the
compile asset is a ref assembly with no OriginalPath (TUMIA003 when absent)
- Warn TUMIA004 on ambiguous simple-name matches instead of silent first-pick
- Wrap resolve/publicize in try/catch -> TUMIA005 error instead of unhandled
Cecil exception crashing the build host
- TUnitMocksInternalsAccessEmitAttributeDefinition=false suppresses the
IgnoresAccessChecksToAttribute definition when another package injects it
- README: diagnostics table + rationale for custom task vs Krafs.Publicizer
---
docs/docs/writing-tests/mocking/advanced.md | 3 +
.../PublicizeAssemblyReferences.cs | 169 +++++++++++++++---
.../README.md | 33 +++-
.../TUnit.Mocks.InternalsAccess.targets | 8 +-
.../PublicizeAssemblyReferencesTaskTests.cs | 161 ++++++++++++++++-
5 files changed, 337 insertions(+), 37 deletions(-)
diff --git a/docs/docs/writing-tests/mocking/advanced.md b/docs/docs/writing-tests/mocking/advanced.md
index 1e8ce29bc6..3f3d11b58e 100644
--- a/docs/docs/writing-tests/mocking/advanced.md
+++ b/docs/docs/writing-tests/mocking/advanced.md
@@ -287,5 +287,8 @@ used by several long-lived OSS tools, wired into the TUnit.Mocks package.
- Not supported on .NET Framework test targets (the runtime there does not honor the attribute);
a build warning is emitted and the pipeline stays inert.
- Works under trimmed publishes; Native AOT is not yet verified.
+- If another package already injects an `IgnoresAccessChecksToAttribute` definition into your
+ compilation (e.g. IgnoresAccessChecksToGenerator), suppress TUnit's copy with
+ `false`.
- Internal APIs are internal for a reason: they can change in any release of the target package.
Prefer public seams when they exist.
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
index 8214b05d6f..8aabca39cb 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Security.Cryptography;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Mono.Cecil;
@@ -34,6 +35,13 @@ public sealed class PublicizeAssemblyReferences : Microsoft.Build.Utilities.Task
[Required]
public ITaskItem[] AssembliesToPublicize { get; set; } = [];
+ ///
+ /// Runtime/copy-local assets (implementation assemblies), used to locate the implementation
+ /// when the compiler reference is itself a metadata-only reference assembly with no
+ /// %(OriginalPath) — e.g. a package whose compile asset comes straight from ref/<tfm>.
+ ///
+ public ITaskItem[] RuntimeAssemblies { get; set; } = [];
+
/// Directory for the rewritten compile-time copies.
[Required]
public string OutputDirectory { get; set; } = "";
@@ -42,6 +50,13 @@ public sealed class PublicizeAssemblyReferences : Microsoft.Build.Utilities.Task
[Required]
public string GeneratedSourceFile { get; set; } = "";
+ ///
+ /// Whether the generated source also defines IgnoresAccessChecksToAttribute. Turn off when
+ /// another package (IgnoresAccessChecksToGenerator, Fody, ...) already injects the same type
+ /// into the compilation, which would otherwise be a duplicate-type compile error.
+ ///
+ public bool EmitAttributeDefinition { get; set; } = true;
+
///
/// Publicized references. ItemSpec = rewritten copy; %(Original) = the reference item it
/// replaces, for the targets file to Remove.
@@ -59,10 +74,10 @@ public override bool Execute()
foreach (var requested in AssembliesToPublicize)
{
var name = requested.ItemSpec;
- var reference = ReferencePaths.FirstOrDefault(r =>
- string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase));
+ var matches = ReferencePaths.Where(r =>
+ string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase)).ToList();
- if (reference is null)
+ if (matches.Count == 0)
{
Log.LogError(
subcategory: null, errorCode: "TUMIA001", helpKeyword: null,
@@ -72,27 +87,57 @@ public override bool Execute()
continue;
}
- // Publicize the IMPLEMENTATION assembly, not the reference assembly the compiler
- // would normally consume: Roslyn ref assemblies strip internal members when the
- // assembly grants no InternalsVisibleTo (internal types survive as empty shells —
- // e.g. an internal constructor would be gone). ReferencePathWithRefAssemblies items
- // carry the implementation path as %(OriginalPath) when a ref assembly was
- // substituted.
- var originalPath = reference.GetMetadata("OriginalPath");
- var source = string.IsNullOrEmpty(originalPath) ? reference.ItemSpec : originalPath;
- var destination = Path.Combine(OutputDirectory, Path.GetFileName(source));
-
- if (!File.Exists(destination) || File.GetLastWriteTimeUtc(destination) < File.GetLastWriteTimeUtc(source))
+ if (matches.Select(m => m.ItemSpec).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
+ {
+ Log.LogWarning(
+ subcategory: null, warningCode: "TUMIA004", helpKeyword: null,
+ file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0,
+ message: $"TUnitMocksInternalsAccess: multiple resolved references match '{name}': " +
+ $"{string.Join(", ", matches.Select(m => m.ItemSpec))}. Using the first; " +
+ "if that is the wrong one, resolve the version conflict in the project.");
+ }
+
+ var reference = matches[0];
+ string source;
+ string destination;
+
+ try
{
- Publicize(source, destination);
- Log.LogMessage(MessageImportance.Normal, $"TUnitMocksInternalsAccess: publicized '{source}' -> '{destination}'.");
+ source = ResolveImplementationAssembly(name, reference);
+ destination = Path.Combine(OutputDirectory, Path.GetFileName(source));
+
+ // Content-based incrementality: the signature records the resolved source path
+ // and a hash of its bytes, so a replaced/downgraded assembly with an equal or
+ // older timestamp still invalidates the publicized copy.
+ var signaturePath = destination + ".sig";
+ var signature = source + "\n" + HashFile(source);
+
+ if (!File.Exists(destination) || !File.Exists(signaturePath) || File.ReadAllText(signaturePath) != signature)
+ {
+ Publicize(source, destination);
+ File.WriteAllText(signaturePath, signature);
+ Log.LogMessage(MessageImportance.Normal, $"TUnitMocksInternalsAccess: publicized '{source}' -> '{destination}'.");
+ }
+ else
+ {
+ Log.LogMessage(MessageImportance.Low, $"TUnitMocksInternalsAccess: '{destination}' is up to date.");
+ }
}
- else
+ catch (Exception ex)
{
- Log.LogMessage(MessageImportance.Low, $"TUnitMocksInternalsAccess: '{destination}' is up to date.");
+ Log.LogError(
+ subcategory: null, errorCode: "TUMIA005", helpKeyword: null,
+ file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0,
+ message: $"TUnitMocksInternalsAccess: failed to publicize '{reference.ItemSpec}': {ex.Message}");
+ Log.LogMessage(MessageImportance.Low, ex.ToString());
+ continue;
}
var item = new TaskItem(destination);
+ // Preserve the original reference's metadata (Aliases, EmbedInteropTypes, ...) —
+ // Csc reads compiler-significant options from item metadata, and an extern-aliased
+ // reference must stay extern-aliased after the swap.
+ reference.CopyMetadataTo(item);
// "Original" is what the targets file Removes — the reference item as the compiler
// knew it (the ref assembly when one existed), not the implementation path.
item.SetMetadata("Original", reference.ItemSpec);
@@ -112,6 +157,69 @@ public override bool Execute()
return !Log.HasLoggedErrors;
}
+ ///
+ /// Picks the implementation assembly to publicize. Roslyn reference assemblies strip
+ /// internal members when the assembly grants no InternalsVisibleTo (internal types survive
+ /// as empty shells — e.g. an internal constructor would be gone), so a ref assembly is
+ /// useless as a publicizer source. ReferencePathWithRefAssemblies items carry the
+ /// implementation path as %(OriginalPath) when a ref assembly was substituted; when a
+ /// package supplies its compile asset from ref/<tfm> directly there is no OriginalPath, so
+ /// fall back to the runtime/copy-local assets to find the implementation.
+ ///
+ private string ResolveImplementationAssembly(string name, ITaskItem reference)
+ {
+ var originalPath = reference.GetMetadata("OriginalPath");
+ if (!string.IsNullOrEmpty(originalPath))
+ {
+ return originalPath;
+ }
+
+ if (!IsReferenceAssembly(reference.ItemSpec))
+ {
+ return reference.ItemSpec;
+ }
+
+ var runtimeMatch = RuntimeAssemblies.FirstOrDefault(r =>
+ string.Equals(Path.GetExtension(r.ItemSpec), ".dll", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase) &&
+ File.Exists(r.ItemSpec));
+
+ if (runtimeMatch is not null)
+ {
+ Log.LogMessage(MessageImportance.Normal,
+ $"TUnitMocksInternalsAccess: '{reference.ItemSpec}' is a reference assembly; " +
+ $"publicizing the implementation '{runtimeMatch.ItemSpec}' instead.");
+ return runtimeMatch.ItemSpec;
+ }
+
+ Log.LogWarning(
+ subcategory: null, warningCode: "TUMIA003", helpKeyword: null,
+ file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0,
+ message: $"TUnitMocksInternalsAccess: '{reference.ItemSpec}' is a metadata-only reference assembly " +
+ "and no implementation assembly was found among the runtime assets. Internal members may " +
+ "already be stripped from it, in which case internals access will be incomplete for " +
+ $"'{name}'.");
+ return reference.ItemSpec;
+ }
+
+ private static bool IsReferenceAssembly(string path)
+ {
+ using var module = ModuleDefinition.ReadModule(path);
+ return module.Assembly.HasCustomAttributes && module.Assembly.CustomAttributes.Any(a =>
+ a.AttributeType.FullName == "System.Runtime.CompilerServices.ReferenceAssemblyAttribute");
+ }
+
+ private static string HashFile(string path)
+ {
+ using var sha = SHA256.Create();
+ using var stream = File.OpenRead(path);
+#if NET
+ return Convert.ToHexString(sha.ComputeHash(stream));
+#else
+ return BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "");
+#endif
+ }
+
private static void Publicize(string source, string destination)
{
using var module = ModuleDefinition.ReadModule(source);
@@ -160,17 +268,20 @@ private void WriteIgnoresAccessChecksToSource(List assemblyNames)
writer.WriteLine($"[assembly: System.Runtime.CompilerServices.IgnoresAccessChecksTo(\"{name}\")]");
}
- writer.WriteLine();
- writer.WriteLine("namespace System.Runtime.CompilerServices");
- writer.WriteLine("{");
- writer.WriteLine(" [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]");
- writer.WriteLine(" internal sealed class IgnoresAccessChecksToAttribute : Attribute");
- writer.WriteLine(" {");
- writer.WriteLine(" public IgnoresAccessChecksToAttribute(string assemblyName) => AssemblyName = assemblyName;");
- writer.WriteLine();
- writer.WriteLine(" public string AssemblyName { get; }");
- writer.WriteLine(" }");
- writer.WriteLine("}");
+ if (EmitAttributeDefinition)
+ {
+ writer.WriteLine();
+ writer.WriteLine("namespace System.Runtime.CompilerServices");
+ writer.WriteLine("{");
+ writer.WriteLine(" [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]");
+ writer.WriteLine(" internal sealed class IgnoresAccessChecksToAttribute : Attribute");
+ writer.WriteLine(" {");
+ writer.WriteLine(" public IgnoresAccessChecksToAttribute(string assemblyName) => AssemblyName = assemblyName;");
+ writer.WriteLine();
+ writer.WriteLine(" public string AssemblyName { get; }");
+ writer.WriteLine(" }");
+ writer.WriteLine("}");
+ }
var content = writer.ToString();
Directory.CreateDirectory(Path.GetDirectoryName(GeneratedSourceFile)!);
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
index bf3c35246a..a832fd0e3d 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
@@ -31,14 +31,23 @@ for TUnit.Mocks:
types and members are public, preserving the assembly identity (name, version, public key).
The **implementation** assembly is used as the source — Roslyn reference assemblies strip
internal members (e.g. internal constructors) when no `InternalsVisibleTo` exists, so
- publicizing a ref assembly would yield empty shells. Copies live under `obj/` only.
+ publicizing a ref assembly would yield empty shells. `%(OriginalPath)` supplies the
+ implementation when MSBuild substituted a ref assembly; when a package ships its compile
+ asset from `ref/` directly (no `OriginalPath`), the implementation is located among the
+ runtime/copy-local assets instead (warning `TUMIA003` if none is found). Copies live under
+ `obj/` only, invalidated by content hash (not timestamps), so downgrades and equal-timestamp
+ replacements re-publicize.
2. `TUnit.Mocks.InternalsAccess.targets` (imported by `TUnit.Mocks.targets`, fully inert without
the opt-in) swaps the copies into `ReferencePathWithRefAssemblies` — the item group that feeds
- the compiler and nothing else. `ReferencePath` is untouched, so copy-local output and
- `deps.json` keep the original assembly and the runtime binds to it.
+ the compiler and nothing else. The replacement item carries the original reference's metadata
+ (`Aliases`, `EmbedInteropTypes`, ...) so extern-aliased references stay aliased.
+ `ReferencePath` is untouched, so copy-local output and `deps.json` keep the original assembly
+ and the runtime binds to it.
3. The task emits `[assembly: IgnoresAccessChecksTo(...)]` (plus the attribute definition) into
the compilation; the runtime honors it and skips accessibility checks, so generated mock
- classes implementing internal interfaces load and run against the original assembly.
+ classes implementing internal interfaces load and run against the original assembly. If
+ another package already defines `IgnoresAccessChecksToAttribute` in the compilation, set
+ `false` to suppress TUnit's copy.
4. The TUnit.Mocks source generator needs no changes: through the publicized reference the
internal types simply look public — discovery, TM007 accessibility checks, and emission all
behave as for any public type.
@@ -60,6 +69,22 @@ for TUnit.Mocks:
alone; explicit interface implementations stay private as IL requires.
- Dev loop: MSBuild nodes hold the task assembly's file lock across builds — run
`dotnet build-server shutdown` after changing this project.
+- Diagnostics: `TUMIA001` unresolved assembly name (error), `TUMIA002` .NET Framework inert
+ (warning), `TUMIA003` ref-assembly-only source (warning), `TUMIA004` ambiguous simple-name
+ match — first wins (warning), `TUMIA005` publicize failure, e.g. unreadable/locked assembly
+ (error).
+
+## Why a custom task instead of depending on Krafs.Publicizer / IgnoresAccessChecksToGenerator
+
+Those packages are consumer-facing: each wires its own MSBuild entry points, props/targets, and
+item vocabulary into the referencing project. TUnit.Mocks needs the publicize step to ride
+inside its own package transparently (single opt-in property, transitive `buildTransitive`
+import, dual net472/net8.0 MSBuild hosts, `ReferencePathWithRefAssemblies`-only swap so
+`deps.json` stays clean) — none of which those packages expose as a library API; both would
+arrive as additional package references with their own configuration surface for users to
+manage. The rewrite itself (~60 lines of Cecil) is deliberately narrower than theirs: types and
+methods only, no fields, no `IgnoresAccessChecksTo`-free mode. If the scope ever grows toward
+theirs, vendoring or depending on one should be revisited.
## Validation
diff --git a/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets
index 8884d1c446..b699962fe0 100644
--- a/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets
+++ b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets
@@ -41,13 +41,18 @@
<_TUnitMocksIactSourceFile>$(IntermediateOutputPath)TUnitMocksIgnoresAccessChecksTo.g.cs
+
+ true
+ GeneratedSourceFile="$(_TUnitMocksIactSourceFile)"
+ EmitAttributeDefinition="$(TUnitMocksInternalsAccessEmitAttributeDefinition)">
@@ -61,6 +66,7 @@
+
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
index 5ef030a6d4..59f64d541d 100644
--- a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
@@ -110,6 +110,161 @@ public async Task Second_Run_Is_Incremental()
await Assert.That(File.GetLastWriteTimeUtc(second.GeneratedSourceFile)).IsEqualTo(firstSourceWrite);
}
+ [Test]
+ public async Task Content_Change_Invalidates_Publicized_Copy_Despite_Older_Timestamp()
+ {
+ var dir = NewScratchDirectory();
+
+ // Use a private copy of the source so its content/timestamp can be manipulated.
+ var sourceDir = NewScratchDirectory();
+ var source = Path.Combine(sourceDir, TargetLibName + ".dll");
+ File.Copy(TargetLibPath, source);
+
+ var first = CreateTask(dir, TargetLibName);
+ first.ReferencePaths = [new TaskItem(source)];
+ await Assert.That(first.Execute()).IsTrue();
+
+ var rewritten = Path.Combine(dir, TargetLibName + ".dll");
+ var firstWrite = File.GetLastWriteTimeUtc(rewritten);
+
+ // Different content, timestamp pushed BEFORE the publicized copy — a timestamp-only
+ // check would treat the output as up to date.
+ File.Copy(Path.Combine(AppContext.BaseDirectory, "TUnit.Mocks.dll"), source, overwrite: true);
+ File.SetLastWriteTimeUtc(source, firstWrite.AddMinutes(-5));
+
+ var second = CreateTask(dir, TargetLibName);
+ second.ReferencePaths = [new TaskItem(source)];
+ await Assert.That(second.Execute()).IsTrue();
+
+ await Assert.That(File.GetLastWriteTimeUtc(rewritten)).IsNotEqualTo(firstWrite);
+ }
+
+ [Test]
+ public async Task Replacement_Reference_Preserves_Compiler_Metadata()
+ {
+ var dir = NewScratchDirectory();
+ var reference = new TaskItem(TargetLibPath);
+ reference.SetMetadata("Aliases", "sdkalias");
+ reference.SetMetadata("EmbedInteropTypes", "false");
+
+ var task = CreateTask(dir, TargetLibName);
+ task.ReferencePaths = [reference];
+
+ await Assert.That(task.Execute()).IsTrue();
+
+ var publicized = task.PublicizedReferences[0];
+ await Assert.That(publicized.GetMetadata("Aliases")).IsEqualTo("sdkalias");
+ await Assert.That(publicized.GetMetadata("EmbedInteropTypes")).IsEqualTo("false");
+ // The overrides still win over copied metadata.
+ await Assert.That(publicized.GetMetadata("Private")).IsEqualTo("false");
+ await Assert.That(publicized.GetMetadata("CopyLocal")).IsEqualTo("false");
+ }
+
+ [Test]
+ public async Task Attribute_Definition_Can_Be_Suppressed()
+ {
+ var dir = NewScratchDirectory();
+ var task = CreateTask(dir, TargetLibName);
+ task.EmitAttributeDefinition = false;
+
+ await Assert.That(task.Execute()).IsTrue();
+
+ var source = await File.ReadAllTextAsync(task.GeneratedSourceFile);
+ await Assert.That(source).Contains($"IgnoresAccessChecksTo(\"{TargetLibName}\")");
+ await Assert.That(source).DoesNotContain("class IgnoresAccessChecksToAttribute");
+ }
+
+ [Test]
+ public async Task Ambiguous_Simple_Name_Warns_With_TUMIA004()
+ {
+ var dir = NewScratchDirectory();
+ var duplicateDir = NewScratchDirectory();
+ var duplicate = Path.Combine(duplicateDir, TargetLibName + ".dll");
+ File.Copy(TargetLibPath, duplicate);
+
+ var engine = new StubBuildEngine();
+ var task = CreateTask(dir, TargetLibName);
+ task.BuildEngine = engine;
+ task.ReferencePaths = [new TaskItem(TargetLibPath), new TaskItem(duplicate)];
+
+ await Assert.That(task.Execute()).IsTrue();
+ await Assert.That(engine.Warnings.Count).IsEqualTo(1);
+ await Assert.That(engine.Warnings[0].Code).IsEqualTo("TUMIA004");
+ // Deterministic: first match wins.
+ await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(TargetLibPath);
+ }
+
+ [Test]
+ public async Task Unreadable_Assembly_Fails_With_TUMIA005_Not_An_Unhandled_Exception()
+ {
+ var dir = NewScratchDirectory();
+ var garbage = Path.Combine(dir, "Garbage.Assembly.dll");
+ await File.WriteAllTextAsync(garbage, "this is not a PE file");
+
+ var engine = new StubBuildEngine();
+ var task = CreateTask(dir, "Garbage.Assembly");
+ task.BuildEngine = engine;
+ task.ReferencePaths = [new TaskItem(garbage)];
+
+ await Assert.That(task.Execute()).IsFalse();
+ await Assert.That(engine.Errors.Count).IsEqualTo(1);
+ await Assert.That(engine.Errors[0].Code).IsEqualTo("TUMIA005");
+ }
+
+ [Test]
+ public async Task Reference_Assembly_Without_Implementation_Warns_With_TUMIA003()
+ {
+ var dir = NewScratchDirectory();
+ var refAsmDir = NewScratchDirectory();
+ var refAsm = Path.Combine(refAsmDir, TargetLibName + ".dll");
+ CreateReferenceAssemblyCopy(TargetLibPath, refAsm);
+
+ var engine = new StubBuildEngine();
+ var task = CreateTask(dir, TargetLibName);
+ task.BuildEngine = engine;
+ task.ReferencePaths = [new TaskItem(refAsm)];
+
+ await Assert.That(task.Execute()).IsTrue();
+ await Assert.That(engine.Warnings.Count).IsEqualTo(1);
+ await Assert.That(engine.Warnings[0].Code).IsEqualTo("TUMIA003");
+ }
+
+ [Test]
+ public async Task Reference_Assembly_Falls_Back_To_Runtime_Implementation()
+ {
+ var dir = NewScratchDirectory();
+ var refAsmDir = NewScratchDirectory();
+ var refAsm = Path.Combine(refAsmDir, TargetLibName + ".dll");
+ CreateReferenceAssemblyCopy(TargetLibPath, refAsm);
+
+ var engine = new StubBuildEngine();
+ var task = CreateTask(dir, TargetLibName);
+ task.BuildEngine = engine;
+ task.ReferencePaths = [new TaskItem(refAsm)];
+ task.RuntimeAssemblies = [new TaskItem(TargetLibPath)];
+
+ await Assert.That(task.Execute()).IsTrue();
+ await Assert.That(engine.Warnings.Count).IsEqualTo(0);
+
+ // The swap must still Remove the compiler's item (the ref assembly), while the
+ // publicized bits come from the implementation.
+ await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(refAsm);
+ }
+
+ private static void CreateReferenceAssemblyCopy(string source, string destination)
+ {
+ using var module = Mono.Cecil.ModuleDefinition.ReadModule(source);
+ var attributeType = new Mono.Cecil.TypeReference(
+ "System.Runtime.CompilerServices", "ReferenceAssemblyAttribute",
+ module, module.TypeSystem.CoreLibrary);
+ var constructor = new Mono.Cecil.MethodReference(".ctor", module.TypeSystem.Void, attributeType)
+ {
+ HasThis = true,
+ };
+ module.Assembly.CustomAttributes.Add(new Mono.Cecil.CustomAttribute(constructor));
+ module.Write(destination);
+ }
+
[Test]
public async Task Unresolved_Assembly_Name_Fails_With_TUMIA001()
{
@@ -127,6 +282,8 @@ private sealed class StubBuildEngine : IBuildEngine
{
public List Errors { get; } = [];
+ public List Warnings { get; } = [];
+
public bool ContinueOnError => false;
public int LineNumberOfTaskNode => 0;
@@ -147,8 +304,6 @@ public void LogMessageEvent(BuildMessageEventArgs e)
{
}
- public void LogWarningEvent(BuildWarningEventArgs e)
- {
- }
+ public void LogWarningEvent(BuildWarningEventArgs e) => Warnings.Add(e);
}
}
From 55e5049ab036c2ea464e0d4cd8d8d31a2bdfa2aa Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 30 Jul 2026 09:43:21 +0100
Subject: [PATCH 04/10] fix(mocks): scope publicizer to assembly-visible
members and remove all ambiguous matches
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review round 3:
- TUMIA004 now supersedes ALL same-simple-name matches, not just the winner —
a leftover duplicate shared the publicized copy's assembly identity (CS1703).
New SupersededReferences task output feeds the targets Remove.
- Publicize promotes only internal/protected internal/private protected;
private and protected members are untouched, so a formerly-private overload
can no longer capture existing calls in consuming code.
- IgnoresAccessChecksTo is emitted with the assembly's real identity read from
the resolved file, not the file-derived requested name.
---
.../PublicizeAssemblyReferences.cs | 53 +++++++++++----
.../README.md | 10 ++-
.../TUnit.Mocks.InternalsAccess.targets | 6 +-
.../SdkRuntime.cs | 22 +++++++
.../PublicizeAssemblyReferencesTaskTests.cs | 66 +++++++++++++++++++
5 files changed, 141 insertions(+), 16 deletions(-)
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
index 8aabca39cb..0315c67200 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -59,16 +59,26 @@ public sealed class PublicizeAssemblyReferences : Microsoft.Build.Utilities.Task
///
/// Publicized references. ItemSpec = rewritten copy; %(Original) = the reference item it
- /// replaces, for the targets file to Remove.
+ /// replaces (the selected match).
///
[Output]
public ITaskItem[] PublicizedReferences { get; set; } = [];
+ ///
+ /// Every resolved reference superseded by a publicized copy — the selected match plus any
+ /// same-simple-name duplicates. The targets file Removes all of these so an ambiguous match
+ /// degrades to "first wins, cleanly" instead of the compiler seeing the same assembly
+ /// identity twice (CS1703).
+ ///
+ [Output]
+ public ITaskItem[] SupersededReferences { get; set; } = [];
+
public override bool Execute()
{
Directory.CreateDirectory(OutputDirectory);
var outputs = new List();
+ var superseded = new List();
var publicizedNames = new List();
foreach (var requested in AssembliesToPublicize)
@@ -100,10 +110,14 @@ public override bool Execute()
var reference = matches[0];
string source;
string destination;
+ string assemblyName;
try
{
source = ResolveImplementationAssembly(name, reference);
+ // The runtime matches IgnoresAccessChecksTo against the real assembly identity,
+ // which can differ from the requested (file-derived) simple name.
+ assemblyName = System.Reflection.AssemblyName.GetAssemblyName(source).Name!;
destination = Path.Combine(OutputDirectory, Path.GetFileName(source));
// Content-based incrementality: the signature records the resolved source path
@@ -145,7 +159,11 @@ public override bool Execute()
item.SetMetadata("Private", "false");
item.SetMetadata("CopyLocal", "false");
outputs.Add(item);
- publicizedNames.Add(name);
+ publicizedNames.Add(assemblyName);
+ // All same-simple-name matches leave the compiler's reference list, not just the
+ // winner — a leftover duplicate would share the publicized copy's assembly identity
+ // and fail the compile with CS1703.
+ superseded.AddRange(matches.Select(ITaskItem (m) => new TaskItem(m.ItemSpec)));
}
if (!Log.HasLoggedErrors)
@@ -154,6 +172,7 @@ public override bool Execute()
}
PublicizedReferences = outputs.ToArray();
+ SupersededReferences = superseded.ToArray();
return !Log.HasLoggedErrors;
}
@@ -231,20 +250,23 @@ private static void Publicize(string source, string destination)
continue;
}
- type.Attributes = type.IsNested
- ? (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NestedPublic
- : (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.Public;
+ // Only assembly-level visibility is promoted. private/protected members stay as
+ // they are: the feature promises INTERNALS access, and promoting private members
+ // would inject them into overload resolution for consuming code (a formerly-private
+ // M(string) beside a public M(object) silently rebinding M(null)).
+ if (IsAssemblyVisibleType(type))
+ {
+ type.Attributes = type.IsNested
+ ? (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NestedPublic
+ : (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.Public;
+ }
foreach (var method in type.Methods)
{
- // Explicit interface implementations stay private — IL requires it, and making
- // them public would surface dotted names the compiler cannot bind anyway.
- if (method.Overrides.Count > 0 && method.IsPrivate)
+ if (IsAssemblyVisibleMethod(method))
{
- continue;
+ method.Attributes = (method.Attributes & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Public;
}
-
- method.Attributes = (method.Attributes & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Public;
}
}
@@ -255,6 +277,15 @@ private static void Publicize(string source, string destination)
module.Write(destination);
}
+ /// internal, protected internal, or private protected — never plain private.
+ private static bool IsAssemblyVisibleType(TypeDefinition type)
+ => type.IsNested
+ ? type.IsNestedAssembly || type.IsNestedFamilyOrAssembly || type.IsNestedFamilyAndAssembly
+ : type.IsNotPublic;
+
+ private static bool IsAssemblyVisibleMethod(MethodDefinition method)
+ => method.IsAssembly || method.IsFamilyOrAssembly || method.IsFamilyAndAssembly;
+
private void WriteIgnoresAccessChecksToSource(List assemblyNames)
{
var writer = new StringWriter();
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
index a832fd0e3d..5b2f971000 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
@@ -65,14 +65,18 @@ for TUnit.Mocks:
(it underpins several long-lived OSS packages; breakage risk is low but nonzero).
- .NET Framework test targets are unsupported (warning `TUMIA002`, pipeline stays inert).
- Verified under `PublishTrimmed` (full trim mode); Native AOT not yet verified.
-- Publicizer scope: types and methods (constructors and accessors included); fields are left
+- Publicizer scope: assembly-visible types and methods only — `internal`, `protected internal`,
+ and `private protected` become `public` (constructors and accessors included). `private` and
+ `protected` members are untouched: the feature promises internals access, and promoting a
+ private overload would silently change overload resolution in consuming code. Fields are left
alone; explicit interface implementations stay private as IL requires.
- Dev loop: MSBuild nodes hold the task assembly's file lock across builds — run
`dotnet build-server shutdown` after changing this project.
- Diagnostics: `TUMIA001` unresolved assembly name (error), `TUMIA002` .NET Framework inert
(warning), `TUMIA003` ref-assembly-only source (warning), `TUMIA004` ambiguous simple-name
- match — first wins (warning), `TUMIA005` publicize failure, e.g. unreadable/locked assembly
- (error).
+ match — first wins and all matches are removed from the compiler's references so the duplicate
+ cannot collide with the publicized copy (warning), `TUMIA005` publicize failure, e.g.
+ unreadable/locked assembly (error).
## Why a custom task instead of depending on Krafs.Publicizer / IgnoresAccessChecksToGenerator
diff --git a/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets
index b699962fe0..0b16aa073a 100644
--- a/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets
+++ b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets
@@ -54,14 +54,16 @@
GeneratedSourceFile="$(_TUnitMocksIactSourceFile)"
EmitAttributeDefinition="$(TUnitMocksInternalsAccessEmitAttributeDefinition)">
+
-
+ over. Superseded = the selected reference plus any same-simple-name duplicates; a
+ leftover duplicate would carry the publicized copy's assembly identity (CS1703). -->
+
diff --git a/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs b/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs
index e9c3641313..d8c0655e60 100644
--- a/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs
@@ -41,6 +41,28 @@ internal InternalWidget()
public virtual int Weight() => 100;
}
+///
+/// Overload-resolution guard for the publicizer: only assembly-visible members may be promoted.
+/// If the private Describe(string) overload became public, a consumer's Describe(null) would
+/// silently rebind from the object overload to it.
+///
+public class PublicSurface
+{
+ public string Describe(object? value) => "object";
+
+ private string Describe(string? value) => "string";
+
+ internal string InternalHelper() => "internal";
+
+ protected virtual string ProtectedHook() => "protected";
+
+ public string UsePrivates() => Describe((string?)null) + Describe((object?)null) + ProtectedHook();
+
+ private sealed class PrivateNested;
+
+ internal sealed class InternalNested;
+}
+
///
/// Simulates SDK-internal call sites the test has no control over.
///
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
index 59f64d541d..52b6787068 100644
--- a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
@@ -192,6 +192,72 @@ public async Task Ambiguous_Simple_Name_Warns_With_TUMIA004()
await Assert.That(engine.Warnings[0].Code).IsEqualTo("TUMIA004");
// Deterministic: first match wins.
await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(TargetLibPath);
+ // ALL matches are superseded — a leftover duplicate would carry the same assembly
+ // identity as the publicized copy and break the compile with CS1703.
+ var supersededPaths = task.SupersededReferences.Select(s => s.ItemSpec).ToList();
+ await Assert.That(supersededPaths).Contains(TargetLibPath);
+ await Assert.That(supersededPaths).Contains(duplicate);
+ }
+
+ [Test]
+ public async Task Publicize_Promotes_Only_Assembly_Visible_Members()
+ {
+ var dir = NewScratchDirectory();
+ var task = CreateTask(dir, TargetLibName);
+
+ await Assert.That(task.Execute()).IsTrue();
+
+ var context = new AssemblyLoadContext("visibility-probe", isCollectible: true);
+ try
+ {
+ var assembly = context.LoadFromAssemblyPath(Path.Combine(dir, TargetLibName + ".dll"));
+ var surface = assembly.GetType("FakeSdk.PublicSurface", throwOnError: true)!;
+
+ // internal -> public.
+ var internalHelper = surface.GetMethod("InternalHelper", BindingFlags.Instance | BindingFlags.Public);
+ await Assert.That(internalHelper).IsNotNull();
+
+ // private overload stays private — promoting it would rebind Describe(null) in
+ // consuming code from the object overload to the string overload.
+ var describeString = surface.GetMethod(
+ "Describe", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
+ binder: null, [typeof(string)], modifiers: null)!;
+ await Assert.That(describeString.IsPrivate).IsTrue();
+
+ // protected stays protected — already accessible where it matters (derived mocks).
+ var protectedHook = surface.GetMethod("ProtectedHook", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ await Assert.That(protectedHook.IsFamily).IsTrue();
+
+ // Nested types: internal -> public, private stays private.
+ var internalNested = surface.GetNestedType("InternalNested", BindingFlags.Public);
+ await Assert.That(internalNested).IsNotNull();
+ var privateNested = surface.GetNestedType("PrivateNested", BindingFlags.NonPublic)!;
+ await Assert.That(privateNested.IsNestedPrivate).IsTrue();
+ }
+ finally
+ {
+ context.Unload();
+ }
+ }
+
+ [Test]
+ public async Task Generated_Source_Uses_Real_Assembly_Identity_When_File_Name_Differs()
+ {
+ var dir = NewScratchDirectory();
+ var renamedDir = NewScratchDirectory();
+ var renamed = Path.Combine(renamedDir, "Renamed.Lib.dll");
+ File.Copy(TargetLibPath, renamed);
+
+ var task = CreateTask(dir, "Renamed.Lib");
+ task.ReferencePaths = [new TaskItem(renamed)];
+
+ await Assert.That(task.Execute()).IsTrue();
+
+ // The runtime matches IgnoresAccessChecksTo against the assembly's real identity, not
+ // the file name the reference was requested by.
+ var source = await File.ReadAllTextAsync(task.GeneratedSourceFile);
+ await Assert.That(source).Contains($"IgnoresAccessChecksTo(\"{TargetLibName}\")");
+ await Assert.That(source).DoesNotContain("IgnoresAccessChecksTo(\"Renamed.Lib\")");
}
[Test]
From 05cdf1ec30fd014846baa15b2a15f32a16e12b87 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 30 Jul 2026 10:03:28 +0100
Subject: [PATCH 05/10] fix(mocks): dedupe internals-access requests, union
ambiguous-match metadata, version the cache signature
- Duplicate TUnitMocksInternalsAccess items (same simple name, any case) now
produce a single publicized reference instead of handing Csc the same
rewritten assembly twice.
- When multiple resolved references share the requested simple name, extern
aliases are unioned and EmbedInteropTypes kept if any match set it, so
compiler-significant metadata carried only by a non-selected match survives
the swap.
- The incrementality signature now includes the task version, so a TUnit.Mocks
upgrade re-publicizes instead of reusing a copy produced by older rewrite
rules.
---
.../PublicizeAssemblyReferences.cs | 91 ++++++++++++++++++-
.../README.md | 9 +-
.../PublicizeAssemblyReferencesTaskTests.cs | 69 ++++++++++++++
3 files changed, 163 insertions(+), 6 deletions(-)
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
index 0315c67200..e8085bb930 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -81,9 +81,14 @@ public override bool Execute()
var superseded = new List();
var publicizedNames = new List();
- foreach (var requested in AssembliesToPublicize)
+ // The same simple name requested twice (duplicate items, multi-import) must not produce
+ // two publicized items — the targets would hand Csc the same rewritten assembly twice.
+ var requestedNames = AssembliesToPublicize
+ .Select(i => i.ItemSpec)
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var name in requestedNames)
{
- var name = requested.ItemSpec;
var matches = ReferencePaths.Where(r =>
string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase)).ToList();
@@ -103,7 +108,8 @@ public override bool Execute()
subcategory: null, warningCode: "TUMIA004", helpKeyword: null,
file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0,
message: $"TUnitMocksInternalsAccess: multiple resolved references match '{name}': " +
- $"{string.Join(", ", matches.Select(m => m.ItemSpec))}. Using the first; " +
+ $"{string.Join(", ", matches.Select(m => m.ItemSpec))}. Using the first " +
+ "(extern aliases and EmbedInteropTypes from all matches are preserved); " +
"if that is the wrong one, resolve the version conflict in the project.");
}
@@ -123,8 +129,11 @@ public override bool Execute()
// Content-based incrementality: the signature records the resolved source path
// and a hash of its bytes, so a replaced/downgraded assembly with an equal or
// older timestamp still invalidates the publicized copy.
+ // The task version is part of the signature: a TUnit.Mocks upgrade that changes
+ // the rewrite rules must invalidate copies produced by the previous task, or the
+ // compiler keeps seeing the stale publicized API until obj is cleaned.
var signaturePath = destination + ".sig";
- var signature = source + "\n" + HashFile(source);
+ var signature = TaskVersion + "\n" + source + "\n" + HashFile(source);
if (!File.Exists(destination) || !File.Exists(signaturePath) || File.ReadAllText(signaturePath) != signature)
{
@@ -152,6 +161,10 @@ public override bool Execute()
// Csc reads compiler-significant options from item metadata, and an extern-aliased
// reference must stay extern-aliased after the swap.
reference.CopyMetadataTo(item);
+ if (matches.Count > 1)
+ {
+ MergeCompilerMetadataFromDuplicates(item, matches);
+ }
// "Original" is what the targets file Removes — the reference item as the compiler
// knew it (the ref assembly when one existed), not the implementation path.
item.SetMetadata("Original", reference.ItemSpec);
@@ -176,6 +189,76 @@ public override bool Execute()
return !Log.HasLoggedErrors;
}
+ ///
+ /// Version stamp for the incrementality signature — a new TUnit.Mocks release re-publicizes
+ /// even when the source assembly is unchanged.
+ ///
+ private static readonly string TaskVersion = GetTaskVersion();
+
+ private static string GetTaskVersion()
+ {
+ var assembly = typeof(PublicizeAssemblyReferences).Assembly;
+ var informational = (System.Reflection.AssemblyInformationalVersionAttribute?)Attribute.GetCustomAttribute(
+ assembly, typeof(System.Reflection.AssemblyInformationalVersionAttribute));
+ return informational?.InformationalVersion ?? assembly.GetName().Version?.ToString() ?? "0";
+ }
+
+ ///
+ /// Every same-simple-name match is removed from the compiler's reference list, so
+ /// compiler-significant metadata carried only by a non-selected match — an extern alias, an
+ /// EmbedInteropTypes flag — must survive on the single replacement item: aliases are
+ /// unioned, and interop embedding is kept if any match asked for it.
+ ///
+ private static void MergeCompilerMetadataFromDuplicates(TaskItem item, List matches)
+ {
+ var aliases = new List();
+ var anyGlobal = false;
+
+ foreach (var match in matches)
+ {
+ var value = match.GetMetadata("Aliases");
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ // No aliases = visible through the global namespace.
+ anyGlobal = true;
+ continue;
+ }
+
+ foreach (var raw in value.Split(','))
+ {
+ var alias = raw.Trim();
+ if (alias.Length == 0)
+ {
+ continue;
+ }
+
+ if (alias == "global")
+ {
+ anyGlobal = true;
+ }
+ else if (!aliases.Contains(alias, StringComparer.Ordinal))
+ {
+ aliases.Add(alias);
+ }
+ }
+ }
+
+ if (aliases.Count > 0)
+ {
+ if (anyGlobal)
+ {
+ aliases.Insert(0, "global");
+ }
+
+ item.SetMetadata("Aliases", string.Join(",", aliases));
+ }
+
+ if (matches.Any(m => string.Equals(m.GetMetadata("EmbedInteropTypes"), "true", StringComparison.OrdinalIgnoreCase)))
+ {
+ item.SetMetadata("EmbedInteropTypes", "true");
+ }
+ }
+
///
/// Picks the implementation assembly to publicize. Roslyn reference assemblies strip
/// internal members when the assembly grants no InternalsVisibleTo (internal types survive
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
index 5b2f971000..68a35f31b3 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
@@ -75,8 +75,13 @@ for TUnit.Mocks:
- Diagnostics: `TUMIA001` unresolved assembly name (error), `TUMIA002` .NET Framework inert
(warning), `TUMIA003` ref-assembly-only source (warning), `TUMIA004` ambiguous simple-name
match — first wins and all matches are removed from the compiler's references so the duplicate
- cannot collide with the publicized copy (warning), `TUMIA005` publicize failure, e.g.
- unreadable/locked assembly (error).
+ cannot collide with the publicized copy; extern aliases are unioned and `EmbedInteropTypes`
+ kept if any match set it, so compiler-significant metadata carried only by a non-selected
+ match survives the swap (warning), `TUMIA005` publicize failure, e.g. unreadable/locked
+ assembly (error).
+- Incrementality is content-based (source path + SHA-256) and versioned: upgrading TUnit.Mocks
+ re-publicizes even when the referenced assembly is unchanged, so rewrite-rule fixes are never
+ masked by a stale `obj` copy.
## Why a custom task instead of depending on Krafs.Publicizer / IgnoresAccessChecksToGenerator
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
index 52b6787068..24ebf2e0b7 100644
--- a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
@@ -199,6 +199,75 @@ public async Task Ambiguous_Simple_Name_Warns_With_TUMIA004()
await Assert.That(supersededPaths).Contains(duplicate);
}
+ [Test]
+ public async Task Duplicate_Requests_Produce_Single_Publicized_Reference()
+ {
+ var dir = NewScratchDirectory();
+ // Same simple name requested twice (case difference included) — e.g. duplicated items
+ // from a multi-imported props file. The compiler must see exactly one publicized copy.
+ var task = CreateTask(dir, TargetLibName, TargetLibName.ToUpperInvariant());
+
+ await Assert.That(task.Execute()).IsTrue();
+
+ await Assert.That(task.PublicizedReferences.Length).IsEqualTo(1);
+ var source = await File.ReadAllTextAsync(task.GeneratedSourceFile);
+ var applications = source.Split([$"IgnoresAccessChecksTo(\"{TargetLibName}\")"], StringSplitOptions.None).Length - 1;
+ await Assert.That(applications).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Ambiguous_Matches_Union_Compiler_Metadata()
+ {
+ var dir = NewScratchDirectory();
+ var duplicateDir = NewScratchDirectory();
+ var duplicatePath = Path.Combine(duplicateDir, TargetLibName + ".dll");
+ File.Copy(TargetLibPath, duplicatePath);
+
+ // The alias and interop flag live ONLY on the non-selected match. Both matches leave
+ // the compiler's reference list, so the replacement item must carry them or an existing
+ // `extern alias sdkalias` in the consuming project stops resolving.
+ var winner = new TaskItem(TargetLibPath);
+ var duplicate = new TaskItem(duplicatePath);
+ duplicate.SetMetadata("Aliases", "sdkalias");
+ duplicate.SetMetadata("EmbedInteropTypes", "true");
+
+ var task = CreateTask(dir, TargetLibName);
+ task.ReferencePaths = [winner, duplicate];
+
+ await Assert.That(task.Execute()).IsTrue();
+
+ var publicized = task.PublicizedReferences[0];
+ // The winner had no aliases (global visibility) — that must survive the union too.
+ await Assert.That(publicized.GetMetadata("Aliases")).IsEqualTo("global,sdkalias");
+ await Assert.That(publicized.GetMetadata("EmbedInteropTypes")).IsEqualTo("true");
+ }
+
+ [Test]
+ public async Task Task_Version_Change_Invalidates_Publicized_Copy()
+ {
+ var dir = NewScratchDirectory();
+
+ var first = CreateTask(dir, TargetLibName);
+ await Assert.That(first.Execute()).IsTrue();
+
+ var rewritten = Path.Combine(dir, TargetLibName + ".dll");
+ var signaturePath = rewritten + ".sig";
+
+ // Emulate a copy produced by an older TUnit.Mocks: same source path and hash, different
+ // task version on the first signature line.
+ var lines = (await File.ReadAllTextAsync(signaturePath)).Split('\n');
+ lines[0] = "0.0.0-previous-task-version";
+ await File.WriteAllTextAsync(signaturePath, string.Join("\n", lines));
+
+ var staleWrite = File.GetLastWriteTimeUtc(rewritten).AddMinutes(-5);
+ File.SetLastWriteTimeUtc(rewritten, staleWrite);
+
+ var second = CreateTask(dir, TargetLibName);
+ await Assert.That(second.Execute()).IsTrue();
+
+ await Assert.That(File.GetLastWriteTimeUtc(rewritten)).IsNotEqualTo(staleWrite);
+ }
+
[Test]
public async Task Publicize_Promotes_Only_Assembly_Visible_Members()
{
From 0a55cafc460d153b7778a4a727ab4ef77d2d3bfd Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 30 Jul 2026 10:25:29 +0100
Subject: [PATCH 06/10] fix(mocks): resolve runtime implementation assets by
assembly identity
A compile asset's file name can differ from the assembly identity it
contains; the implementation then appears among the runtime assets under
the real name, so the file-name-only match missed it and publicized the
stripped ref assembly (TUMIA003). The fallback now reads the reference's
real identity and matches runtime assets by identity name, preferring a
version-exact match, with the file-name match kept as the fast path.
---
.../PublicizeAssemblyReferences.cs | 74 +++++++++++++++++--
.../PublicizeAssemblyReferencesTaskTests.cs | 30 ++++++++
2 files changed, 98 insertions(+), 6 deletions(-)
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
index e8085bb930..79ed10f9ca 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -281,17 +281,14 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference)
return reference.ItemSpec;
}
- var runtimeMatch = RuntimeAssemblies.FirstOrDefault(r =>
- string.Equals(Path.GetExtension(r.ItemSpec), ".dll", StringComparison.OrdinalIgnoreCase) &&
- string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase) &&
- File.Exists(r.ItemSpec));
+ var runtimeMatch = FindRuntimeImplementation(name, reference.ItemSpec);
if (runtimeMatch is not null)
{
Log.LogMessage(MessageImportance.Normal,
$"TUnitMocksInternalsAccess: '{reference.ItemSpec}' is a reference assembly; " +
- $"publicizing the implementation '{runtimeMatch.ItemSpec}' instead.");
- return runtimeMatch.ItemSpec;
+ $"publicizing the implementation '{runtimeMatch}' instead.");
+ return runtimeMatch;
}
Log.LogWarning(
@@ -304,6 +301,71 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference)
return reference.ItemSpec;
}
+ ///
+ /// Finds the implementation assembly among the runtime/copy-local assets: first by file name
+ /// (the common case), then by the reference's real assembly identity — a compile asset can
+ /// be renamed relative to the assembly it contains, in which case the implementation shows
+ /// up among the runtime assets under the real name, not the requested (file-derived) one.
+ /// A version-exact identity match wins over a name-only one.
+ ///
+ private string? FindRuntimeImplementation(string name, string referencePath)
+ {
+ var candidates = RuntimeAssemblies
+ .Select(r => r.ItemSpec)
+ .Where(p =>
+ string.Equals(Path.GetExtension(p), ".dll", StringComparison.OrdinalIgnoreCase) &&
+ File.Exists(p) &&
+ // Never hand back the reference assembly itself if it also appears as an asset.
+ !string.Equals(Path.GetFullPath(p), Path.GetFullPath(referencePath), StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ var byFileName = candidates.FirstOrDefault(p =>
+ string.Equals(Path.GetFileNameWithoutExtension(p), name, StringComparison.OrdinalIgnoreCase));
+ if (byFileName is not null)
+ {
+ return byFileName;
+ }
+
+ System.Reflection.AssemblyName identity;
+ try
+ {
+ identity = System.Reflection.AssemblyName.GetAssemblyName(referencePath);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+
+ string? nameOnlyMatch = null;
+ foreach (var candidate in candidates)
+ {
+ System.Reflection.AssemblyName candidateIdentity;
+ try
+ {
+ candidateIdentity = System.Reflection.AssemblyName.GetAssemblyName(candidate);
+ }
+ catch (Exception)
+ {
+ // Native or otherwise unreadable dll among the copy-local assets.
+ continue;
+ }
+
+ if (!string.Equals(candidateIdentity.Name, identity.Name, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (Equals(candidateIdentity.Version, identity.Version))
+ {
+ return candidate;
+ }
+
+ nameOnlyMatch ??= candidate;
+ }
+
+ return nameOnlyMatch;
+ }
+
private static bool IsReferenceAssembly(string path)
{
using var module = ModuleDefinition.ReadModule(path);
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
index 24ebf2e0b7..15ab521176 100644
--- a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
@@ -386,6 +386,36 @@ public async Task Reference_Assembly_Falls_Back_To_Runtime_Implementation()
await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(refAsm);
}
+ [Test]
+ public async Task Renamed_Reference_Assembly_Resolves_Implementation_By_Assembly_Identity()
+ {
+ // The compile asset's file name can differ from the assembly identity it contains; the
+ // implementation then sits among the runtime assets under the REAL name, so a
+ // file-name-only match would miss it and publicize the stripped ref assembly (TUMIA003).
+ var dir = NewScratchDirectory();
+ var refAsmDir = NewScratchDirectory();
+ var refAsm = Path.Combine(refAsmDir, "Renamed.Compile.Asset.dll");
+ CreateReferenceAssemblyCopy(TargetLibPath, refAsm);
+
+ var engine = new StubBuildEngine();
+ var task = CreateTask(dir, "Renamed.Compile.Asset");
+ task.BuildEngine = engine;
+ task.ReferencePaths = [new TaskItem(refAsm)];
+ task.RuntimeAssemblies = [new TaskItem(TargetLibPath)];
+
+ await Assert.That(task.Execute()).IsTrue();
+ await Assert.That(engine.Warnings.Count).IsEqualTo(0);
+ await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(refAsm);
+
+ // The publicized bits must come from the implementation — the ref-assembly copy would
+ // still carry ReferenceAssemblyAttribute.
+ var publicized = task.PublicizedReferences[0].ItemSpec;
+ using var module = Mono.Cecil.ModuleDefinition.ReadModule(publicized);
+ var isRefAssembly = module.Assembly.CustomAttributes.Any(a =>
+ a.AttributeType.FullName == "System.Runtime.CompilerServices.ReferenceAssemblyAttribute");
+ await Assert.That(isRefAssembly).IsFalse();
+ }
+
private static void CreateReferenceAssemblyCopy(string source, string destination)
{
using var module = Mono.Cecil.ModuleDefinition.ReadModule(source);
From 7837ea74eb2f7326faf737ecce1f8dd1b44940fb Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 30 Jul 2026 10:47:29 +0100
Subject: [PATCH 07/10] fix(mocks): identity-aware ambiguous-match handling and
identity-based request lookup
Same-simple-name matches are now partitioned by real assembly identity:
only the winner's exact identity (name/version/public key) is superseded
and metadata-merged; different-identity references (legal only under
extern aliases) stay in the compiler's reference list untouched.
Requested names that miss by file name now fall back to matching each
reference's real assembly identity, so a renamed compile asset resolves
by the documented simple assembly name instead of failing TUMIA001.
---
.../PublicizeAssemblyReferences.cs | 117 ++++++++++++++----
.../README.md | 14 ++-
.../PublicizeAssemblyReferencesTaskTests.cs | 63 ++++++++++
3 files changed, 166 insertions(+), 28 deletions(-)
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
index 79ed10f9ca..417b6bb1af 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -89,8 +89,7 @@ public override bool Execute()
foreach (var name in requestedNames)
{
- var matches = ReferencePaths.Where(r =>
- string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase)).ToList();
+ var matches = FindReferenceMatches(name);
if (matches.Count == 0)
{
@@ -102,15 +101,27 @@ public override bool Execute()
continue;
}
+ // Only matches sharing the winner's assembly identity are safe to collapse onto the
+ // publicized copy. A same-file-name reference with a DIFFERENT identity (version or
+ // public key — a legal pair only when extern aliases keep them apart) must stay in
+ // the compiler's reference list untouched, or its alias would rebind to the wrong
+ // assembly and its unique API would vanish from the compilation.
+ var (supersededMatches, retainedMatches) = PartitionByIdentity(matches);
+
if (matches.Select(m => m.ItemSpec).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
{
+ var retainedNote = retainedMatches.Count > 0
+ ? " References whose assembly identity differs from the first match are left " +
+ $"in place: {string.Join(", ", retainedMatches.Select(m => m.ItemSpec))}."
+ : "";
Log.LogWarning(
subcategory: null, warningCode: "TUMIA004", helpKeyword: null,
file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0,
message: $"TUnitMocksInternalsAccess: multiple resolved references match '{name}': " +
$"{string.Join(", ", matches.Select(m => m.ItemSpec))}. Using the first " +
- "(extern aliases and EmbedInteropTypes from all matches are preserved); " +
- "if that is the wrong one, resolve the version conflict in the project.");
+ "(extern aliases and EmbedInteropTypes from same-identity duplicates are " +
+ $"preserved).{retainedNote} If the first is the wrong one, resolve the " +
+ "version conflict in the project.");
}
var reference = matches[0];
@@ -161,9 +172,9 @@ public override bool Execute()
// Csc reads compiler-significant options from item metadata, and an extern-aliased
// reference must stay extern-aliased after the swap.
reference.CopyMetadataTo(item);
- if (matches.Count > 1)
+ if (supersededMatches.Count > 1)
{
- MergeCompilerMetadataFromDuplicates(item, matches);
+ MergeCompilerMetadataFromDuplicates(item, supersededMatches);
}
// "Original" is what the targets file Removes — the reference item as the compiler
// knew it (the ref assembly when one existed), not the implementation path.
@@ -173,10 +184,10 @@ public override bool Execute()
item.SetMetadata("CopyLocal", "false");
outputs.Add(item);
publicizedNames.Add(assemblyName);
- // All same-simple-name matches leave the compiler's reference list, not just the
+ // Every SAME-IDENTITY match leaves the compiler's reference list, not just the
// winner — a leftover duplicate would share the publicized copy's assembly identity
- // and fail the compile with CS1703.
- superseded.AddRange(matches.Select(ITaskItem (m) => new TaskItem(m.ItemSpec)));
+ // and fail the compile with CS1703. Different-identity matches stay.
+ superseded.AddRange(supersededMatches.Select(ITaskItem (m) => new TaskItem(m.ItemSpec)));
}
if (!Log.HasLoggedErrors)
@@ -204,7 +215,77 @@ private static string GetTaskVersion()
}
///
- /// Every same-simple-name match is removed from the compiler's reference list, so
+ /// Resolves the requested simple name against the compiler's references: by file name first
+ /// (the common case), then by each reference's REAL assembly identity — a compile asset can
+ /// be renamed relative to the assembly it contains (e.g. VendorAlias.dll holding identity
+ /// Vendor.Core), and the documented contract is the simple ASSEMBLY name, not the file name.
+ ///
+ private List FindReferenceMatches(string name)
+ {
+ var byFileName = ReferencePaths.Where(r =>
+ string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (byFileName.Count > 0)
+ {
+ return byFileName;
+ }
+
+ return ReferencePaths.Where(r =>
+ string.Equals(TryGetAssemblyIdentity(r.ItemSpec)?.Name, name, StringComparison.OrdinalIgnoreCase)).ToList();
+ }
+
+ ///
+ /// Splits the matches into those safe to collapse onto the publicized copy (the winner plus
+ /// duplicates with the winner's exact assembly identity) and those that must remain in the
+ /// compiler's reference list (a different version or public key — or an unreadable file,
+ /// which is never assumed to be a duplicate).
+ ///
+ private (List Superseded, List Retained) PartitionByIdentity(List matches)
+ {
+ var supersededMatches = new List { matches[0] };
+ var retained = new List();
+
+ if (matches.Count > 1)
+ {
+ var winnerIdentity = TryGetAssemblyIdentity(matches[0].ItemSpec);
+
+ foreach (var match in matches.Skip(1))
+ {
+ var identity = winnerIdentity is null ? null : TryGetAssemblyIdentity(match.ItemSpec);
+ if (identity is not null && HasSameIdentity(winnerIdentity!, identity))
+ {
+ supersededMatches.Add(match);
+ }
+ else
+ {
+ retained.Add(match);
+ }
+ }
+ }
+
+ return (supersededMatches, retained);
+ }
+
+ private static bool HasSameIdentity(System.Reflection.AssemblyName left, System.Reflection.AssemblyName right)
+ => string.Equals(left.Name, right.Name, StringComparison.OrdinalIgnoreCase)
+ && Equals(left.Version, right.Version)
+ && (left.GetPublicKeyToken() ?? []).SequenceEqual(right.GetPublicKeyToken() ?? []);
+
+ private static System.Reflection.AssemblyName? TryGetAssemblyIdentity(string path)
+ {
+ try
+ {
+ return System.Reflection.AssemblyName.GetAssemblyName(path);
+ }
+ catch (Exception)
+ {
+ // Native or otherwise unreadable dll.
+ return null;
+ }
+ }
+
+ ///
+ /// Every superseded (same-identity) match is removed from the compiler's reference list, so
/// compiler-significant metadata carried only by a non-selected match — an extern alias, an
/// EmbedInteropTypes flag — must survive on the single replacement item: aliases are
/// unioned, and interop embedding is kept if any match asked for it.
@@ -326,12 +407,8 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference)
return byFileName;
}
- System.Reflection.AssemblyName identity;
- try
- {
- identity = System.Reflection.AssemblyName.GetAssemblyName(referencePath);
- }
- catch (Exception)
+ var identity = TryGetAssemblyIdentity(referencePath);
+ if (identity is null)
{
return null;
}
@@ -339,12 +416,8 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference)
string? nameOnlyMatch = null;
foreach (var candidate in candidates)
{
- System.Reflection.AssemblyName candidateIdentity;
- try
- {
- candidateIdentity = System.Reflection.AssemblyName.GetAssemblyName(candidate);
- }
- catch (Exception)
+ var candidateIdentity = TryGetAssemblyIdentity(candidate);
+ if (candidateIdentity is null)
{
// Native or otherwise unreadable dll among the copy-local assets.
continue;
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
index 68a35f31b3..3eb71f8546 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
@@ -72,12 +72,14 @@ for TUnit.Mocks:
alone; explicit interface implementations stay private as IL requires.
- Dev loop: MSBuild nodes hold the task assembly's file lock across builds — run
`dotnet build-server shutdown` after changing this project.
-- Diagnostics: `TUMIA001` unresolved assembly name (error), `TUMIA002` .NET Framework inert
- (warning), `TUMIA003` ref-assembly-only source (warning), `TUMIA004` ambiguous simple-name
- match — first wins and all matches are removed from the compiler's references so the duplicate
- cannot collide with the publicized copy; extern aliases are unioned and `EmbedInteropTypes`
- kept if any match set it, so compiler-significant metadata carried only by a non-selected
- match survives the swap (warning), `TUMIA005` publicize failure, e.g. unreadable/locked
+- Diagnostics: `TUMIA001` unresolved assembly name (error; the requested name is matched by
+ file name first, then by the reference's real assembly identity, so renamed compile assets
+ resolve), `TUMIA002` .NET Framework inert (warning), `TUMIA003` ref-assembly-only source
+ (warning), `TUMIA004` ambiguous simple-name match — first wins and same-identity duplicates
+ are removed from the compiler's references so they cannot collide with the publicized copy;
+ their extern aliases are unioned and `EmbedInteropTypes` kept if any set it. Matches with a
+ DIFFERENT assembly identity (version/public key — a legal pair only under extern aliases) are
+ left in place untouched (warning), `TUMIA005` publicize failure, e.g. unreadable/locked
assembly (error).
- Incrementality is content-based (source path + SHA-256) and versioned: upgrading TUnit.Mocks
re-publicizes even when the referenced assembly is unchanged, so rewrite-rule fixes are never
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
index 15ab521176..bc7a05970d 100644
--- a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
@@ -242,6 +242,69 @@ public async Task Ambiguous_Matches_Union_Compiler_Metadata()
await Assert.That(publicized.GetMetadata("EmbedInteropTypes")).IsEqualTo("true");
}
+ [Test]
+ public async Task Different_Identity_Same_Name_Reference_Is_Left_In_Place()
+ {
+ var dir = NewScratchDirectory();
+ var otherVersionDir = NewScratchDirectory();
+ var otherVersionPath = Path.Combine(otherVersionDir, TargetLibName + ".dll");
+ CreateDifferentVersionCopy(TargetLibPath, otherVersionPath);
+
+ // A same-file-name reference with a DIFFERENT identity is a legal pair only when extern
+ // aliases keep the two apart — superseding it would rebind its alias to the publicized
+ // copy's identity and drop its unique API from the compilation.
+ var winner = new TaskItem(TargetLibPath);
+ var otherVersion = new TaskItem(otherVersionPath);
+ otherVersion.SetMetadata("Aliases", "oldsdk");
+
+ var engine = new StubBuildEngine();
+ var task = CreateTask(dir, TargetLibName);
+ task.BuildEngine = engine;
+ task.ReferencePaths = [winner, otherVersion];
+
+ await Assert.That(task.Execute()).IsTrue();
+ await Assert.That(engine.Warnings.Count).IsEqualTo(1);
+ await Assert.That(engine.Warnings[0].Code).IsEqualTo("TUMIA004");
+
+ // Only the winner is superseded; the different-identity reference stays untouched.
+ var supersededPaths = task.SupersededReferences.Select(s => s.ItemSpec).ToList();
+ await Assert.That(supersededPaths).Contains(TargetLibPath);
+ await Assert.That(supersededPaths).DoesNotContain(otherVersionPath);
+ // Its alias must NOT be merged onto the publicized item — it belongs to a different
+ // assembly identity that remains in the compiler's reference list.
+ await Assert.That(task.PublicizedReferences[0].GetMetadata("Aliases")).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Requested_Name_Matches_Assembly_Identity_When_Compile_Asset_Is_Renamed()
+ {
+ var dir = NewScratchDirectory();
+ var renamedDir = NewScratchDirectory();
+ var renamed = Path.Combine(renamedDir, "VendorAlias.dll");
+ File.Copy(TargetLibPath, renamed);
+
+ // The documented contract is the simple ASSEMBLY name — a compile asset renamed
+ // relative to the identity it contains must still resolve, not fail with TUMIA001.
+ var engine = new StubBuildEngine();
+ var task = CreateTask(dir, TargetLibName);
+ task.BuildEngine = engine;
+ task.ReferencePaths = [new TaskItem(renamed)];
+
+ await Assert.That(task.Execute()).IsTrue();
+ await Assert.That(engine.Errors.Count).IsEqualTo(0);
+ await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(renamed);
+
+ var source = await File.ReadAllTextAsync(task.GeneratedSourceFile);
+ await Assert.That(source).Contains($"IgnoresAccessChecksTo(\"{TargetLibName}\")");
+ }
+
+ private static void CreateDifferentVersionCopy(string source, string destination)
+ {
+ using var module = Mono.Cecil.ModuleDefinition.ReadModule(source);
+ module.Assembly.Name.Version = new Version(99, 0, 0, 0);
+ module.Write(destination);
+ }
+
[Test]
public async Task Task_Version_Change_Invalidates_Publicized_Copy()
{
From 2dc89b6bcb7cdd05089b85a7e09fe583cf1759d9 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 30 Jul 2026 11:15:46 +0100
Subject: [PATCH 08/10] fix(mocks): publicize every distinct assembly identity
behind a simple name, collision-proof output paths
A simple-name request has no way to single out one of two extern-aliased
references with different identities, so each identity group now gets its
own publicized copy (per-identity aliases preserved, never merged across
identities); previously the non-first identity was retained and could
never be publicized.
Publicized copies now land in a per-source-path subdirectory, so two
implementation assemblies sharing a file name (renamed assets, or two
identities behind one name) cannot overwrite each other's copy.
---
.../PublicizeAssemblyReferences.cs | 232 +++++++++++-------
.../README.md | 9 +-
.../PublicizeAssemblyReferencesTaskTests.cs | 71 +++++-
3 files changed, 200 insertions(+), 112 deletions(-)
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
index 417b6bb1af..e638ce72fe 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -101,93 +101,32 @@ public override bool Execute()
continue;
}
- // Only matches sharing the winner's assembly identity are safe to collapse onto the
- // publicized copy. A same-file-name reference with a DIFFERENT identity (version or
- // public key — a legal pair only when extern aliases keep them apart) must stay in
- // the compiler's reference list untouched, or its alias would rebind to the wrong
- // assembly and its unique API would vanish from the compilation.
- var (supersededMatches, retainedMatches) = PartitionByIdentity(matches);
+ // EVERY distinct assembly identity behind the requested name is publicized — a
+ // same-file-name pair with different identities (version or public key, legal only
+ // under extern aliases) gets one publicized copy each, since the simple-name request
+ // has no way to single one out. Same-identity duplicates collapse onto one copy.
+ var (identityGroups, unreadableMatches) = GroupMatchesByIdentity(matches);
if (matches.Select(m => m.ItemSpec).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
{
- var retainedNote = retainedMatches.Count > 0
- ? " References whose assembly identity differs from the first match are left " +
- $"in place: {string.Join(", ", retainedMatches.Select(m => m.ItemSpec))}."
+ var unreadableNote = unreadableMatches.Count > 0
+ ? " References whose assembly identity cannot be read are left in place: " +
+ $"{string.Join(", ", unreadableMatches.Select(m => m.ItemSpec))}."
: "";
Log.LogWarning(
subcategory: null, warningCode: "TUMIA004", helpKeyword: null,
file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0,
message: $"TUnitMocksInternalsAccess: multiple resolved references match '{name}': " +
- $"{string.Join(", ", matches.Select(m => m.ItemSpec))}. Using the first " +
- "(extern aliases and EmbedInteropTypes from same-identity duplicates are " +
- $"preserved).{retainedNote} If the first is the wrong one, resolve the " +
- "version conflict in the project.");
+ $"{string.Join(", ", matches.Select(m => m.ItemSpec))}. Each distinct " +
+ "assembly identity is publicized separately; same-identity duplicates " +
+ "collapse onto one copy (extern aliases and EmbedInteropTypes " +
+ $"preserved).{unreadableNote}");
}
- var reference = matches[0];
- string source;
- string destination;
- string assemblyName;
-
- try
- {
- source = ResolveImplementationAssembly(name, reference);
- // The runtime matches IgnoresAccessChecksTo against the real assembly identity,
- // which can differ from the requested (file-derived) simple name.
- assemblyName = System.Reflection.AssemblyName.GetAssemblyName(source).Name!;
- destination = Path.Combine(OutputDirectory, Path.GetFileName(source));
-
- // Content-based incrementality: the signature records the resolved source path
- // and a hash of its bytes, so a replaced/downgraded assembly with an equal or
- // older timestamp still invalidates the publicized copy.
- // The task version is part of the signature: a TUnit.Mocks upgrade that changes
- // the rewrite rules must invalidate copies produced by the previous task, or the
- // compiler keeps seeing the stale publicized API until obj is cleaned.
- var signaturePath = destination + ".sig";
- var signature = TaskVersion + "\n" + source + "\n" + HashFile(source);
-
- if (!File.Exists(destination) || !File.Exists(signaturePath) || File.ReadAllText(signaturePath) != signature)
- {
- Publicize(source, destination);
- File.WriteAllText(signaturePath, signature);
- Log.LogMessage(MessageImportance.Normal, $"TUnitMocksInternalsAccess: publicized '{source}' -> '{destination}'.");
- }
- else
- {
- Log.LogMessage(MessageImportance.Low, $"TUnitMocksInternalsAccess: '{destination}' is up to date.");
- }
- }
- catch (Exception ex)
+ foreach (var identityGroup in identityGroups)
{
- Log.LogError(
- subcategory: null, errorCode: "TUMIA005", helpKeyword: null,
- file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0,
- message: $"TUnitMocksInternalsAccess: failed to publicize '{reference.ItemSpec}': {ex.Message}");
- Log.LogMessage(MessageImportance.Low, ex.ToString());
- continue;
+ PublicizeGroup(name, identityGroup, outputs, superseded, publicizedNames);
}
-
- var item = new TaskItem(destination);
- // Preserve the original reference's metadata (Aliases, EmbedInteropTypes, ...) —
- // Csc reads compiler-significant options from item metadata, and an extern-aliased
- // reference must stay extern-aliased after the swap.
- reference.CopyMetadataTo(item);
- if (supersededMatches.Count > 1)
- {
- MergeCompilerMetadataFromDuplicates(item, supersededMatches);
- }
- // "Original" is what the targets file Removes — the reference item as the compiler
- // knew it (the ref assembly when one existed), not the implementation path.
- item.SetMetadata("Original", reference.ItemSpec);
- // Compile-time only: never copy the rewritten assembly to the output directory.
- item.SetMetadata("Private", "false");
- item.SetMetadata("CopyLocal", "false");
- outputs.Add(item);
- publicizedNames.Add(assemblyName);
- // Every SAME-IDENTITY match leaves the compiler's reference list, not just the
- // winner — a leftover duplicate would share the publicized copy's assembly identity
- // and fail the compile with CS1703. Different-identity matches stay.
- superseded.AddRange(supersededMatches.Select(ITaskItem (m) => new TaskItem(m.ItemSpec)));
}
if (!Log.HasLoggedErrors)
@@ -235,35 +174,123 @@ private List FindReferenceMatches(string name)
}
///
- /// Splits the matches into those safe to collapse onto the publicized copy (the winner plus
- /// duplicates with the winner's exact assembly identity) and those that must remain in the
- /// compiler's reference list (a different version or public key — or an unreadable file,
- /// which is never assumed to be a duplicate).
+ /// Publicizes one identity group: the group's first member is the source, every member is
+ /// superseded (a leftover same-identity duplicate would collide with the publicized copy —
+ /// CS1703), and compiler-significant metadata from all members survives on the replacement.
+ ///
+ private void PublicizeGroup(
+ string name, List identityGroup,
+ List outputs, List superseded, List publicizedNames)
+ {
+ var reference = identityGroup[0];
+ string source;
+ string destination;
+ string assemblyName;
+
+ try
+ {
+ source = ResolveImplementationAssembly(name, reference);
+ // The runtime matches IgnoresAccessChecksTo against the real assembly identity,
+ // which can differ from the requested (file-derived) simple name.
+ assemblyName = System.Reflection.AssemblyName.GetAssemblyName(source).Name!;
+ // Each source gets its own subdirectory keyed by its path: two implementation
+ // assemblies with the same file name in different directories (renamed assets, or
+ // two identities behind one simple name) must not overwrite each other's copy.
+ var destinationDirectory = Path.Combine(OutputDirectory, HashPathToken(source));
+ Directory.CreateDirectory(destinationDirectory);
+ destination = Path.Combine(destinationDirectory, Path.GetFileName(source));
+
+ // Content-based incrementality: the signature records the resolved source path
+ // and a hash of its bytes, so a replaced/downgraded assembly with an equal or
+ // older timestamp still invalidates the publicized copy.
+ // The task version is part of the signature: a TUnit.Mocks upgrade that changes
+ // the rewrite rules must invalidate copies produced by the previous task, or the
+ // compiler keeps seeing the stale publicized API until obj is cleaned.
+ var signaturePath = destination + ".sig";
+ var signature = TaskVersion + "\n" + source + "\n" + HashFile(source);
+
+ if (!File.Exists(destination) || !File.Exists(signaturePath) || File.ReadAllText(signaturePath) != signature)
+ {
+ Publicize(source, destination);
+ File.WriteAllText(signaturePath, signature);
+ Log.LogMessage(MessageImportance.Normal, $"TUnitMocksInternalsAccess: publicized '{source}' -> '{destination}'.");
+ }
+ else
+ {
+ Log.LogMessage(MessageImportance.Low, $"TUnitMocksInternalsAccess: '{destination}' is up to date.");
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.LogError(
+ subcategory: null, errorCode: "TUMIA005", helpKeyword: null,
+ file: null, lineNumber: 0, columnNumber: 0, endLineNumber: 0, endColumnNumber: 0,
+ message: $"TUnitMocksInternalsAccess: failed to publicize '{reference.ItemSpec}': {ex.Message}");
+ Log.LogMessage(MessageImportance.Low, ex.ToString());
+ return;
+ }
+
+ var item = new TaskItem(destination);
+ // Preserve the original reference's metadata (Aliases, EmbedInteropTypes, ...) —
+ // Csc reads compiler-significant options from item metadata, and an extern-aliased
+ // reference must stay extern-aliased after the swap.
+ reference.CopyMetadataTo(item);
+ if (identityGroup.Count > 1)
+ {
+ MergeCompilerMetadataFromDuplicates(item, identityGroup);
+ }
+ // "Original" is what the targets file Removes — the reference item as the compiler
+ // knew it (the ref assembly when one existed), not the implementation path.
+ item.SetMetadata("Original", reference.ItemSpec);
+ // Compile-time only: never copy the rewritten assembly to the output directory.
+ item.SetMetadata("Private", "false");
+ item.SetMetadata("CopyLocal", "false");
+ outputs.Add(item);
+ // Two identities behind one simple name produce one attribute application — the runtime
+ // matches IgnoresAccessChecksTo by simple name only.
+ if (!publicizedNames.Contains(assemblyName, StringComparer.OrdinalIgnoreCase))
+ {
+ publicizedNames.Add(assemblyName);
+ }
+
+ superseded.AddRange(identityGroup.Select(ITaskItem (m) => new TaskItem(m.ItemSpec)));
+ }
+
+ ///
+ /// Groups the matches by exact assembly identity (name, version, public key), preserving
+ /// order — each group is publicized once from its first member. A match whose identity
+ /// cannot be read is never assumed to duplicate anything: the first match still anchors a
+ /// group (so a single unreadable reference fails loudly in Publicize), later unreadable
+ /// matches are left in the compiler's reference list untouched.
///
- private (List Superseded, List Retained) PartitionByIdentity(List matches)
+ private (List> Groups, List Unreadable) GroupMatchesByIdentity(List matches)
{
- var supersededMatches = new List { matches[0] };
- var retained = new List();
+ var groups = new List> { new() { matches[0] } };
+ var groupIdentities = new List { TryGetAssemblyIdentity(matches[0].ItemSpec) };
+ var unreadable = new List();
- if (matches.Count > 1)
+ foreach (var match in matches.Skip(1))
{
- var winnerIdentity = TryGetAssemblyIdentity(matches[0].ItemSpec);
+ var identity = TryGetAssemblyIdentity(match.ItemSpec);
+ if (identity is null)
+ {
+ unreadable.Add(match);
+ continue;
+ }
- foreach (var match in matches.Skip(1))
+ var groupIndex = groupIdentities.FindIndex(g => g is not null && HasSameIdentity(g, identity));
+ if (groupIndex >= 0)
{
- var identity = winnerIdentity is null ? null : TryGetAssemblyIdentity(match.ItemSpec);
- if (identity is not null && HasSameIdentity(winnerIdentity!, identity))
- {
- supersededMatches.Add(match);
- }
- else
- {
- retained.Add(match);
- }
+ groups[groupIndex].Add(match);
+ }
+ else
+ {
+ groups.Add([match]);
+ groupIdentities.Add(identity);
}
}
- return (supersededMatches, retained);
+ return (groups, unreadable);
}
private static bool HasSameIdentity(System.Reflection.AssemblyName left, System.Reflection.AssemblyName right)
@@ -446,6 +473,21 @@ private static bool IsReferenceAssembly(string path)
a.AttributeType.FullName == "System.Runtime.CompilerServices.ReferenceAssemblyAttribute");
}
+ ///
+ /// Stable per-source-path directory token — derived from the path (not content) so the same
+ /// source keeps the same output location across builds and incrementality still works.
+ ///
+ private static string HashPathToken(string sourcePath)
+ {
+ using var sha = SHA256.Create();
+ var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Path.GetFullPath(sourcePath).ToUpperInvariant()));
+#if NET
+ return Convert.ToHexString(hash, 0, 4).ToLowerInvariant();
+#else
+ return BitConverter.ToString(hash, 0, 4).Replace("-", "").ToLowerInvariant();
+#endif
+ }
+
private static string HashFile(string path)
{
using var sha = SHA256.Create();
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
index 3eb71f8546..0183f2dd2e 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
@@ -75,10 +75,11 @@ for TUnit.Mocks:
- Diagnostics: `TUMIA001` unresolved assembly name (error; the requested name is matched by
file name first, then by the reference's real assembly identity, so renamed compile assets
resolve), `TUMIA002` .NET Framework inert (warning), `TUMIA003` ref-assembly-only source
- (warning), `TUMIA004` ambiguous simple-name match — first wins and same-identity duplicates
- are removed from the compiler's references so they cannot collide with the publicized copy;
- their extern aliases are unioned and `EmbedInteropTypes` kept if any set it. Matches with a
- DIFFERENT assembly identity (version/public key — a legal pair only under extern aliases) are
+ (warning), `TUMIA004` ambiguous simple-name match — each distinct assembly identity
+ (version/public key — a legal pair only under extern aliases) gets its own publicized copy,
+ while same-identity duplicates collapse onto one copy with extern aliases unioned and
+ `EmbedInteropTypes` kept if any set it; all matched references are removed from the compiler's
+ list so nothing collides with the publicized copies. Matches whose identity cannot be read are
left in place untouched (warning), `TUMIA005` publicize failure, e.g. unreadable/locked
assembly (error).
- Incrementality is content-based (source path + SHA-256) and versioned: upgrading TUnit.Mocks
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
index bc7a05970d..69411fa96a 100644
--- a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
@@ -44,7 +44,7 @@ public async Task Publicizes_Internal_Types_And_Preserves_Identity()
await Assert.That(task.Execute()).IsTrue();
- var rewritten = Path.Combine(dir, TargetLibName + ".dll");
+ var rewritten = task.PublicizedReferences[0].ItemSpec;
await Assert.That(File.Exists(rewritten)).IsTrue();
var context = new AssemblyLoadContext("publicized-probe", isCollectible: true);
@@ -99,7 +99,7 @@ public async Task Second_Run_Is_Incremental()
var first = CreateTask(dir, TargetLibName);
await Assert.That(first.Execute()).IsTrue();
- var rewritten = Path.Combine(dir, TargetLibName + ".dll");
+ var rewritten = first.PublicizedReferences[0].ItemSpec;
var firstWrite = File.GetLastWriteTimeUtc(rewritten);
var firstSourceWrite = File.GetLastWriteTimeUtc(first.GeneratedSourceFile);
@@ -124,7 +124,7 @@ public async Task Content_Change_Invalidates_Publicized_Copy_Despite_Older_Times
first.ReferencePaths = [new TaskItem(source)];
await Assert.That(first.Execute()).IsTrue();
- var rewritten = Path.Combine(dir, TargetLibName + ".dll");
+ var rewritten = first.PublicizedReferences[0].ItemSpec;
var firstWrite = File.GetLastWriteTimeUtc(rewritten);
// Different content, timestamp pushed BEFORE the publicized copy — a timestamp-only
@@ -243,16 +243,16 @@ public async Task Ambiguous_Matches_Union_Compiler_Metadata()
}
[Test]
- public async Task Different_Identity_Same_Name_Reference_Is_Left_In_Place()
+ public async Task Different_Identity_Same_Name_References_Are_Each_Publicized()
{
var dir = NewScratchDirectory();
var otherVersionDir = NewScratchDirectory();
var otherVersionPath = Path.Combine(otherVersionDir, TargetLibName + ".dll");
CreateDifferentVersionCopy(TargetLibPath, otherVersionPath);
- // A same-file-name reference with a DIFFERENT identity is a legal pair only when extern
- // aliases keep the two apart — superseding it would rebind its alias to the publicized
- // copy's identity and drop its unique API from the compilation.
+ // A same-file-name pair with DIFFERENT identities is legal only under extern aliases,
+ // and the simple-name request cannot single one out — so each identity gets its own
+ // publicized copy, with per-identity metadata (the alias) preserved on its own item.
var winner = new TaskItem(TargetLibPath);
var otherVersion = new TaskItem(otherVersionPath);
otherVersion.SetMetadata("Aliases", "oldsdk");
@@ -266,13 +266,58 @@ public async Task Different_Identity_Same_Name_Reference_Is_Left_In_Place()
await Assert.That(engine.Warnings.Count).IsEqualTo(1);
await Assert.That(engine.Warnings[0].Code).IsEqualTo("TUMIA004");
- // Only the winner is superseded; the different-identity reference stays untouched.
+ // Both identities are superseded — each is replaced by its own publicized copy.
var supersededPaths = task.SupersededReferences.Select(s => s.ItemSpec).ToList();
await Assert.That(supersededPaths).Contains(TargetLibPath);
- await Assert.That(supersededPaths).DoesNotContain(otherVersionPath);
- // Its alias must NOT be merged onto the publicized item — it belongs to a different
- // assembly identity that remains in the compiler's reference list.
+ await Assert.That(supersededPaths).Contains(otherVersionPath);
+
+ await Assert.That(task.PublicizedReferences.Length).IsEqualTo(2);
+ await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(TargetLibPath);
+ await Assert.That(task.PublicizedReferences[1].GetMetadata("Original")).IsEqualTo(otherVersionPath);
+ // Aliases stay with their own identity — never merged across identities.
await Assert.That(task.PublicizedReferences[0].GetMetadata("Aliases")).IsEqualTo("");
+ await Assert.That(task.PublicizedReferences[1].GetMetadata("Aliases")).IsEqualTo("oldsdk");
+
+ // Same file name from different directories — the copies must not overwrite each other,
+ // and each must keep its own identity.
+ var copyPaths = task.PublicizedReferences.Select(p => p.ItemSpec).ToList();
+ await Assert.That(copyPaths[0]).IsNotEqualTo(copyPaths[1]);
+ await Assert.That(AssemblyName.GetAssemblyName(copyPaths[0]).Version)
+ .IsEqualTo(AssemblyName.GetAssemblyName(TargetLibPath).Version);
+ await Assert.That(AssemblyName.GetAssemblyName(copyPaths[1]).Version).IsEqualTo(new Version(99, 0, 0, 0));
+
+ // One simple name -> one IgnoresAccessChecksTo application, even with two identities.
+ var source = await File.ReadAllTextAsync(task.GeneratedSourceFile);
+ var applications = source.Split([$"IgnoresAccessChecksTo(\"{TargetLibName}\")"], StringSplitOptions.None).Length - 1;
+ await Assert.That(applications).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Same_File_Name_Sources_From_Different_Directories_Get_Distinct_Outputs()
+ {
+ var dir = NewScratchDirectory();
+ var dirA = NewScratchDirectory();
+ var dirB = NewScratchDirectory();
+
+ // Two opted-in references whose compile assets share a file name but hold different
+ // assemblies (renamed assets) — their publicized copies must not collide.
+ var renamedTargetLib = Path.Combine(dirA, "Common.dll");
+ File.Copy(TargetLibPath, renamedTargetLib);
+ var renamedMocks = Path.Combine(dirB, "Common.dll");
+ File.Copy(Path.Combine(AppContext.BaseDirectory, "TUnit.Mocks.dll"), renamedMocks);
+
+ var task = CreateTask(dir, TargetLibName, "TUnit.Mocks");
+ task.ReferencePaths = [new TaskItem(renamedTargetLib), new TaskItem(renamedMocks)];
+
+ await Assert.That(task.Execute()).IsTrue();
+ await Assert.That(task.PublicizedReferences.Length).IsEqualTo(2);
+
+ var copyPaths = task.PublicizedReferences.Select(p => p.ItemSpec).ToList();
+ await Assert.That(copyPaths[0]).IsNotEqualTo(copyPaths[1]);
+ // Each copy holds the assembly it was publicized from, not the other name's overwrite.
+ var identities = copyPaths.Select(p => AssemblyName.GetAssemblyName(p).Name).ToList();
+ await Assert.That(identities).Contains(TargetLibName);
+ await Assert.That(identities).Contains("TUnit.Mocks");
}
[Test]
@@ -313,7 +358,7 @@ public async Task Task_Version_Change_Invalidates_Publicized_Copy()
var first = CreateTask(dir, TargetLibName);
await Assert.That(first.Execute()).IsTrue();
- var rewritten = Path.Combine(dir, TargetLibName + ".dll");
+ var rewritten = first.PublicizedReferences[0].ItemSpec;
var signaturePath = rewritten + ".sig";
// Emulate a copy produced by an older TUnit.Mocks: same source path and hash, different
@@ -342,7 +387,7 @@ public async Task Publicize_Promotes_Only_Assembly_Visible_Members()
var context = new AssemblyLoadContext("visibility-probe", isCollectible: true);
try
{
- var assembly = context.LoadFromAssemblyPath(Path.Combine(dir, TargetLibName + ".dll"));
+ var assembly = context.LoadFromAssemblyPath(task.PublicizedReferences[0].ItemSpec);
var surface = assembly.GetType("FakeSdk.PublicSurface", throwOnError: true)!;
// internal -> public.
From 6d4a0da72af71dc5ba1259cca5275a8c39266190 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 30 Jul 2026 11:27:04 +0100
Subject: [PATCH 09/10] fix(mocks): identity-first runtime-asset resolution,
case-safe path tokens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The filename-first shortcut in FindRuntimeImplementation handed both
identity groups of a same-file-name extern-alias pair the first
implementation, so the second publicized copy carried the wrong
identity. Identity now decides; the requested-name filename match
survives only for a reference whose own identity cannot be read.
Path tokens fold case only on Windows hosts — on a case-sensitive
filesystem /deps/A/Foo.dll and /deps/a/Foo.dll are different sources
and folding would collapse their destinations.
---
.../PublicizeAssemblyReferences.cs | 36 +++++++++++-------
.../PublicizeAssemblyReferencesTaskTests.cs | 38 +++++++++++++++++++
2 files changed, 60 insertions(+), 14 deletions(-)
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
index e638ce72fe..531e2341e0 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -410,11 +410,12 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference)
}
///
- /// Finds the implementation assembly among the runtime/copy-local assets: first by file name
- /// (the common case), then by the reference's real assembly identity — a compile asset can
- /// be renamed relative to the assembly it contains, in which case the implementation shows
- /// up among the runtime assets under the real name, not the requested (file-derived) one.
- /// A version-exact identity match wins over a name-only one.
+ /// Finds the implementation assembly among the runtime/copy-local assets by the reference's
+ /// real assembly identity — a version-exact match wins over a name-only one. Identity, not
+ /// file name, decides: two same-file-name references with distinct identities (extern-alias
+ /// pairs) each search this list, and a filename-first shortcut would hand both the same
+ /// implementation. The requested-name filename match survives only as a fallback for a
+ /// reference whose own identity cannot be read.
///
private string? FindRuntimeImplementation(string name, string referencePath)
{
@@ -427,17 +428,11 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference)
!string.Equals(Path.GetFullPath(p), Path.GetFullPath(referencePath), StringComparison.OrdinalIgnoreCase))
.ToList();
- var byFileName = candidates.FirstOrDefault(p =>
- string.Equals(Path.GetFileNameWithoutExtension(p), name, StringComparison.OrdinalIgnoreCase));
- if (byFileName is not null)
- {
- return byFileName;
- }
-
var identity = TryGetAssemblyIdentity(referencePath);
if (identity is null)
{
- return null;
+ return candidates.FirstOrDefault(p =>
+ string.Equals(Path.GetFileNameWithoutExtension(p), name, StringComparison.OrdinalIgnoreCase));
}
string? nameOnlyMatch = null;
@@ -476,11 +471,24 @@ private static bool IsReferenceAssembly(string path)
///
/// Stable per-source-path directory token — derived from the path (not content) so the same
/// source keeps the same output location across builds and incrementality still works.
+ /// Case is folded only where paths are case-insensitive: on a case-sensitive host,
+ /// /deps/A/Foo.dll and /deps/a/Foo.dll are DIFFERENT sources and folding would collapse
+ /// their tokens (and, with equal file names, their destinations).
///
private static string HashPathToken(string sourcePath)
{
+ var fullPath = Path.GetFullPath(sourcePath);
+#if NET
+ if (OperatingSystem.IsWindows())
+ {
+ fullPath = fullPath.ToUpperInvariant();
+ }
+#else
+ // The net472 MSBuild host only runs on Windows.
+ fullPath = fullPath.ToUpperInvariant();
+#endif
using var sha = SHA256.Create();
- var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Path.GetFullPath(sourcePath).ToUpperInvariant()));
+ var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(fullPath));
#if NET
return Convert.ToHexString(hash, 0, 4).ToLowerInvariant();
#else
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
index 69411fa96a..88e4e0c122 100644
--- a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
@@ -524,6 +524,44 @@ public async Task Renamed_Reference_Assembly_Resolves_Implementation_By_Assembly
await Assert.That(isRefAssembly).IsFalse();
}
+ [Test]
+ public async Task Same_File_Name_Identity_Pair_Resolves_Each_Implementation_By_Identity()
+ {
+ // Two metadata-only references sharing a file name but holding DISTINCT identities
+ // (extern-alias pair) both search the runtime assets — a filename-first shortcut would
+ // hand both groups the first implementation, so the second publicized copy would carry
+ // the first assembly's identity.
+ var dir = NewScratchDirectory();
+
+ var implOldDir = NewScratchDirectory();
+ var implOld = Path.Combine(implOldDir, TargetLibName + ".dll");
+ CreateDifferentVersionCopy(TargetLibPath, implOld);
+
+ var refNewDir = NewScratchDirectory();
+ var refNew = Path.Combine(refNewDir, TargetLibName + ".dll");
+ CreateReferenceAssemblyCopy(TargetLibPath, refNew);
+ var refOldDir = NewScratchDirectory();
+ var refOld = Path.Combine(refOldDir, TargetLibName + ".dll");
+ CreateReferenceAssemblyCopy(implOld, refOld);
+
+ var engine = new StubBuildEngine();
+ var task = CreateTask(dir, TargetLibName);
+ task.BuildEngine = engine;
+ task.ReferencePaths = [new TaskItem(refNew), new TaskItem(refOld)];
+ task.RuntimeAssemblies = [new TaskItem(TargetLibPath), new TaskItem(implOld)];
+
+ await Assert.That(task.Execute()).IsTrue();
+ await Assert.That(task.PublicizedReferences.Length).IsEqualTo(2);
+
+ // Each copy must hold ITS reference's identity, resolved from the matching
+ // implementation — never the other identity's asset.
+ var originalVersion = AssemblyName.GetAssemblyName(TargetLibPath).Version;
+ await Assert.That(AssemblyName.GetAssemblyName(task.PublicizedReferences[0].ItemSpec).Version)
+ .IsEqualTo(originalVersion);
+ await Assert.That(AssemblyName.GetAssemblyName(task.PublicizedReferences[1].ItemSpec).Version)
+ .IsEqualTo(new Version(99, 0, 0, 0));
+ }
+
private static void CreateReferenceAssemblyCopy(string source, string destination)
{
using var module = Mono.Cecil.ModuleDefinition.ReadModule(source);
From 26e2f55bed5a28fda50791b3e3c00629efcb1759 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 30 Jul 2026 11:54:25 +0100
Subject: [PATCH 10/10] fix(mocks): identity-first reference lookup,
full-identity runtime matching, platform-aware path compare
- FindReferenceMatches now matches readable assembly identities first; a
colliding file name holding a different identity can no longer hide the
real match. File-name matching survives only for unreadable references
and as a no-identity-match compatibility fallback. Identity reads are
cached per task invocation.
- FindRuntimeImplementation compares the full identity: a candidate with
a conflicting public key token is never selected; version drift alone
remains the tolerated fallback.
- The reference-asset exclusion folds path case only on case-insensitive
hosts, matching the HashPathToken rule.
---
.../PublicizeAssemblyReferences.cs | 80 +++++++++++++-----
.../PublicizeAssemblyReferencesTaskTests.cs | 81 +++++++++++++++++++
2 files changed, 140 insertions(+), 21 deletions(-)
diff --git a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
index 531e2341e0..93fc08ce91 100644
--- a/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -154,23 +154,29 @@ private static string GetTaskVersion()
}
///
- /// Resolves the requested simple name against the compiler's references: by file name first
- /// (the common case), then by each reference's REAL assembly identity — a compile asset can
- /// be renamed relative to the assembly it contains (e.g. VendorAlias.dll holding identity
- /// Vendor.Core), and the documented contract is the simple ASSEMBLY name, not the file name.
+ /// Resolves the requested simple name against the compiler's references. The documented
+ /// contract is the simple ASSEMBLY name, so a readable identity always decides: a renamed
+ /// asset (VendorAlias.dll holding identity Vendor.Core) matches, and a colliding file name
+ /// holding a DIFFERENT identity must not hide it. File-name matching survives in two places
+ /// only — references whose identity cannot be read ride along with the identity matches, and
+ /// when NO identity matches at all the file name is tried as a compatibility fallback (the
+ /// pre-existing behavior of requesting a renamed asset by its file name).
///
private List FindReferenceMatches(string name)
{
- var byFileName = ReferencePaths.Where(r =>
- string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase)).ToList();
+ var byIdentity = ReferencePaths.Where(r =>
+ string.Equals(TryGetAssemblyIdentity(r.ItemSpec)?.Name, name, StringComparison.OrdinalIgnoreCase)).ToList();
- if (byFileName.Count > 0)
+ if (byIdentity.Count > 0)
{
- return byFileName;
+ byIdentity.AddRange(ReferencePaths.Where(r =>
+ TryGetAssemblyIdentity(r.ItemSpec) is null &&
+ string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase)));
+ return byIdentity;
}
return ReferencePaths.Where(r =>
- string.Equals(TryGetAssemblyIdentity(r.ItemSpec)?.Name, name, StringComparison.OrdinalIgnoreCase)).ToList();
+ string.Equals(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase)).ToList();
}
///
@@ -298,17 +304,30 @@ private static bool HasSameIdentity(System.Reflection.AssemblyName left, System.
&& Equals(left.Version, right.Version)
&& (left.GetPublicKeyToken() ?? []).SequenceEqual(right.GetPublicKeyToken() ?? []);
- private static System.Reflection.AssemblyName? TryGetAssemblyIdentity(string path)
+ // Identity-first matching reads every reference's identity per requested name; cache the
+ // reads so each file is opened once per task invocation.
+ private readonly Dictionary _identityCache = new(StringComparer.Ordinal);
+
+ private System.Reflection.AssemblyName? TryGetAssemblyIdentity(string path)
{
+ if (_identityCache.TryGetValue(path, out var cached))
+ {
+ return cached;
+ }
+
+ System.Reflection.AssemblyName? identity;
try
{
- return System.Reflection.AssemblyName.GetAssemblyName(path);
+ identity = System.Reflection.AssemblyName.GetAssemblyName(path);
}
catch (Exception)
{
// Native or otherwise unreadable dll.
- return null;
+ identity = null;
}
+
+ _identityCache[path] = identity;
+ return identity;
}
///
@@ -411,11 +430,12 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference)
///
/// Finds the implementation assembly among the runtime/copy-local assets by the reference's
- /// real assembly identity — a version-exact match wins over a name-only one. Identity, not
- /// file name, decides: two same-file-name references with distinct identities (extern-alias
- /// pairs) each search this list, and a filename-first shortcut would hand both the same
- /// implementation. The requested-name filename match survives only as a fallback for a
- /// reference whose own identity cannot be read.
+ /// real assembly identity — a full-identity match (name, public key token, version) wins,
+ /// with version drift alone tolerated as a fallback for imperfectly built packages. A
+ /// conflicting public key token is a DIFFERENT assembly that happens to share the simple
+ /// name (extern-alias pairs) and is never handed back — doing so would swap identities.
+ /// The requested-name filename match survives only as a fallback for a reference whose own
+ /// identity cannot be read.
///
private string? FindRuntimeImplementation(string name, string referencePath)
{
@@ -425,7 +445,7 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference)
string.Equals(Path.GetExtension(p), ".dll", StringComparison.OrdinalIgnoreCase) &&
File.Exists(p) &&
// Never hand back the reference assembly itself if it also appears as an asset.
- !string.Equals(Path.GetFullPath(p), Path.GetFullPath(referencePath), StringComparison.OrdinalIgnoreCase))
+ !string.Equals(Path.GetFullPath(p), Path.GetFullPath(referencePath), PathComparison))
.ToList();
var identity = TryGetAssemblyIdentity(referencePath);
@@ -435,7 +455,7 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference)
string.Equals(Path.GetFileNameWithoutExtension(p), name, StringComparison.OrdinalIgnoreCase));
}
- string? nameOnlyMatch = null;
+ string? versionDriftMatch = null;
foreach (var candidate in candidates)
{
var candidateIdentity = TryGetAssemblyIdentity(candidate);
@@ -450,17 +470,35 @@ private string ResolveImplementationAssembly(string name, ITaskItem reference)
continue;
}
+ if (!(candidateIdentity.GetPublicKeyToken() ?? []).SequenceEqual(identity.GetPublicKeyToken() ?? []))
+ {
+ continue;
+ }
+
if (Equals(candidateIdentity.Version, identity.Version))
{
return candidate;
}
- nameOnlyMatch ??= candidate;
+ versionDriftMatch ??= candidate;
}
- return nameOnlyMatch;
+ return versionDriftMatch;
}
+ ///
+ /// Path comparisons fold case only where the filesystem does: on a case-sensitive host,
+ /// /deps/A/Foo.dll and /deps/a/Foo.dll are distinct files, and folding would misjudge the
+ /// implementation as the reference assembly itself.
+ ///
+ private static StringComparison PathComparison =>
+#if NET
+ OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+#else
+ // The net472 MSBuild host only runs on Windows.
+ StringComparison.OrdinalIgnoreCase;
+#endif
+
private static bool IsReferenceAssembly(string path)
{
using var module = ModuleDefinition.ReadModule(path);
diff --git a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
index 88e4e0c122..700aa682e9 100644
--- a/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
@@ -562,6 +562,87 @@ await Assert.That(AssemblyName.GetAssemblyName(task.PublicizedReferences[1].Item
.IsEqualTo(new Version(99, 0, 0, 0));
}
+ [Test]
+ public async Task Colliding_File_Name_Does_Not_Hide_The_Identity_Match()
+ {
+ // One reference carries the requested FILE name but a different assembly identity, while
+ // a renamed reference holds the requested identity. The documented contract is the simple
+ // assembly name, so the identity match must win — a filename-first lookup would publicize
+ // the wrong assembly and leave the intended reference untouched.
+ var dir = NewScratchDirectory();
+
+ var decoyDir = NewScratchDirectory();
+ var decoy = Path.Combine(decoyDir, TargetLibName + ".dll");
+ CreateRenamedIdentityCopy(TargetLibPath, decoy, "Some.Unrelated.Assembly");
+
+ var aliasDir = NewScratchDirectory();
+ var alias = Path.Combine(aliasDir, "VendorAlias.dll");
+ File.Copy(TargetLibPath, alias);
+
+ var engine = new StubBuildEngine();
+ var task = CreateTask(dir, TargetLibName);
+ task.BuildEngine = engine;
+ task.ReferencePaths = [new TaskItem(decoy), new TaskItem(alias)];
+
+ await Assert.That(task.Execute()).IsTrue();
+ await Assert.That(task.PublicizedReferences.Length).IsEqualTo(1);
+ await Assert.That(task.PublicizedReferences[0].GetMetadata("Original")).IsEqualTo(alias);
+ await Assert.That(AssemblyName.GetAssemblyName(task.PublicizedReferences[0].ItemSpec).Name)
+ .IsEqualTo(TargetLibName);
+ }
+
+ [Test]
+ public async Task Runtime_Asset_With_Conflicting_Public_Key_Token_Is_Never_Selected()
+ {
+ // Same simple name and version but a different public key token is a DIFFERENT assembly;
+ // handing it back as the "implementation" would swap identities. The stripped ref
+ // assembly must be publicized instead, with the TUMIA003 warning.
+ var dir = NewScratchDirectory();
+
+ var refAsmDir = NewScratchDirectory();
+ var refAsm = Path.Combine(refAsmDir, TargetLibName + ".dll");
+ CreateReferenceAssemblyCopy(TargetLibPath, refAsm);
+
+ var unsignedDir = NewScratchDirectory();
+ var unsignedImpl = Path.Combine(unsignedDir, TargetLibName + ".dll");
+ CreateUnsignedCopy(TargetLibPath, unsignedImpl);
+
+ // Sanity: the scenario only exists if the tokens actually differ.
+ await Assert.That(AssemblyName.GetAssemblyName(TargetLibPath).GetPublicKeyToken()!.Length)
+ .IsNotEqualTo(0);
+ await Assert.That(AssemblyName.GetAssemblyName(unsignedImpl).GetPublicKeyToken() ?? [])
+ .IsEmpty();
+
+ var engine = new StubBuildEngine();
+ var task = CreateTask(dir, TargetLibName);
+ task.BuildEngine = engine;
+ task.ReferencePaths = [new TaskItem(refAsm)];
+ task.RuntimeAssemblies = [new TaskItem(unsignedImpl)];
+
+ await Assert.That(task.Execute()).IsTrue();
+ await Assert.That(engine.Warnings.Count).IsEqualTo(1);
+ await Assert.That(engine.Warnings[0].Code).IsEqualTo("TUMIA003");
+
+ // Publicized from the ref assembly itself — it keeps ITS identity.
+ var publicizedToken = AssemblyName.GetAssemblyName(task.PublicizedReferences[0].ItemSpec).GetPublicKeyToken();
+ await Assert.That(publicizedToken).IsEquivalentTo(AssemblyName.GetAssemblyName(TargetLibPath).GetPublicKeyToken()!);
+ }
+
+ private static void CreateRenamedIdentityCopy(string source, string destination, string newAssemblyName)
+ {
+ using var module = Mono.Cecil.ModuleDefinition.ReadModule(source);
+ module.Assembly.Name.Name = newAssemblyName;
+ module.Write(destination);
+ }
+
+ private static void CreateUnsignedCopy(string source, string destination)
+ {
+ using var module = Mono.Cecil.ModuleDefinition.ReadModule(source);
+ module.Assembly.Name.PublicKey = [];
+ module.Assembly.Name.HasPublicKey = false;
+ module.Write(destination);
+ }
+
private static void CreateReferenceAssemblyCopy(string source, string destination)
{
using var module = Mono.Cecil.ModuleDefinition.ReadModule(source);