[API Proposal]: `UnsafeAccessorTypeAttribute` for static or private type access
AaronRobinsonMSFT opened this issue ยท 29 comments
Background and motivation
The UnsafeAccessorAttribute
mechanism was designed to provide access to a non-visible static or instance member (that is, method or field). There are however limitations with this design when involving static
types or accessing members on non-visible types. This attribute helps bridge that gap.
Consider the following two scenarios involving methods. Note that fields suffer from the same issue.
Scenario 1 - Private type
// Assembly A
private class C
{
private static int Method(int a) { ... }
}
// Assembly B
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method")]
static extern int CallMethod(??? c, int a); // One cannot write type C due to visibility.
Scenario 2 - Static type
// Assembly A
public static class C
{
private static int Method(int a) { ... }
}
// Assembly B
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method")]
static extern int CallMethod(??? c, int a); // A static class cannot be used as a parameter in a signature.
Scenario 3 - Private class parameters
// Assembly A
public class C
{
private class D { }
private static int Method(D d) { ... }
}
// Assembly B
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method")]
static extern int CallMethod(??? d); // Unable express D type as a parameter.
API Proposal
The following attribute would accept a fully qualified or partially qualified type name to use for member look-up.
namespace System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.ReturnValue, AllowMultiple = false, Inherited = false)]
public sealed class UnsafeAccessorTypeAttribute : Attribute
{
/// <summary>
/// Instantiates an <see cref="UnsafeAccessorTypeAttribute"/> providing access to a type supplied by <paramref name="typeName"/>.
/// </summary>
/// <param name="typeName">A fully qualified or partially qualified type name.</param>
public UnsafeAccessorTypeAttribute(string typeName)
{
TypeName = typeName;
}
/// <summary>
/// Fully qualified or partially qualified type name to target.
/// </summary>
public string TypeName { get; init; }
}
API Usage
Scenario 1 - Private type
// Assembly A
private class C
{
private static int Method(int a) { ... }
}
// Assembly B
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method")]
[UnsafeAccessorType("C, A, Version=1.0.0.0, Culture=neutral")] // Look up type here as opposed to signature
static extern int CallMethod(int a);
Scenario 2 - Static type
// Assembly A
public static class C
{
private static int Method(int a) { ... }
}
// Assembly B
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method")]
[UnsafeAccessorType("C, A")] // Look up type here as opposed to signature
static extern int CallMethod(int a);
Scenario 3 - Private class parameters
// Assembly A
public class C
{
private class D { }
private static int Method(D d) { ... }
}
// Assembly B
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method")]
static extern int CallMethod([UnsafeAccessorType("C+D, A")] object d); // Use attribute to look up type
Alternative Designs
Expand the UnsafeAccessorAttribute
attribute to have an optional type target field/property. Note This API option wouldn't address scenario (3).
namespace System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public sealed class UnsafeAccessorAttribute : Attribute
{
+ /// <summary>
+ /// Fully qualified or partially qualified type name to target.
+ /// </summary>
+ public string TargetTypeName { get; set; }
}
Risks
No response
Tagging subscribers to this area: @dotnet/area-system-runtime-compilerservices
See info in area-owners.md if you want to be subscribed.
Issue Details
Background and motivation
The UnsafeAccessorAttribute
mechanism was designed to provide access to a non-visible static or instance member (that is, method or field). There are however limitations with this design when involving static
types or accessing members on non-visible types. This attribute helps bridge that gap.
Consider the following two scenarios involving methods. Note that fields suffer from the same issue.
Scenario 1 - Private type
// Assembly A
private class C
{
private static int Method(int a) { ... }
}
// Assembly B
[UnsafeAccessor("Method")]
static extern int CallMethod(??? c, int a); // One cannot write type C due to visibility.
Scenario 2 - Static type
// Assembly A
public static class C
{
private static int Method(int a) { ... }
}
// Assembly B
[UnsafeAccessor("Method")]
static extern int CallMethod(??? c, int a); // A static class cannot be used as a parameter in a signature.
API Proposal
The following attribute would accept a fully qualified or partially qualified type name to use for member look-up. This attribute would only be referenced if the UnsafeAccessorAttribute
kind type was UnsafeAccessorKind.StaticField
or UnsafeAccessorKind.StaticMethod
. For any other value of UnsafeAccessorKind
, this attribute would be ignored.
namespace System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public sealed class UnsafeAccessorTypeAttribute : Attribute
{
/// <summary>
/// Instantiates an <see cref="UnsafeAccessorTypeAttribute"/> providing access to a type supplied by <paramref name="typeName"/>.
/// </summary>
/// <param name="typeName">A fully qualified or partially qualified type name.</param>
public UnsafeAccessorTypeAttribute(string typeName)
{
TypeName = typeName;
}
/// <summary>
/// Fully qualified or partially qualified type name to target.
/// </summary>
public string TypeName { get; init; }
}
API Usage
Scenario 1 - Private type
// Assembly A
private class C
{
private static int Method(int a) { ... }
}
// Assembly B
[UnsafeAccessor("Method")]
[UnsafeAccessorType("C, A, Version=1.0.0.0, Culture=neutral")] // Look up type here as opposed to signature
static extern int CallMethod(int a);
Scenario 2 - Static type
// Assembly A
public static class C
{
private static int Method(int a) { ... }
}
// Assembly B
[UnsafeAccessor("Method")]
[UnsafeAccessorType("C, A")] // Look up type here as opposed to signature
static extern int CallMethod(int a);
Alternative Designs
Expand the UnsafeAccessorAttribute
attribute to have an optional type target field/property.
It could be possible to permit UnsafeAccessorKind.Constructor
when the target type is a sub-class of a visible type. Is there a scenario where this is practical?
Risks
No response
Author: | AaronRobinsonMSFT |
---|---|
Assignees: | - |
Labels: |
|
Milestone: | - |
What's the downside to:
Expand the UnsafeAccessorAttribute attribute to have an optional type target field/property.
? That seems cleaner and easier to reason about than needing a second attribute.
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public sealed class UnsafeAccessorTypeAttribute : Attribute
The idea in #81741 (comment) was that this attribute is applied to parameter and return types. We need to be able to assign the private type for each parameter and return type to be able to call arbitrary methods with non-visible types in the signature.
internal class A
{
private class B
{
}
private class C
{
}
// How can one call this method using `UnsafeAccessor`?
private void M(B a, C b)
{
}
}
The idea in #81741 (comment) was that this attribute is applied to parameter and return types.
Ah. I was focusing too much on the static
scenario. Expanding the target doesn't address all the issues. I'll clarify that in an example.
The idea in #81741 (comment) was that this attribute is applied to parameter and return types
What would the full signature of the unsafe accessor method for C.M look like in that case?
The idea in #81741 (comment) was that this attribute is applied to parameter and return types
What would the full signature of the method look like on that case?
Something like the following.
/// Assembly NonVisibleTypes.dll
internal class A
{
private class B
{
}
private class C
{
}
// How can one call this method using `UnsafeAccessor`?
private static void M(B a, C b)
{
}
}
// Consuming assembly
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "M")]
[UnsafeAccessorType("A, NonVisibleTypes")]
static extern void CallMethod(
[UnsafeAccessorType("A+B, NonVisibleTypes")] object a,
[UnsafeAccessorType("A+C, NonVisibleTypes")] object b);
Nit: This is instance method so you need to have the this
pointer in the signature.
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "M")]
static extern void CallMethod(
[UnsafeAccessorType("A, NonVisibleTypes")] object @this,
[UnsafeAccessorType("A+B, NonVisibleTypes")] object a,
[UnsafeAccessorType("A+C, NonVisibleTypes")] object b);
(This is for my example that has instance method.)
Nit: This is instance method so you need to have the
this
pointer in the signature.[UnsafeAccessor("M")] static extern void CallMethod( [UnsafeAccessorType("A, NonVisibleTypes")] object @this, [UnsafeAccessorType("A+B, NonVisibleTypes")] object a, [UnsafeAccessorType("A+C, NonVisibleTypes")] object b);
Yep. Let me update the examples. I've been a bit loose here.
We will need to decide whether the implementation should do the casts from object to the actual type as regular throwing casts or as unsafe casts. There is a good argument that can be made for either option.
regular throwing casts or as unsafe casts
If this is for highest performance scenarios, I think the "unsafe casts" is what we want. The obvious downside here is destabilizing the runtime and creating painful bugs to hunt down. I think the question is do we expect users of the UnsafeAccessor
API to directly expose these private accessor APIs from their public API surface. If so, which would be sad, then we should go with the throwing casts. If we expect "unsafe" to mean that and let people get into trouble, then we can do the unsafe cast.
Will you be able to call methods which take private structs / use the types in generics? If so what type would you write?
// Assembly A
public class C
{
private struct D { }
private static int Method1(D d) { ... }
private static int Method2(ref D d) { ... }
private static int Method3(delegate* managed<List<D>, D*, TypedReference*, void> d) { ... }
private static int Method4(delegate* managed<in D, void> d) { ... }
private static int Method4(delegate* managed<ref D, void> d) { ... } //to throw off resolution
}
// Assembly B
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method1")]
static extern int CallMethod1(??? c, int a);
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method2")]
static extern int CallMethod2(??? c, int a);
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method3")]
static extern int CallMethod3(??? c, int a);
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method4")]
static extern int CallMethod4(??? c, int a);
My guess would be that all of the following would be allowed:
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method1")]
static extern int CallMethod1([UnsafeAccessorType("C+D, A")] ref byte c, int a); //allowed since D is a struct
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method2")]
static extern int CallMethod2([UnsafeAccessorType("C+D&, A")] ref byte c, int a); //allowed since parameter is a ref
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method1")]
static extern int CallMethod1_Alt([UnsafeAccessorType("C+D, A")] TypedReference c, int a); //allowed for any type of parameter which you can take a TypedReference to - unchecked type?
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method2")]
static extern int CallMethod2_Alt([UnsafeAccessorType("C+D&, A")] TypedReference c, int a); //allowed for any type of parameter which you can take a TypedReference to - unchecked type since it's a ref
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method3")]
static extern int CallMethod3([UnsafeAccessorType("What atrocity would go here?")] void* c, int a); //allowed since it takes a pointer
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method4")]
static extern int CallMethod4([UnsafeAccessorType("Whatever goes here for delegate* managed<in D, void>)")] void* d, int a); //could we allow specifying modifiers with this? this way it could still be used in any cases of ambiguity, e.g. between fn pointer overloads but when the type has some type parameter of a private type.
regular throwing casts or as unsafe casts
If this is for highest performance scenarios, I think the "unsafe casts" is what we want.
If no validation is being performed, what benefit is the attribute providing? Presumably we're not attempting to provide any kind of overload resolution; without that or validation, what purpose does providing a type name serve?
If no validation is being performed, what benefit is the attribute providing?
Member resolution pertaining to overloads would be one.
Presumably we're not attempting to provide any kind of overload resolution;
We do perform a signature look up on the matching member. For example, in the following scenario we would select the proper M()
.
public class A
{
private class B() {}
private class C() {}
private void M(B b) { }
private void M(C b) { }
}
Presumably we're not attempting to provide any kind of overload resolution
We do perform a signature look up on the matching member.
Overload resolution is insanely complicated. What rules are we using? Are we matching C# overload resolution rules? Do we layer on top of that rules pertaining to things that don't exist in C#, like overloading on return type?
Or is this not really overload resolution and we're just requiring every type to match 100% with its counterpart, i.e. no base types, no derived types, etc.? If so, I understand now.
Overload resolution is insanely complicated.
Or is this not really overload resolution and we're just requiring every type to match 100% with its counterpart, i.e. no base types, no derived types, etc.? If so, I understand now.
The latter. We aren't performing the complete .NET overload resolution logic at all. We focus on the specific type, don't walk the type hierarchy, and match on full .NET signature (minus custom modifiers). If we detect ambiguity we perform the look-up again but include custom modifiers. If we still detect an ambiguity, then we throw an AmbiguousMatchException
.
Ambiguous test:
I'm not seeing a way to manage fields of internal types, would it be possible to also support:
Referenced assembly
public struct A
{
private B _b;
}
internal struct B
{
private C _c;
}
internal struct C;
Consuming assembly
// Get a field of a private type, from a public type instance
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_b")]
[return: UnsafeAccessorType("B, AssemblyName")]
public static extern ref byte GetB(ref A a);
// Get a field of a private type, from a private type instance
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_c")]
[return: UnsafeAccessorType("C, AssemblyName")]
public static extern ref byte GetC([UnsafeAccessorType("B, AssemblyName")] ref byte b);
Return type can always be ref byte
and then people can reinterpret as needed (or pass to another unsafe accessor).
Params of reference types can be object
instead of ref byte
.
This is already called out a bit in #90081 (comment), but I think it's worth asking explicitly. How will this attribute work with generics (once we support UnsafeAccessorAttribute
on open generic types).
// Module1
internal class G<K> {
internal List<K> GetElements() {... };
};
// Module2
[UnsafeAccessor(UnsafeAccessorKind.Field, Name="GetElements")]
[UnsafeAccessorType("G`1, Module2")] // is this ok?
//[UnsafeAccessorType("G`1<!!0>, Module2")] // or do we need to spell out the instantiation with the !!0 ("T") mvar below?
[return: UnsafeAccessorType("System.Collections.Generic.List<!!0>, System.Runtime")] // how to specify the method return type?
object GetElementsAccessor<T>();
A fully qualified or partially qualified type name.
What will be the scope for type parameters? Or do we use the !!digit
syntax from IL? (presumably the scope is the unsafe accessor method declaration, so ELEMENT_TYPE_MVAR ranging over the generic parameters of the unsafe accessor generic method)
(I think methods are simpler but for fields we might also need to be explicit about return types and the type of self (for generic structs) being byref?)
How about using compiler preprocessing directives instead?
A directive could tell the compiler to completely turn off visibility checking for everything. The compiler would treat it as if all assemblies had used InternalsVisibleToAttribute
, but with the addition that private and protected members are also exposed.
Some might argue that this is dangerous, but so is Reflection, UnsafeAccessorAttribute
, and a lot of other things. I say "let's go all in" and make everything possible. Libraries can't depend on internals for security in any case, and if some private method is removed in the library, it's on the developer that accessed it anyway to work around it.
The biggest problem I can see is how to "expose" structs from other assemblies. Even wrapping them would be a problem for ref structs, I think.
I called the directive pragma internals
in my example below.
Maybe it should also be possible to only enable visibility for some things, so that the developer using the feature doesn't get totally swamped, but that can be done with the directive too.
E.g. #pragma internals visible System.IO.Path
or #pragma internals visible protected,internal
.
Of course, if this is implemented, it would make both UnsafeAccessorAttribute
andUnsafeAccessorTypeAttribute
unnecessary.
Edit: UnsafeAccessorAttribute
would still be useful, since it can be used to access compiler generated fields from primary constructors and properties.
// Module1
namespace Module1;
struct Matrix3x3
{
public Vector3 R1, R2, R3;
...
}
internal ref struct A
{
public ref int SomeValue;
public readonly Span<Matrix3x3> Matrices;
}
internal class B
{
private int _something;
internal string Text { get; private set; }
}
//----
// Module2
#pragma internals enable
namespace Module2;
var a = new Module1.A();
var keyLength = System.IO.Path.KeyLength; // private const int KeyLength = 8;
string path = @"C:\Users\MyName\Documents";
var ix = System.IO.Path.GetDirectoryNameOffset( path ); // internal static int GetDirectoryNameOffset(ReadOnlySpan<char> path)
// keep the memory layout from Module1.Matrix3x3
struct Matrix3x3
{
public Vector3 R1, R2, R3;
...
}
// this way of wrapping won't work.
ref struct ImpossibleWrappedA
{
ref Module1.A _wrappedA; // Error CS9050: A ref field cannot refer to a ref struct.
}
// no idea how to do this properly. Or in this case why.. Silly example.
ref struct WrappedA
{
public ref int SomeValue;
public Span<Matrix3x3> Matrices
internal WrappedA( Module1.A toWrap )
{
SomeValue = ref toWrap.SomeValue;
Matrices = MemoryMarshal.Cast<Module1.Matrix3x3, Matrix3x3>( toWrap.Matrices );
}
}
struct WrappedB
{
Module1.B _value;
// expose private field of wrapped object.
public ref int Something => ref _value._something;
public string Text
{
get => _value.Text;
set => _value.Text = value; // set even though it's private in Module1.B
}
}
// expose interal methods from System.IO.Path.
class PathEx
{
public static int GetDirectoryNameOffset(ReadOnlySpan<char> path)
=> System.IO.Path.GetDirectoryNameOffset( path );
}
#pragma internals disable
I'd like to make sure that this proposal also covers types with unspeakable names. e.g. anonymous types and closure frame types.
Right now, we have to do things like this:
var scopedVariable = new { Value = 1 };
var value = Unwrap<int>(() => scopedVariable);
T Unwrap<T>(LambdaExpression expression)
{
var member = (MemberExpression)expression.Body;
var constant = (ConstantExpression)member.Expression!;
var value = constant.Value!;
var field = (FieldInfo)member.Member;
var fieldValue = field.GetValue(value)!;
var anonymousType = fieldValue.GetType();
var property = anonymousType.GetProperty("Value")!;
return (T)property.GetValue(fieldValue)!;
}
And my understanding is that with this proposal we'd be able to rewrite it to something like the following (generated by a source generator so the names are up-to-date):
int Unwrap(LambdaExpression expression)
{
var member = (MemberExpression)expression.Body;
var constant = (ConstantExpression)member.Expression!;
var value = constant.Value!;
var fieldValue = GetScopedVariable(value);
return GetAnonymousValue(fieldValue);
}
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "scopedVariable")]
[return: UnsafeAccessorType("<>f__AnonymousType0`1[Int32]")]
extern static ref object GetScopedVariable([UnsafeAccessorType("Program+<>c__DisplayClass0_0")] object @this);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_Value()")]
extern static int GetAnonymousValue([UnsafeAccessorType("<>f__AnonymousType0`1[Int32]")] object @this);
Or, to avoid referencing generic types, we could define an interface that matches the shape:
[return: UnsafeAccessorType(nameof(IAnonymousType))]
extern static ref object GetScopedVariable([UnsafeAccessorType("Program.<>c__DisplayClass0_0")] object @this);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_Value()")]
extern static int GetAnonymousValue([UnsafeAccessorType(nameof(IAnonymousType))] object @this);
interface IAnonymousType
{
int Value { get; }
}
static extern int CallMethod([UnsafeAccessorType("C+D, A")] object d); // Use attribute to look up type
What would be the equivalent for value types here btw? Or would the idea idea be that the runtime would always eliminate boxing for them? (which I guess would be problematic when passing by ref)
What would be the equivalent for value types here btw
TypedReference was one of the options discussed in #81741 (comment) . It would require fixing the TypedReference limitations in C#.
I'd like to make sure that this proposal also covers types with unspeakable names. e.g. anonymous types and closure frame types.
Not to sidetrack this conversation, but is there even a way to get the DisplayClass and other generated type names with roslyn?
From what I have seen those names are generated during lowering and are not available through the api.
Not to sidetrack this conversation, but is there even a way to get the DisplayClass and other generated type names with roslyn?
From what I have seen those names are generated during lowering and are not available through the api.
Right. To avoid having to reference those names we would need to create an interface for every one of these types.
We will need to decide whether the implementation should do the casts from object to the actual type as regular throwing casts or as unsafe casts. There is a good argument that can be made for either option.
Safe cast in debug builds, unsafe in release builds :)
@AndriySvyryd Is this needed for .NET 9? This isn't high on my priority list and I was going to start this in .NET 10.
@AaronRobinsonMSFT It's not high priority, we can use reflection as a workaround for now
@AndriySvyryd Great. This will likely be done in vNext, unless we hear of a blocking issue.