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.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/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/docs/docs/writing-tests/mocking/advanced.md b/docs/docs/writing-tests/mocking/advanced.md
index 261cb495d4..3f3d11b58e 100644
--- a/docs/docs/writing-tests/mocking/advanced.md
+++ b/docs/docs/writing-tests/mocking/advanced.md
@@ -238,3 +238,57 @@ 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.
+- 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
new file mode 100644
index 0000000000..93fc08ce91
--- /dev/null
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/PublicizeAssemblyReferences.cs
@@ -0,0 +1,630 @@
+using System;
+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;
+
+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
+{
+ ///
+ /// 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; } = [];
+
+ /// Simple assembly names to publicize (@(TUnitMocksInternalsAccess)).
+ [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; } = "";
+
+ /// Path of the generated IgnoresAccessChecksTo source file.
+ [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 (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();
+
+ // 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 matches = FindReferenceMatches(name);
+
+ if (matches.Count == 0)
+ {
+ 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.");
+ continue;
+ }
+
+ // 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 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))}. Each distinct " +
+ "assembly identity is publicized separately; same-identity duplicates " +
+ "collapse onto one copy (extern aliases and EmbedInteropTypes " +
+ $"preserved).{unreadableNote}");
+ }
+
+ foreach (var identityGroup in identityGroups)
+ {
+ PublicizeGroup(name, identityGroup, outputs, superseded, publicizedNames);
+ }
+ }
+
+ if (!Log.HasLoggedErrors)
+ {
+ WriteIgnoresAccessChecksToSource(publicizedNames);
+ }
+
+ PublicizedReferences = outputs.ToArray();
+ SupersededReferences = superseded.ToArray();
+ 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";
+ }
+
+ ///
+ /// 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 byIdentity = ReferencePaths.Where(r =>
+ string.Equals(TryGetAssemblyIdentity(r.ItemSpec)?.Name, name, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (byIdentity.Count > 0)
+ {
+ 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(Path.GetFileNameWithoutExtension(r.ItemSpec), name, StringComparison.OrdinalIgnoreCase)).ToList();
+ }
+
+ ///
+ /// 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> Groups, List Unreadable) GroupMatchesByIdentity(List matches)
+ {
+ var groups = new List> { new() { matches[0] } };
+ var groupIdentities = new List { TryGetAssemblyIdentity(matches[0].ItemSpec) };
+ var unreadable = new List();
+
+ foreach (var match in matches.Skip(1))
+ {
+ var identity = TryGetAssemblyIdentity(match.ItemSpec);
+ if (identity is null)
+ {
+ unreadable.Add(match);
+ continue;
+ }
+
+ var groupIndex = groupIdentities.FindIndex(g => g is not null && HasSameIdentity(g, identity));
+ if (groupIndex >= 0)
+ {
+ groups[groupIndex].Add(match);
+ }
+ else
+ {
+ groups.Add([match]);
+ groupIdentities.Add(identity);
+ }
+ }
+
+ return (groups, unreadable);
+ }
+
+ 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() ?? []);
+
+ // 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
+ {
+ identity = System.Reflection.AssemblyName.GetAssemblyName(path);
+ }
+ catch (Exception)
+ {
+ // Native or otherwise unreadable dll.
+ identity = null;
+ }
+
+ _identityCache[path] = identity;
+ return identity;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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
+ /// 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 = FindRuntimeImplementation(name, reference.ItemSpec);
+
+ if (runtimeMatch is not null)
+ {
+ Log.LogMessage(MessageImportance.Normal,
+ $"TUnitMocksInternalsAccess: '{reference.ItemSpec}' is a reference assembly; " +
+ $"publicizing the implementation '{runtimeMatch}' instead.");
+ return runtimeMatch;
+ }
+
+ 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;
+ }
+
+ ///
+ /// Finds the implementation assembly among the runtime/copy-local assets by the reference's
+ /// 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)
+ {
+ 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), PathComparison))
+ .ToList();
+
+ var identity = TryGetAssemblyIdentity(referencePath);
+ if (identity is null)
+ {
+ return candidates.FirstOrDefault(p =>
+ string.Equals(Path.GetFileNameWithoutExtension(p), name, StringComparison.OrdinalIgnoreCase));
+ }
+
+ string? versionDriftMatch = null;
+ foreach (var candidate in candidates)
+ {
+ var candidateIdentity = TryGetAssemblyIdentity(candidate);
+ if (candidateIdentity is null)
+ {
+ // Native or otherwise unreadable dll among the copy-local assets.
+ continue;
+ }
+
+ if (!string.Equals(candidateIdentity.Name, identity.Name, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (!(candidateIdentity.GetPublicKeyToken() ?? []).SequenceEqual(identity.GetPublicKeyToken() ?? []))
+ {
+ continue;
+ }
+
+ if (Equals(candidateIdentity.Version, identity.Version))
+ {
+ return candidate;
+ }
+
+ versionDriftMatch ??= candidate;
+ }
+
+ 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);
+ return module.Assembly.HasCustomAttributes && module.Assembly.CustomAttributes.Any(a =>
+ 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.
+ /// 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(fullPath));
+#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();
+ 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);
+
+ foreach (var type in module.GetTypes())
+ {
+ if (type.Name == "")
+ {
+ continue;
+ }
+
+ // 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)
+ {
+ if (IsAssemblyVisibleMethod(method))
+ {
+ 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);
+ }
+
+ /// 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();
+ 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}\")]");
+ }
+
+ 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)!);
+ 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..0183f2dd2e
--- /dev/null
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/README.md
@@ -0,0 +1,111 @@
+# TUnit.Mocks internals access (experimental)
+
+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. 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
+
+
+
+
+
+
+```
+
+## How it works
+
+The established "publicizer" pattern (Krafs.Publicizer, IgnoresAccessChecksToGenerator), wired
+for TUnit.Mocks:
+
+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. `%(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. 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. 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.
+
+## 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
+
+- `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: 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; 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 — 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
+ 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
+
+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
+
+- `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
new file mode 100644
index 0000000000..e402b29b0e
--- /dev/null
+++ b/src/TUnit.Mocks.InternalsAccess.Tasks/TUnit.Mocks.InternalsAccess.Tasks.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+ net472;net8.0
+ false
+
+ true
+ true
+
+
+
+
+
+
+
+
+
diff --git a/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets
new file mode 100644
index 0000000000..0b16aa073a
--- /dev/null
+++ b/src/TUnit.Mocks/TUnit.Mocks.InternalsAccess.targets
@@ -0,0 +1,74 @@
+
+
+
+
+ <_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
+
+
+
+
+
+
+
+
+
+
+
+ <_TUnitMocksIactSourceFile>$(IntermediateOutputPath)TUnitMocksIgnoresAccessChecksTo.g.cs
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/TUnit.Mocks/TUnit.Mocks.csproj b/src/TUnit.Mocks/TUnit.Mocks.csproj
index 1eb8cfb2bc..ebbae89f15 100644
--- a/src/TUnit.Mocks/TUnit.Mocks.csproj
+++ b/src/TUnit.Mocks/TUnit.Mocks.csproj
@@ -50,6 +50,43 @@
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
new file mode 100644
index 0000000000..d8c0655e60
--- /dev/null
+++ b/tests/TUnit.Mocks.InternalsAccess.TargetLib/SdkRuntime.cs
@@ -0,0 +1,94 @@
+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);
+}
+
+///
+/// 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;
+}
+
+///
+/// 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.
+///
+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();
+ 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..8aa5d27690
--- /dev/null
+++ b/tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj
@@ -0,0 +1,15 @@
+
+
+
+
+ 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
new file mode 100644
index 0000000000..7d97d13bd6
--- /dev/null
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/InternalsAccessTests.cs
@@ -0,0 +1,102 @@
+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 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()
+ {
+ // 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/PublicizeAssemblyReferencesTaskTests.cs b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
new file mode 100644
index 0000000000..700aa682e9
--- /dev/null
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/PublicizeAssemblyReferencesTaskTests.cs
@@ -0,0 +1,701 @@
+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 = task.PublicizedReferences[0].ItemSpec;
+ 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 = first.PublicizedReferences[0].ItemSpec;
+ 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 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 = first.PublicizedReferences[0].ItemSpec;
+ 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);
+ // 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 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 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 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");
+
+ 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");
+
+ // 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).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]
+ 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()
+ {
+ var dir = NewScratchDirectory();
+
+ var first = CreateTask(dir, TargetLibName);
+ await Assert.That(first.Execute()).IsTrue();
+
+ 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
+ // 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()
+ {
+ 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(task.PublicizedReferences[0].ItemSpec);
+ 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]
+ 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);
+ }
+
+ [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();
+ }
+
+ [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));
+ }
+
+ [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);
+ 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()
+ {
+ 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 List Warnings { 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) => Warnings.Add(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
new file mode 100644
index 0000000000..93923485c6
--- /dev/null
+++ b/tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj
@@ -0,0 +1,39 @@
+
+
+
+
+
+ net10.0
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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",
+ }
+ }
+ ));
+ }
+}