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