using System.Reflection;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
///
/// Deferred register row 50, mitigation 2: a reflection guard that no Akka actor holds
/// captive scope state — a scoped service, or an , cached for
/// the actor's lifetime.
///
///
///
/// Why this exists separately from scope validation. Register row 45 was exactly this
/// bug: NotificationOutboxActor cached delivery adapters resolved from an
/// actor-lifetime IServiceScope, so MaxParallelDeliveries concurrent deliveries
/// shared one EF DbContext and EF threw "a second operation was started on this
/// context instance" continuously on the rig (fixed 9e769735). .NET's own
/// ValidateScopes could never have caught it: the actors take the ROOT
/// and call GetRequiredService by hand, which the
/// container's static graph analysis cannot see through. This guard closes that half.
///
///
/// What is allowed. Holding or
/// for the actor's lifetime is the CORRECT pattern and is
/// deliberately not flagged — every actor here does that and opens a fresh scope per
/// operation. What is forbidden is holding the SCOPE, or anything resolved from one, past
/// the operation that opened it.
///
///
/// How "scoped" is decided. The caller supplies the scoped set derived from the actual
/// DI registrations of a real composition root, so the guard tracks the container rather than
/// a hand-maintained list — re-registering a service from singleton to scoped automatically
/// brings any actor field of that type into scope for the check. Two types are flagged
/// unconditionally on top of that set, because they are wrong in an actor field whatever the
/// container says: itself, and any .
///
///
/// Auto-property backing fields need no special handling: the compiler emits them as private
/// instance fields, so GetFields(NonPublic | Instance) already returns them.
///
///
internal static class CaptiveDependencyGuard
{
/// One offending field on one actor type.
/// The actor class declaring the field.
/// The field's name (a compiler-generated backing field keeps its mangled name).
/// The field's declared type.
/// Why the field was flagged.
internal sealed record Violation(Type ActorType, string FieldName, Type FieldType, string Reason)
{
///
public override string ToString() =>
$"{ActorType.FullName}.{FieldName} : {FieldType.FullName} — {Reason}";
}
///
/// Field types that are always correct to hold for an actor's lifetime: they ARE the
/// per-operation-scope pattern rather than a violation of it.
///
internal static readonly IReadOnlyList AllowedProviderTypes =
[
typeof(IServiceProvider),
typeof(IServiceScopeFactory),
];
///
/// Fields that hold a scoped-typed value but are provably safe, keyed
/// "Namespace.ActorType.fieldName". Every entry carries its safety argument —
/// nothing is exempted silently.
///
///
///
/// EMPTY, and pinned empty by CaptiveDependencyGuardTests.Allowlist_IsEmpty.
///
///
/// It previously held two entries — AuditLogIngestActor._injectedRepository and
/// SiteCallAuditActor._injectedRepository — nullable repository fields assigned only
/// by explicitly test-mode constructors. They were safe solely because no production call
/// site picked those overloads, which left the row-45 captive-dependency bug one
/// constructor call away. Register row 61 deleted the constructors instead: both actors now
/// have a single constructor, the fields are gone, and the
/// test fixtures build stub providers that hand a repository out of a scope. With the shape
/// removed there is nothing left to exempt.
///
///
/// Keep it empty. A new entry means someone reintroduced a scoped service cached on an
/// actor field; prefer fixing the actor.
///
///
internal static readonly IReadOnlySet Allowlist =
new HashSet(StringComparer.Ordinal);
///
/// Scans the given actor types for instance fields holding captive scope state.
///
/// Actor classes to inspect.
///
/// Service and implementation types registered with
/// in the composition root being checked.
///
///
/// False makes the scan report allowlisted fields too — used by the guard's own self-tests
/// so the allowlist cannot quietly grow to cover a real regression.
///
/// Every violation found, in declaration order.
internal static IReadOnlyList Scan(
IEnumerable actorTypes,
IReadOnlySet scopedServiceTypes,
bool applyAllowlist = true)
{
ArgumentNullException.ThrowIfNull(actorTypes);
ArgumentNullException.ThrowIfNull(scopedServiceTypes);
var violations = new List();
foreach (var actorType in actorTypes)
{
foreach (var field in InstanceFields(actorType))
{
var fieldType = field.FieldType;
if (AllowedProviderTypes.Contains(fieldType))
{
// The correct pattern — create a scope per operation.
continue;
}
string? reason = null;
if (fieldType == typeof(IServiceScope) || fieldType == typeof(AsyncServiceScope))
{
reason = "holds a DI scope for the actor's lifetime; open a scope per operation instead";
}
else if (typeof(DbContext).IsAssignableFrom(fieldType))
{
reason = "holds an EF DbContext for the actor's lifetime; resolve it inside a per-operation scope";
}
else if (scopedServiceTypes.Contains(fieldType))
{
reason = "holds a service registered Scoped; resolve it inside a per-operation scope";
}
if (reason is null)
{
continue;
}
if (applyAllowlist && Allowlist.Contains($"{actorType.FullName}.{field.Name}"))
{
continue;
}
violations.Add(new Violation(actorType, field.Name, fieldType, reason));
}
}
return violations;
}
///
/// Every instance field declared by the actor class and by its ScadaBridge-owned base
/// classes. The walk stops at the first non-ScadaBridge base, which is how Akka's own
/// internals are excluded without naming them.
///
/// The actor class to walk.
/// The instance fields, most-derived first.
private static IEnumerable InstanceFields(Type actorType)
{
for (var t = actorType; t is not null && IsScadaBridgeType(t); t = t.BaseType)
{
foreach (var field in t.GetFields(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
yield return field;
}
}
}
/// Whether a type belongs to a first-party ScadaBridge assembly.
/// The type to test.
/// True when the declaring assembly is a ScadaBridge assembly.
private static bool IsScadaBridgeType(Type type) =>
type.Assembly.GetName().Name?.StartsWith(AssemblyPrefix, StringComparison.Ordinal) == true;
private const string AssemblyPrefix = "ZB.MOM.WW.ScadaBridge";
///
/// Every concrete Akka actor class in the shipped ScadaBridge assemblies. Test assemblies
/// are excluded deliberately — the guard's own deliberately-violating fixtures live there,
/// and a test-only actor is not part of any production composition root.
///
/// The production actor types, ordered by full name.
internal static IReadOnlyList ProductionActorTypes() =>
ProductionAssemblies()
.SelectMany(a => a.GetTypes())
.Where(t => typeof(ActorBase).IsAssignableFrom(t) && t is { IsAbstract: false, IsInterface: false })
.OrderBy(t => t.FullName, StringComparer.Ordinal)
.ToList();
///
/// Transitively loads and returns the shipped ScadaBridge assemblies.
///
///
/// Referenced assemblies load lazily, so enumerating AppDomain.CurrentDomain alone
/// would silently scan whichever subset the test run happened to touch — a guard that
/// quietly checks nothing. The reference graph is walked from the Host assembly instead,
/// and the caller asserts a floor on the actor count so a regression here fails loudly.
///
/// The distinct production ScadaBridge assemblies.
private static IReadOnlyList ProductionAssemblies()
{
var seen = new Dictionary(StringComparer.Ordinal);
var pending = new Stack();
pending.Push(typeof(Program).Assembly);
while (pending.Count > 0)
{
var assembly = pending.Pop();
var name = assembly.GetName().Name;
if (name is null
|| !name.StartsWith(AssemblyPrefix, StringComparison.Ordinal)
|| name.EndsWith(".Tests", StringComparison.Ordinal)
|| !seen.TryAdd(name, assembly))
{
continue;
}
foreach (var reference in assembly.GetReferencedAssemblies())
{
if (reference.Name?.StartsWith(AssemblyPrefix, StringComparison.Ordinal) != true
|| seen.ContainsKey(reference.Name))
{
continue;
}
try
{
pending.Push(Assembly.Load(reference));
}
catch (Exception ex) when (ex is FileNotFoundException or BadImageFormatException)
{
// A ScadaBridge assembly that is referenced but not deployed alongside the
// test binaries cannot contribute actors to this run either.
}
}
}
return seen.Values.ToList();
}
///
/// Collects the service and implementation types registered with
/// in a composition root.
///
///
/// Both sides of a descriptor are collected: an actor can just as easily cache the
/// concrete EmailNotificationDeliveryAdapter as the INotificationDeliveryAdapter
/// it is registered as, and row 45 in fact cached the interface.
///
/// The composition root's service collection.
/// The scoped service and implementation types.
internal static IReadOnlySet ScopedTypes(IEnumerable services)
{
ArgumentNullException.ThrowIfNull(services);
var scoped = new HashSet();
foreach (var descriptor in services.Where(d => d.Lifetime == ServiceLifetime.Scoped))
{
scoped.Add(descriptor.ServiceType);
// ImplementationType throws on a keyed descriptor and vice versa, so branch.
var implementationType = descriptor.IsKeyedService
? descriptor.KeyedImplementationType
: descriptor.ImplementationType;
if (implementationType is not null)
{
scoped.Add(implementationType);
}
}
return scoped;
}
}