diff --git a/src/EFCore.Proxies/Properties/ProxiesStrings.Designer.cs b/src/EFCore.Proxies/Properties/ProxiesStrings.Designer.cs index 4bcc8184623..b2365838d3d 100644 --- a/src/EFCore.Proxies/Properties/ProxiesStrings.Designer.cs +++ b/src/EFCore.Proxies/Properties/ProxiesStrings.Designer.cs @@ -67,6 +67,14 @@ public static string NonVirtualProperty(object? property, object? entityType) GetString("NonVirtualProperty", "0_property", "1_entityType"), property, entityType); + /// + /// Property '{entityType}.{property}' cannot be overridden by a proxy because its {accessor} is not visible to the proxy assembly. Make it public or protected, or add '[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]' to the assembly that declares the entity type (a strong-named assembly must specify the proxy assembly's public key). + /// + public static string NonVisibleProperty(object? property, object? entityType, object? accessor) + => string.Format( + GetString("NonVisibleProperty", nameof(property), nameof(entityType), nameof(accessor)), + property, entityType, accessor); + /// /// Unable to create proxy for '{entityType}' because proxies are not enabled. Call 'DbContextOptionsBuilder.UseChangeTrackingProxies' or 'DbContextOptionsBuilder.UseLazyLoadingProxies' to enable proxies. /// diff --git a/src/EFCore.Proxies/Properties/ProxiesStrings.resx b/src/EFCore.Proxies/Properties/ProxiesStrings.resx index 2d17ddbd509..09268e710dd 100644 --- a/src/EFCore.Proxies/Properties/ProxiesStrings.resx +++ b/src/EFCore.Proxies/Properties/ProxiesStrings.resx @@ -135,6 +135,9 @@ Property '{1_entityType}.{0_property}' is not virtual. 'UseChangeTrackingProxies' requires all entity types to be public, unsealed, have virtual properties, and have a public or protected constructor. 'UseLazyLoadingProxies' requires only the navigation properties be virtual. + + Property '{entityType}.{property}' cannot be overridden by a proxy because its {accessor} is not visible to the proxy assembly. Make it public or protected, or add '[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]' to the assembly that declares the entity type (a strong-named assembly must specify the proxy assembly's public key). + Unable to create proxy for '{entityType}' because proxies are not enabled. Call 'DbContextOptionsBuilder.UseChangeTrackingProxies' or 'DbContextOptionsBuilder.UseLazyLoadingProxies' to enable proxies. diff --git a/src/EFCore.Proxies/Proxies/Internal/ProxyBindingRewriter.cs b/src/EFCore.Proxies/Proxies/Internal/ProxyBindingRewriter.cs index 8d14b9ab1ed..1492c5100d5 100644 --- a/src/EFCore.Proxies/Proxies/Internal/ProxyBindingRewriter.cs +++ b/src/EFCore.Proxies/Proxies/Internal/ProxyBindingRewriter.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Castle.DynamicProxy; using Microsoft.EntityFrameworkCore.Internal; namespace Microsoft.EntityFrameworkCore.Proxies.Internal; @@ -92,6 +93,13 @@ public virtual void ProcessModelFinalizing( throw new InvalidOperationException( ProxiesStrings.NonVirtualProperty(navigationBase.Name, entityType.DisplayName())); } + + if (navigationBase.PropertyInfo.SetMethod is { } setMethod + && !IsVisibleToProxies(setMethod)) + { + throw new InvalidOperationException( + ProxiesStrings.NonVisibleProperty(navigationBase.Name, entityType.DisplayName(), "setter")); + } } if (_options.UseLazyLoadingProxies @@ -113,6 +121,16 @@ public virtual void ProcessModelFinalizing( ProxiesStrings.NonVirtualProperty(navigationBase.Name, entityType.DisplayName())); } } + else if (!IsVisibleToProxies(navigationBase.PropertyInfo.GetMethod!)) + { + // The proxy cannot override the getter, so the navigation would silently never be loaded. + if (!_options.IgnoreNonVirtualNavigations + && navigationBase is not INavigation { ForeignKey.IsOwnership: true }) + { + throw new InvalidOperationException( + ProxiesStrings.NonVisibleProperty(navigationBase.Name, entityType.DisplayName(), "getter")); + } + } else { navigationBase.SetPropertyAccessMode(PropertyAccessMode.Field); @@ -205,4 +223,12 @@ public virtual void ProcessModelFinalizing( } } } + + // Defer to Castle so that the rules match exactly what the proxy generator can override, including + // [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]. Castle also requires the declaring type itself to be accessible, + // but an inaccessible entity type already fails with Castle's own error when a proxy is created, so only the visibility of + // the accessor is validated here. + private static bool IsVisibleToProxies(MethodInfo method) + => !ProxyUtil.IsAccessible(method.DeclaringType!) + || ProxyUtil.IsAccessible(method, out _); } diff --git a/test/EFCore.Proxies.Tests/ProxyAccessibilityTests.cs b/test/EFCore.Proxies.Tests/ProxyAccessibilityTests.cs new file mode 100644 index 00000000000..0ea74921dfc --- /dev/null +++ b/test/EFCore.Proxies.Tests/ProxyAccessibilityTests.cs @@ -0,0 +1,504 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection.Emit; +using System.Runtime.CompilerServices; +using Microsoft.EntityFrameworkCore.Internal; + +namespace Microsoft.EntityFrameworkCore; + +/// +/// Tests for https://github.com/dotnet/efcore/issues/32352. Proxies can only override members that are visible to the +/// Castle proxy assembly (DynamicProxyGenAssembly2). A navigation whose accessor is not visible is silently never +/// intercepted, so model validation should reject it instead. +/// The entity types are emitted at runtime so that each accessibility level can be tested with and without +/// InternalsVisibleTo("DynamicProxyGenAssembly2") on the declaring assembly. +/// +public class ProxyAccessibilityTests +{ + // Accessors that the proxy can always override. + public static IEnumerable AlwaysVisible() + { + foreach (var grant in new[] { false, true }) + { + yield return [MethodAttributes.Public, grant]; + yield return [MethodAttributes.Family, grant]; + yield return [MethodAttributes.FamORAssem, grant]; + } + } + + // Accessors that the proxy can only override when the assembly grants access to DynamicProxyGenAssembly2. + public static IEnumerable OnlyVisibleWithGrant() + { + yield return [MethodAttributes.Assembly]; + yield return [MethodAttributes.FamANDAssem]; + } + + // Accessors that can never be overridden, with or without a grant. + public static IEnumerable NeverVisible() + { + foreach (var grant in new[] { false, true }) + { + yield return [MethodAttributes.Private, grant]; + } + } + + [Theory] + [MemberData(nameof(AlwaysVisible))] + public void Lazy_loading_accepts_navigation_whose_getter_is_always_visible(MethodAttributes getter, bool grant) + { + var types = EntityFactory.Create(getter, MethodAttributes.Public, grant); + + using var context = new AccessibilityContext(types, useLazyLoading: true); + + AssertNavigationMapped(context, types); + } + + [Theory] + [MemberData(nameof(OnlyVisibleWithGrant))] + public void Lazy_loading_accepts_navigation_whose_getter_is_visible_through_InternalsVisibleTo(MethodAttributes getter) + { + var types = EntityFactory.Create(getter, MethodAttributes.Public, grantInternalsToProxies: true); + + using var context = new AccessibilityContext(types, useLazyLoading: true); + + AssertNavigationMapped(context, types); + } + + [Theory] + [MemberData(nameof(OnlyVisibleWithGrant))] + public void Lazy_loading_throws_if_navigation_getter_is_not_visible_to_proxies(MethodAttributes getter) + { + var types = EntityFactory.Create(getter, MethodAttributes.Public, grantInternalsToProxies: false); + + using var context = new AccessibilityContext(types, useLazyLoading: true); + + AssertThrowsNotVisible(context, types, "getter"); + } + + [Theory] + [MemberData(nameof(NeverVisible))] + public void Lazy_loading_throws_if_navigation_getter_can_never_be_visible_to_proxies(MethodAttributes getter, bool grant) + { + var types = EntityFactory.Create(getter, MethodAttributes.Public, grant); + + using var context = new AccessibilityContext(types, useLazyLoading: true); + + AssertThrowsNotVisible(context, types, "getter"); + } + + [Theory] + [MemberData(nameof(OnlyVisibleWithGrant))] + public void Lazy_loading_throws_if_collection_navigation_getter_is_not_visible_to_proxies(MethodAttributes getter) + { + var types = EntityFactory.Create(getter, MethodAttributes.Public, grantInternalsToProxies: false, collection: true); + + using var context = new AccessibilityContext(types, useLazyLoading: true); + + AssertThrowsNotVisible(context, types, "getter"); + } + + [Theory] + [MemberData(nameof(OnlyVisibleWithGrant))] + public void Lazy_loading_accepts_collection_navigation_whose_getter_is_visible_through_InternalsVisibleTo(MethodAttributes getter) + { + var types = EntityFactory.Create(getter, MethodAttributes.Public, grantInternalsToProxies: true, collection: true); + + using var context = new AccessibilityContext(types, useLazyLoading: true); + + AssertNavigationMapped(context, types); + } + + [Theory] + [MemberData(nameof(OnlyVisibleWithGrant))] + public void Lazy_loading_does_not_throw_for_invisible_navigation_getter_if_non_virtual_navigations_are_ignored( + MethodAttributes getter) + { + var types = EntityFactory.Create(getter, MethodAttributes.Public, grantInternalsToProxies: false); + + using var context = new AccessibilityContext(types, useLazyLoading: true, ignoreNonVirtualNavigations: true); + + AssertNavigationMapped(context, types); + } + + [Fact] + public void Lazy_loading_does_not_throw_for_invisible_navigation_if_lazy_loading_is_disabled_for_it() + { + var types = EntityFactory.Create(MethodAttributes.Assembly, MethodAttributes.Public, grantInternalsToProxies: false); + + using var context = new AccessibilityContext( + types, useLazyLoading: true, + configure: b => b.Entity(types.Principal).Navigation(EntityFactory.NavigationName).EnableLazyLoading(false)); + + AssertNavigationMapped(context, types); + } + + // Only the getter needs to be overridden for lazy-loading, so a less visible setter must keep working. This is the common + // "{ get; internal set; }" pattern, including for collections. + [Theory] + [InlineData(MethodAttributes.Assembly, false)] + [InlineData(MethodAttributes.Private, false)] + [InlineData(MethodAttributes.FamANDAssem, false)] + [InlineData(MethodAttributes.Assembly, true)] + public void Lazy_loading_accepts_navigation_with_public_getter_and_invisible_setter(MethodAttributes setter, bool collection) + { + var types = EntityFactory.Create(MethodAttributes.Public, setter, grantInternalsToProxies: false, collection); + + using var context = new AccessibilityContext(types, useLazyLoading: true); + + AssertNavigationMapped(context, types); + } + + // Castle also requires the declaring type to be accessible, but an inaccessible entity type already fails when a proxy is + // created, so model validation must not start rejecting it with a misleading message about the (public) property. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Does_not_throw_for_public_accessors_on_inaccessible_entity_types(bool lazyLoading) + { + var types = EntityFactory.Create( + MethodAttributes.Public, MethodAttributes.Public, grantInternalsToProxies: false, publicTypes: false); + + using var context = new AccessibilityContext(types, useLazyLoading: lazyLoading, useChangeDetection: !lazyLoading); + + AssertNavigationMapped(context, types); + } + + // The case that must keep working: an internal virtual navigation in an assembly that grants access to the proxy assembly. + // Proves the navigation really is intercepted and lazily loaded, not just that the model builds. + [Theory] + [InlineData(MethodAttributes.Assembly)] + [InlineData(MethodAttributes.FamORAssem)] + [InlineData(MethodAttributes.Public)] + public void Lazy_loading_actually_loads_navigation_when_accessor_is_visible_to_proxies(MethodAttributes getter) + { + var types = EntityFactory.Create(getter, MethodAttributes.Public, grantInternalsToProxies: true); + var databaseName = Guid.NewGuid().ToString(); + + using (var context = new AccessibilityContext(types, useLazyLoading: true, databaseName: databaseName)) + { + var principal = Activator.CreateInstance(types.Principal)!; + var dependent = Activator.CreateInstance(types.Dependent)!; + EntityFactory.SetNavigation(principal, dependent); + + context.Add(principal); + context.SaveChanges(); + } + + using (var context = new AccessibilityContext(types, useLazyLoading: true, databaseName: databaseName)) + { + var principal = context.Query(types.Principal).Single(); + + Assert.Contains("Proxy", principal.GetType().Name); + Assert.NotNull(EntityFactory.GetNavigation(principal)); + } + } + + // Documents the symptom from the issue: without access for the proxy assembly the navigation is never intercepted, so it + // is never loaded. Only reachable by opting out of validation. + [Theory] + [InlineData(MethodAttributes.Assembly)] + [InlineData(MethodAttributes.FamANDAssem)] + public void Navigation_is_never_loaded_when_getter_is_not_visible_to_proxies_and_validation_is_suppressed(MethodAttributes getter) + { + var types = EntityFactory.Create(getter, MethodAttributes.Public, grantInternalsToProxies: false); + var databaseName = Guid.NewGuid().ToString(); + + using (var context = new AccessibilityContext( + types, useLazyLoading: true, ignoreNonVirtualNavigations: true, databaseName: databaseName)) + { + var principal = Activator.CreateInstance(types.Principal)!; + EntityFactory.SetNavigation(principal, Activator.CreateInstance(types.Dependent)!); + + context.Add(principal); + context.SaveChanges(); + } + + using (var context = new AccessibilityContext( + types, useLazyLoading: true, ignoreNonVirtualNavigations: true, databaseName: databaseName)) + { + var principal = context.Query(types.Principal).Single(); + + Assert.Null(EntityFactory.GetNavigation(principal)); + } + } + + // Change-tracking proxies must override the setter, so it is the setter that must be visible. + [Theory] + [MemberData(nameof(AlwaysVisible))] + public void Change_tracking_accepts_navigation_whose_setter_is_always_visible(MethodAttributes setter, bool grant) + { + var types = EntityFactory.Create(MethodAttributes.Public, setter, grant); + + using var context = new AccessibilityContext(types, useChangeDetection: true); + + AssertNavigationMapped(context, types); + } + + [Theory] + [MemberData(nameof(OnlyVisibleWithGrant))] + public void Change_tracking_accepts_navigation_whose_setter_is_visible_through_InternalsVisibleTo(MethodAttributes setter) + { + var types = EntityFactory.Create(MethodAttributes.Public, setter, grantInternalsToProxies: true); + + using var context = new AccessibilityContext(types, useChangeDetection: true); + + AssertNavigationMapped(context, types); + } + + [Theory] + [MemberData(nameof(OnlyVisibleWithGrant))] + public void Change_tracking_throws_if_navigation_setter_is_not_visible_to_proxies(MethodAttributes setter) + { + var types = EntityFactory.Create(MethodAttributes.Public, setter, grantInternalsToProxies: false); + + using var context = new AccessibilityContext(types, useChangeDetection: true); + + AssertThrowsNotVisible(context, types, "setter"); + } + + [Theory] + [MemberData(nameof(NeverVisible))] + public void Change_tracking_throws_if_navigation_setter_can_never_be_visible_to_proxies(MethodAttributes setter, bool grant) + { + var types = EntityFactory.Create(MethodAttributes.Public, setter, grant); + + using var context = new AccessibilityContext(types, useChangeDetection: true); + + AssertThrowsNotVisible(context, types, "setter"); + } + + // "{ get; internal set; }" on a collection used to build and silently not be tracked. + [Theory] + [MemberData(nameof(OnlyVisibleWithGrant))] + public void Change_tracking_throws_if_collection_navigation_setter_is_not_visible_to_proxies(MethodAttributes setter) + { + var types = EntityFactory.Create(MethodAttributes.Public, setter, grantInternalsToProxies: false, collection: true); + + using var context = new AccessibilityContext(types, useChangeDetection: true); + + AssertThrowsNotVisible(context, types, "setter"); + } + + [Theory] + [MemberData(nameof(OnlyVisibleWithGrant))] + public void Change_tracking_accepts_collection_navigation_whose_setter_is_visible_through_InternalsVisibleTo( + MethodAttributes setter) + { + var types = EntityFactory.Create(MethodAttributes.Public, setter, grantInternalsToProxies: true, collection: true); + + using var context = new AccessibilityContext(types, useChangeDetection: true); + + AssertNavigationMapped(context, types); + } + + // Proves that the setter really is intercepted, and not only that the model builds. + [Theory] + [InlineData(MethodAttributes.Assembly)] + [InlineData(MethodAttributes.FamORAssem)] + [InlineData(MethodAttributes.Public)] + public void Change_tracking_detects_navigation_change_when_setter_is_visible_to_proxies(MethodAttributes setter) + { + var types = EntityFactory.Create(MethodAttributes.Public, setter, grantInternalsToProxies: true); + var databaseName = Guid.NewGuid().ToString(); + + using (var context = new AccessibilityContext(types, useChangeDetection: true, databaseName: databaseName)) + { + var principal = context.CreateProxy(types.Principal); + EntityFactory.SetNavigation(principal, context.CreateProxy(types.Dependent)); + + context.Add(principal); + context.SaveChanges(); + } + + using (var context = new AccessibilityContext(types, useChangeDetection: true, databaseName: databaseName)) + { + var principal = context.Query(types.Principal).Single(); + var newDependent = context.CreateProxy(types.Dependent); + context.Add(newDependent); + + Assert.Equal(EntityState.Unchanged, context.Entry(principal).State); + + EntityFactory.SetNavigation(principal, newDependent); + + // No call to DetectChanges: the change can only be known if the proxy intercepted the setter. + Assert.Equal(EntityState.Modified, context.Entry(principal).State); + } + } + + private static void AssertNavigationMapped(DbContext context, EntityFactory.Types types) + => Assert.NotNull(context.Model.FindEntityType(types.Principal)!.FindNavigation(EntityFactory.NavigationName)); + + private static void AssertThrowsNotVisible(DbContext context, EntityFactory.Types types, string accessor) + => Assert.Equal( + ProxiesStrings.NonVisibleProperty(EntityFactory.NavigationName, types.Principal.Name, accessor), + Assert.Throws(() => context.Model).Message); + + private sealed class AccessibilityContext( + EntityFactory.Types types, + bool useLazyLoading = false, + bool useChangeDetection = false, + bool ignoreNonVirtualNavigations = false, + string? databaseName = null, + Action? configure = null) + : DbContext + { + private static readonly InMemoryDatabaseRoot _dbRoot = new(); + + private readonly string _databaseName = databaseName ?? Guid.NewGuid().ToString(); + + private readonly IServiceProvider _internalServiceProvider = new ServiceCollection() + .AddEntityFrameworkInMemoryDatabase() + .AddEntityFrameworkProxies() + .BuildServiceProvider(validateScopes: true); + + public IQueryable Query(Type entityType) + => (IQueryable)typeof(DbContext).GetMethods() + .Single(m => m.Name == nameof(Set) && m.IsGenericMethodDefinition && m.GetParameters().Length == 0) + .MakeGenericMethod(entityType) + .Invoke(this, null)!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (useLazyLoading) + { + optionsBuilder.UseLazyLoadingProxies(b => + { + if (ignoreNonVirtualNavigations) + { + b.IgnoreNonVirtualNavigations(); + } + }); + } + + if (useChangeDetection) + { + optionsBuilder.UseChangeTrackingProxies(); + } + + optionsBuilder + .UseInternalServiceProvider(_internalServiceProvider) + .UseInMemoryDatabase(_databaseName, _dbRoot); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Non-public navigations are not found by convention, so they are always configured explicitly. + var entityTypeBuilder = modelBuilder.Entity(types.Principal); + + if (types.IsCollection) + { + entityTypeBuilder.HasMany(types.Dependent, EntityFactory.NavigationName).WithOne(); + } + else + { + entityTypeBuilder.HasOne(types.Dependent, EntityFactory.NavigationName).WithMany(); + } + + configure?.Invoke(modelBuilder); + } + } + + private static class EntityFactory + { + public const string NavigationName = "Nav"; + + public sealed record Types(Type Principal, Type Dependent, bool IsCollection); + + public static Types Create( + MethodAttributes getter, + MethodAttributes setter, + bool grantInternalsToProxies, + bool collection = false, + bool publicTypes = true) + { + var assemblyName = "ProxyAccessibility_" + Guid.NewGuid().ToString("N"); + var assembly = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(assemblyName), AssemblyBuilderAccess.Run); + + if (grantInternalsToProxies) + { + assembly.SetCustomAttribute( + new CustomAttributeBuilder( + typeof(InternalsVisibleToAttribute).GetConstructor([typeof(string)])!, + ["DynamicProxyGenAssembly2"])); + } + + var module = assembly.DefineDynamicModule(assemblyName); + var typeVisibility = (publicTypes ? TypeAttributes.Public : TypeAttributes.NotPublic) | TypeAttributes.Class; + + var dependent = module.DefineType("Dependent", typeVisibility); + dependent.DefineDefaultConstructor(MethodAttributes.Public); + DefineProperty(dependent, "Id", typeof(int), MethodAttributes.Public, MethodAttributes.Public); + var dependentType = dependent.CreateType(); + + var principal = module.DefineType("Principal", typeVisibility); + principal.DefineDefaultConstructor(MethodAttributes.Public); + DefineProperty(principal, "Id", typeof(int), MethodAttributes.Public, MethodAttributes.Public); + DefineProperty( + principal, + NavigationName, + collection ? typeof(ICollection<>).MakeGenericType(dependentType) : dependentType, + getter, + setter); + var principalType = principal.CreateType(); + + return new Types(principalType, dependentType, collection); + } + + public static object? GetNavigation(object principal) + => FindNavigation(principal).GetValue(principal); + + public static void SetNavigation(object principal, object dependent) + => FindNavigation(principal).SetValue(principal, dependent); + + private const BindingFlags AnyInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + // The instance may be a proxy subclass, which cannot see the non-public accessors declared on its base type. + private static PropertyInfo FindNavigation(object principal) + { + for (var type = principal.GetType(); type != null; type = type.BaseType) + { + var property = type.GetProperty(NavigationName, AnyInstance | BindingFlags.DeclaredOnly); + + // A proxy redeclares the property with only the accessors it could override, so skip incomplete declarations. + if (property?.GetGetMethod(nonPublic: true) != null + && property.GetSetMethod(nonPublic: true) != null) + { + return property; + } + } + + throw new InvalidOperationException($"Navigation '{NavigationName}' not found."); + } + + private static void DefineProperty( + TypeBuilder type, + string name, + Type propertyType, + MethodAttributes getterVisibility, + MethodAttributes setterVisibility) + { + var field = type.DefineField("_" + name, propertyType, FieldAttributes.Private); + var property = type.DefineProperty(name, PropertyAttributes.None, propertyType, null); + + const MethodAttributes common = MethodAttributes.Virtual | MethodAttributes.NewSlot | MethodAttributes.HideBySig + | MethodAttributes.SpecialName; + + var get = type.DefineMethod("get_" + name, getterVisibility | common, propertyType, Type.EmptyTypes); + var getIl = get.GetILGenerator(); + getIl.Emit(OpCodes.Ldarg_0); + getIl.Emit(OpCodes.Ldfld, field); + getIl.Emit(OpCodes.Ret); + + var set = type.DefineMethod("set_" + name, setterVisibility | common, typeof(void), [propertyType]); + var setIl = set.GetILGenerator(); + setIl.Emit(OpCodes.Ldarg_0); + setIl.Emit(OpCodes.Ldarg_1); + setIl.Emit(OpCodes.Stfld, field); + setIl.Emit(OpCodes.Ret); + + property.SetGetMethod(get); + property.SetSetMethod(set); + } + } +}