Skip to content
Open
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
161 changes: 161 additions & 0 deletions src/embed_tests/TestClrTypeFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
using System;
using System.Collections.Generic;

using NUnit.Framework;

using Python.Runtime;

namespace Python.EmbeddingTest
{
public class TestClrTypeFilter
{
static readonly string RefusedName = typeof(FilterRefusedType).FullName!;

readonly RefuseOneType _filter = new(typeof(FilterRefusedType));

// The engine is already running, so the filter is added to the live
// configuration, as Inheritance does with PythonBaseTypeProviders. That
// is safe here because nothing else ever reflects FilterRefusedType, so
// it cannot already be cached
[OneTimeSetUp]
public void SetUp() => PythonEngine.InteropConfiguration.ClrTypeFilters.Add(_filter);

[OneTimeTearDown]
public void TearDown() => PythonEngine.InteropConfiguration.ClrTypeFilters.Remove(_filter);

[TestCase("host.Method()", TestName = "MethodReturn")]
[TestCase("host.OutParam(None)", TestName = "OutParameterAndReturn")]
[TestCase("host.OutOnly(None)", TestName = "OutParameterOnly")]
[TestCase("list(host.Iterate())", TestName = "IteratorItem")]
[TestCase("host.Property", TestName = "InstanceProperty")]
[TestCase("type(host).StaticProperty", TestName = "StaticProperty")]
[TestCase("host.Field", TestName = "InstanceField")]
[TestCase("type(host).StaticField", TestName = "StaticField")]
[TestCase("host.Array1[0]", TestName = "ArrayItem")]
[TestCase("host.Array2[0, 0]", TestName = "MultidimensionalArrayItem")]
[TestCase("host[0]", TestName = "IndexerResult")]
[TestCase("host.AsInterface().__implementation__", TestName = "InterfaceImplementation")]
[TestCase("host.AsInterface().__raw_implementation__", TestName = "InterfaceRawImplementation")]
public void RefusedTypeRaisesCatchableTypeError(string expression)
{
using var scope = Py.CreateScope();
scope.Set("host", new FilterHost());
scope.Exec($@"
def attempt():
try:
{expression}
except TypeError as e:
return str(e)
return 'nothing raised'
");
Assert.That(scope.Eval<string>("attempt()"), Does.Contain(RefusedName));
}

[Test]
public void PermittedTypesAreUnaffected()
{
using var scope = Py.CreateScope();
scope.Set("host", new FilterHost());
Assert.That(scope.Eval<int>("host.Permitted().Value"), Is.EqualTo(42));
}

[Test]
public void UncaughtRefusalReachesTheCallerAsTypeError()
{
using var scope = Py.CreateScope();
scope.Set("host", new FilterHost());

var error = Assert.Throws<PythonException>(() => scope.Exec("host.Method()"));
Assert.That(error.Type.Name, Is.EqualTo("TypeError"));
Assert.That(error.Message, Does.Contain(RefusedName));
}

[Test]
public void RefusedTypeIsCheckedOnEveryAttempt()
{
var counter = new CountingFilter(typeof(FilterRefusedType));
var filters = PythonEngine.InteropConfiguration.ClrTypeFilters;
filters.Insert(0, counter);
try
{
using var scope = Py.CreateScope();
scope.Set("host", new FilterHost());
for (var i = 0; i < 2; i++)
Assert.Throws<PythonException>(() => scope.Exec("host.Method()"));

// A refusal is never cached, so the filters see the type again
Assert.That(counter.Count, Is.EqualTo(2));
}
finally
{
filters.Remove(counter);
}
}

[Test]
public void NoFiltersByDefault()
{
using var configuration = InteropConfiguration.MakeDefault();
Assert.That(configuration.ClrTypeFilters, Is.Empty);
}

[Test]
public void TypeIsReflectedOnlyIfEveryFilterPermitsIt()
{
var group = new ClrTypeFilterGroup();
Assert.That(group.ShouldReflect(typeof(object)), Is.True, "an empty group permits everything");

group.Add(new RefuseOneType(typeof(string)));
Assert.That(group.ShouldReflect(typeof(object)), Is.True);
Assert.That(group.ShouldReflect(typeof(string)), Is.False);

group.Add(new RefuseOneType(typeof(object)));
Assert.That(group.ShouldReflect(typeof(object)), Is.False);
}

sealed class RefuseOneType(Type refused) : IClrTypeFilter
{
public bool ShouldReflect(Type type) => type != refused;
}

sealed class CountingFilter(Type counted) : IClrTypeFilter
{
public int Count { get; private set; }

public bool ShouldReflect(Type type)
{
if (type == counted)
Count++;
return true;
}
}
}

/// <summary>Refused by <see cref="TestClrTypeFilter"/>; reflected nowhere else.</summary>
public class FilterRefusedType : IFilterTarget { }

public interface IFilterTarget { }

public class FilterPermittedType
{
public int Value => 42;
}

/// <summary>Hands a refused type to Python through each boundary.</summary>
public class FilterHost
{
public FilterRefusedType Method() => new();
public int OutParam(out FilterRefusedType value) { value = new(); return 1; }
public void OutOnly(out FilterRefusedType value) => value = new();
public IEnumerable<FilterRefusedType> Iterate() { yield return new(); }
public FilterRefusedType Property => new();
public static FilterRefusedType StaticProperty => new();
public FilterRefusedType Field = new();
public static FilterRefusedType StaticField = new();
public FilterRefusedType[] Array1 = { new() };
public FilterRefusedType[,] Array2 = { { new() } };
public FilterRefusedType this[int index] => new();
public IFilterTarget AsInterface() => new FilterRefusedType();
public FilterPermittedType Permitted() => new();
}
}
25 changes: 25 additions & 0 deletions src/runtime/ClrTypeFilterGroup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace Python.Runtime
{
/// <summary>
/// Composes the registered filters: a type is reflected only if every filter
/// permits it. An empty group permits everything, which is the default.
/// </summary>
class ClrTypeFilterGroup : List<IClrTypeFilter>, IClrTypeFilter
{
public bool ShouldReflect(Type type)
{
if (type is null)
throw new ArgumentNullException(nameof(type));

foreach (var filter in this)
if (!filter.ShouldReflect(type))
return false;

return true;
}
}
}
18 changes: 18 additions & 0 deletions src/runtime/ClrTypeFilteredException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;

namespace Python.Runtime
{
/// <summary>
/// Raised when a CLR type would be reflected into Python but an
/// <see cref="IClrTypeFilter"/> registered on
/// <see cref="InteropConfiguration.ClrTypeFilters"/> refused it.
/// </summary>
public class ClrTypeFilteredException : Exception
{
public Type FilteredType { get; }

public ClrTypeFilteredException(Type type)
: base($"Reflecting {type?.FullName ?? "<null>"} into Python was refused by an IClrTypeFilter.")
=> FilteredType = type!;
}
}
8 changes: 8 additions & 0 deletions src/runtime/Exceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ public static bool SetError(Exception e)
return true;
}

// Raised as a plain TypeError: wrapping the CLR exception would mean
// reflecting its type into Python, which the same filters may refuse
if (e is ClrTypeFilteredException)
{
SetError(TypeError, e.Message);
return true;
}

using var instance = Converter.ToPython(e);
if (instance.IsNull()) return false;

Expand Down
26 changes: 26 additions & 0 deletions src/runtime/IClrTypeFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;

namespace Python.Runtime
{
/// <summary>
/// Decides whether a CLR type may be reflected into Python.
/// <para>
/// Filters are consulted when a type would first be presented to Python.
/// Permitted types are cached for the lifetime of the engine and not
/// re-checked; a refused type is checked again on each attempt
/// </para>
/// <para>
/// This is a policy hook for embedders, not a security boundary: Python code
/// holding any reflected object can still reach whatever that object's own
/// API exposes.
/// </para>
/// </summary>
public interface IClrTypeFilter
{
/// <summary>
/// Whether <paramref name="type"/> may be reflected into Python. Returning
/// false causes an attempt to present the type to Python to fail.
/// </summary>
bool ShouldReflect(Type type);
}
}
9 changes: 9 additions & 0 deletions src/runtime/InteropConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ internal readonly PythonBaseTypeProviderGroup pythonBaseTypeProviders
/// <summary>Enables replacing base types of CLR types as seen from Python</summary>
public IList<IPythonBaseTypeProvider> PythonBaseTypeProviders => this.pythonBaseTypeProviders;

internal readonly ClrTypeFilterGroup clrTypeFilters = new();

/// <summary>
/// Restricts which CLR types are reflected into Python. Empty by default,
/// so every type is reflected; a type is presented to Python only if every
/// registered filter permits it.
/// </summary>
public IList<IClrTypeFilter> ClrTypeFilters => this.clrTypeFilters;

public static InteropConfiguration MakeDefault()
{
return new InteropConfiguration
Expand Down
19 changes: 15 additions & 4 deletions src/runtime/Types/InterfaceObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,24 @@ public static NewReference tp_getattro(BorrowedReference ob, BorrowedReference k
}

string? name = Runtime.GetManagedString(key);
if (name == "__implementation__")

// Both expose the concrete object, whose type an IClrTypeFilter may
// refuse - that must become a Python exception, not unwind through it
try
{
return Converter.ToPython(clrObj.inst);
if (name == "__implementation__")
{
return Converter.ToPython(clrObj.inst);
}
else if (name == "__raw_implementation__")
{
return CLRObject.GetReference(clrObj.inst);
}
}
else if (name == "__raw_implementation__")
catch (Exception e)
{
return CLRObject.GetReference(clrObj.inst);
Exceptions.SetError(e);
return default;
}

return Runtime.PyObject_GenericGetAttr(ob, key);
Expand Down
6 changes: 6 additions & 0 deletions src/runtime/Types/ReflectedClrType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ public static ReflectedClrType GetOrCreate(Type type)
if (ClassManager.cache.TryGetValue(type, out var pyType))
return pyType;

// Every CLR type reaching Python passes through here, so consulting the
// filters once, on the cache miss, covers every route a type can take:
// GetType(), an API returning System.Type, a field, a return value
if (!PythonEngine.InteropConfiguration.clrTypeFilters.ShouldReflect(type))
throw new ClrTypeFilteredException(type);

// Shared with ClassManager.cache + TypeManager._slotsHolders writes
// so the multi-step type build below is atomic.
lock (ClassManager._cacheCreateLock)
Expand Down
Loading