Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Enhancements:
- Minimally improved support for methods having `ref struct` parameter and return types, such as `Span<T>`: Intercepting such methods caused the runtime to throw `InvalidProgramException` and `NullReferenceException` due to forbidden conversions of `ref struct` values when transferring them into & out of `IInvocation` instances. To prevent these exceptions from being thrown, such values now get replaced with `null` in `IInvocation`, and with `default` values in return values and `out` arguments. When proceeding to a target, the target methods likewise receive such nullified values. (@stakx, #665)
- Restore ability on .NET 9 and later to save dynamic assemblies to disk using `PersistentProxyBuilder` (@stakx, #718)
- Configure SourceLink & `.snupkg` symbols package format (@Romfos, #722)
- Support for C# `with { ... }` expressions. Cloning a record proxy using `with` now produces another proxy of the same type (instead of an instance of the proxied type, as before). The cloning process can still be changed by intercepting the record class' `<Clone>$` method. (@stakx, #733)
- Dependencies were updated

Bugfixes:
Expand Down
25 changes: 25 additions & 0 deletions src/Castle.Core.Tests/Compatibility/IsExternalInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright 2004-2026 Castle Project - http://www.castleproject.org/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#if !NET5_0_OR_GREATER

namespace System.Runtime.CompilerServices
{
// required for records with primary constructors and/or `init` property accessors:
internal class IsExternalInit
{
}
}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2004-2026 Castle Project - http://www.castleproject.org/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#nullable enable

namespace Castle.DynamicProxy.Tests.Records
{
internal record class IdentifiableRecord
{
public string? Id { get; init; }
}
}
94 changes: 89 additions & 5 deletions src/Castle.Core.Tests/DynamicProxy.Tests/RecordsTestCase.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2004-2022 Castle Project - http://www.castleproject.org/
// Copyright 2004-2026 Castle Project - http://www.castleproject.org/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -12,12 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.

using Castle.DynamicProxy.Tests.Records;

using NUnit.Framework;

namespace Castle.DynamicProxy.Tests
{
using System;
using System.Reflection;

using Castle.DynamicProxy.Tests.Interceptors;
using Castle.DynamicProxy.Tests.Records;

using NUnit.Framework;

[TestFixture]
public class RecordsTestCase : BasePEVerifyTestCase
{
Expand All @@ -44,5 +48,85 @@ public void Can_proxy_record_derived_from_empty_generic_record()
{
_ = generator.CreateClassProxy<DerivedFromEmptyGenericRecord>(new StandardInterceptor());
}

[Test]
public void Cloning_proxied_record_preserves_proxy_type()
{
var proxy = generator.CreateClassProxy<EmptyRecord>();
var clonedProxy = proxy with { };
Assert.True(ProxyUtil.IsProxy(clonedProxy));
Assert.AreSame(proxy.GetType(), clonedProxy.GetType());
}

[Test]
public void Cloning_proxied_record_preserves_proxy_type_even_when_not_intercepting()
{
var proxy = generator.CreateClassProxy<EmptyRecord>(new ProxyGenerationOptions(new InterceptNothingHook()));
var clonedProxy = proxy with { };
Assert.True(ProxyUtil.IsProxy(clonedProxy));
Assert.AreSame(proxy.GetType(), clonedProxy.GetType());
}

[Test]
public void Can_intercept_clone_method()
{
var expectedClone = new DerivedFromEmptyRecord();
var interceptor = new WithCallbackInterceptor(invocation =>
{
if (invocation.Method.Name == "<Clone>$")
{
invocation.ReturnValue = expectedClone;
}
});
var proxy = generator.CreateClassProxy<EmptyRecord>(interceptor);
var actualClone = proxy with { };
Assert.AreSame(expectedClone, actualClone);
}

[Test]
public void Can_proceed_for_intercepted_clone_method()
{
var interceptor = new WithCallbackInterceptor(invocation =>
{
if (invocation.Method.Name == "<Clone>$")
{
invocation.Proceed();
}
});
var proxy = generator.CreateClassProxy<EmptyRecord>(interceptor);
var clonedProxy = proxy with { };
Assert.AreSame(proxy.GetType(), clonedProxy.GetType());
}

[Test]
public void Can_proceed_to_record_target()
{
var target = new IdentifiableRecord { Id = "target" };
var proxy = generator.CreateClassProxyWithTarget(target, new StandardInterceptor());
var clonedProxy = proxy with { };
Assert.False(clonedProxy == proxy);
Assert.False(ProxyUtil.IsProxy(clonedProxy));
Assert.True(clonedProxy == target);
}

[Test]
public void Can_proceed_to_record_target_proxy()
{
var target = generator.CreateClassProxy<IdentifiableRecord>() with { Id = "target" };
var proxy = generator.CreateClassProxyWithTarget(target, new StandardInterceptor());
var clonedProxy = proxy with { };
Assert.False(clonedProxy == proxy);
Assert.True(ProxyUtil.IsProxy(clonedProxy));
Assert.True(clonedProxy == target);
}

[Serializable]
public record class InterceptNothingHook : IProxyGenerationHook
{
public bool ShouldInterceptMethod(Type type, MethodInfo methodInfo) => false;

void IProxyGenerationHook.MethodsInspected() { }
void IProxyGenerationHook.NonProxyableMemberNotification(Type type, MemberInfo memberInfo) { }
}
}
}
160 changes: 160 additions & 0 deletions src/Castle.Core/DynamicProxy/Contributors/RecordCloningContributor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright 2004-2026 Castle Project - http://www.castleproject.org/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#nullable enable

namespace Castle.DynamicProxy.Contributors
{
using System;
using System.Reflection;

using Castle.DynamicProxy.Generators;
using Castle.DynamicProxy.Generators.Emitters;
using Castle.DynamicProxy.Generators.Emitters.SimpleAST;

internal sealed class RecordCloningContributor : ITypeContributor
{
private readonly Type targetType;
private readonly INamingScope namingScope;

private MethodInfo? baseCloneMethod;
private bool overridable;
private bool shouldIntercept;

public RecordCloningContributor(Type targetType, INamingScope namingScope)
{
this.targetType = targetType;
this.namingScope = namingScope;
}

public void CollectElementsToProxy(IProxyGenerationHook hook, MetaType model)
{
baseCloneMethod = targetType.GetMethod("<Clone>$", BindingFlags.Public | BindingFlags.Instance);
if (baseCloneMethod == null)
{
return;
}

var cloneMetaMethod = model.FindMethod(baseCloneMethod);
if (cloneMetaMethod != null)
{
// The target contributor may have chosen to generate interception code for this method.
// We override that decision here. This effectively renders `<Clone>$` uninterceptable,
// in favor of some default behavior provided by DynamicProxy. This may be a bad idea.
cloneMetaMethod.Ignore = true;
}

overridable = baseCloneMethod.IsVirtual && !baseCloneMethod.IsFinal;
shouldIntercept = overridable && hook.ShouldInterceptMethod(targetType, baseCloneMethod);
}

public void Generate(ClassEmitter @class)
{
if (baseCloneMethod == null) return;

ImplementCopyConstructor(@class, out var copyCtor);
ImplementCloneMethod(@class, copyCtor);
}

private void ImplementCopyConstructor(ClassEmitter @class, out ConstructorInfo copyCtor)
{
var other = new ArgumentReference(@class.TypeBuilder);
var copyCtorEmitter = @class.CreateConstructor(other);
var baseCopyCtor = targetType.GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, [targetType], null);

copyCtorEmitter.CodeBuilder.AddStatement(
new ConstructorInvocationStatement(
baseCopyCtor,
other));

foreach (var field in @class.GetAllFields())
{
if (field.Reference.IsStatic) continue;

copyCtorEmitter.CodeBuilder.AddStatement(
new AssignStatement(
field,
new FieldReference(
field.Reference,
other)));
}

copyCtorEmitter.CodeBuilder.AddStatement(ReturnStatement.Instance);

copyCtor = copyCtorEmitter.ConstructorBuilder;
}

private void ImplementCloneMethod(ClassEmitter @class, ConstructorInfo copyCtor)
{
if (shouldIntercept)
{
var cloneCallbackMethod = CreateCallbackMethod(@class, copyCtor);
var cloneMetaMethod = new MetaMethod(baseCloneMethod!, cloneCallbackMethod, true, true, true);
var invocationType = CreateInvocationType(@class, cloneMetaMethod, cloneCallbackMethod);

var cloneMethodGenerator = new MethodWithInvocationGenerator(
cloneMetaMethod,
@class.GetField("__interceptors"),
invocationType,
(c, m) => new TypeTokenExpression(@class.TypeBuilder),
@class.CreateMethod,
null);

cloneMethodGenerator.Generate(@class, namingScope);
}
else if (overridable)
{
var cloneMethodEmitter = @class.CreateMethod(
name: baseCloneMethod!.Name,
attrs: (baseCloneMethod.Attributes & MethodAttributes.MemberAccessMask) | MethodAttributes.ReuseSlot | MethodAttributes.HideBySig | MethodAttributes.Virtual,
returnType: baseCloneMethod.ReturnType, // no need to use covariant return type
argumentTypes: []);

cloneMethodEmitter.CodeBuilder.AddStatement(
new ReturnStatement(
new NewInstanceExpression(
copyCtor,
ThisExpression.Instance)));
}
}

private MethodInfo CreateCallbackMethod(ClassEmitter @class, ConstructorInfo copyCtor)
{
var callbackMethod = @class.CreateMethod(
name: baseCloneMethod!.Name + "_callback",
attrs: MethodAttributes.Public | MethodAttributes.HideBySig,
returnType: copyCtor.DeclaringType,
argumentTypes: Type.EmptyTypes);

callbackMethod.CodeBuilder.AddStatement(
new ReturnStatement(
new NewInstanceExpression(
copyCtor,
ThisExpression.Instance)));

return callbackMethod.MethodBuilder;
}

private Type CreateInvocationType(ClassEmitter @class, MetaMethod cloneMetaMethod, MethodInfo cloneCallbackMethod)
{
var generator = new InheritanceInvocationTypeGenerator(
targetType,
cloneMetaMethod,
cloneCallbackMethod,
null);

return generator.Generate(@class, namingScope).BuildType();
}
}
}
16 changes: 13 additions & 3 deletions src/Castle.Core/DynamicProxy/Generators/BaseClassProxyGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2004-2021 Castle Project - http://www.castleproject.org/
// Copyright 2004-2026 Castle Project - http://www.castleproject.org/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.

#nullable enable

namespace Castle.DynamicProxy.Generators
{
using System;
Expand All @@ -25,13 +27,13 @@ namespace Castle.DynamicProxy.Generators

internal abstract class BaseClassProxyGenerator : BaseProxyGenerator
{
protected BaseClassProxyGenerator(ModuleScope scope, Type targetType, Type[] interfaces, ProxyGenerationOptions options)
protected BaseClassProxyGenerator(ModuleScope scope, Type targetType, Type[]? interfaces, ProxyGenerationOptions options)
: base(scope, targetType, interfaces, options)
{
EnsureDoesNotImplementIProxyTargetAccessor(targetType, nameof(targetType));
}

protected abstract FieldReference TargetField { get; }
protected abstract FieldReference? TargetField { get; }

#if FEATURE_SERIALIZATION
protected abstract SerializableContributor GetSerializableContributor();
Expand All @@ -41,6 +43,8 @@ protected BaseClassProxyGenerator(ModuleScope scope, Type targetType, Type[] int

protected abstract ProxyTargetAccessorContributor GetProxyTargetAccessorContributor();

protected abstract RecordCloningContributor? GetRecordCloningContributor(INamingScope namingScope);

protected sealed override Type GenerateType(string name, INamingScope namingScope)
{
IEnumerable<ITypeContributor> contributors;
Expand Down Expand Up @@ -192,6 +196,12 @@ private IEnumerable<Type> GetTypeImplementerMapping(out IEnumerable<ITypeContrib
}
#endif

var recordCloningContributor = GetRecordCloningContributor(namingScope);
if (recordCloningContributor != null)
{
contributorsList.Add(recordCloningContributor);
}

var proxyTargetAccessorContributor = GetProxyTargetAccessorContributor();
contributorsList.Add(proxyTargetAccessorContributor);
try
Expand Down
Loading