diff --git a/CHANGELOG.md b/CHANGELOG.md
index 878ca6e..2d430df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### JsonSubTypes.Text.Json
#### Changed
+- Replaced the global `JsonSubTypesTypeResolution.AddAssembly` registry with a declarative `[KnownSubTypeOtherAssembly("AssemblyName")]` attribute on the base type. Resolution is now per-type instead of process-wide, so it no longer leaks across serialization profiles. The attribute takes an assembly name, keeping the base type free of a compile-time reference to the plugin.
- Renamed `FallBackSubTypeAttribute` to `FallbackSubTypeAttribute` and `FallBackToNearestAncestor()` to `FallbackToNearestAncestor()` for consistent capitalization. The `FallBack*` names still work in `JsonSubTypes` (Newtonsoft), which keeps its historical API.
### JsonSubTypes
diff --git a/JsonSubTypes.Text.Json.Tests.Plugin/JsonSubTypes.Text.Json.Tests.Plugin.csproj b/JsonSubTypes.Text.Json.Tests.Plugin/JsonSubTypes.Text.Json.Tests.Plugin.csproj
index d1f5799..ee64e8d 100644
--- a/JsonSubTypes.Text.Json.Tests.Plugin/JsonSubTypes.Text.Json.Tests.Plugin.csproj
+++ b/JsonSubTypes.Text.Json.Tests.Plugin/JsonSubTypes.Text.Json.Tests.Plugin.csproj
@@ -5,5 +5,6 @@
+
diff --git a/JsonSubTypes.Text.Json.Tests.Plugin/PluginDog.cs b/JsonSubTypes.Text.Json.Tests.Plugin/PluginDog.cs
index bf18392..0b75f5e 100644
--- a/JsonSubTypes.Text.Json.Tests.Plugin/PluginDog.cs
+++ b/JsonSubTypes.Text.Json.Tests.Plugin/PluginDog.cs
@@ -1,7 +1,9 @@
+using JsonSubTypes.Text.Json;
using JsonSubTypes.Text.Json.Tests.Shared;
namespace JsonSubTypes.Text.Json.Tests.Plugin
{
+ [KnownSubTypeOf(typeof(SharedAnimal), "Dog")]
public class PluginDog : SharedAnimal
{
public bool CanBark { get; set; }
diff --git a/JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs b/JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs
new file mode 100644
index 0000000..c10224c
--- /dev/null
+++ b/JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs
@@ -0,0 +1,41 @@
+using JsonSubTypes.Text.Json;
+using JsonSubTypes.Text.Json.Tests.Shared;
+
+namespace JsonSubTypes.Text.Json.Tests.Plugin
+{
+ // A subtype in a separate assembly that declares itself as a subtype of SelfDeclaredBase
+ // through [KnownSubTypeOf]. The host registers the plugin assembly at runtime; the base type
+ // knows nothing about this type or its assembly.
+ [KnownSubTypeOf(typeof(SelfDeclaredBase), "Dog")]
+ public class SelfDeclaredDog : SelfDeclaredBase
+ {
+ public bool CanBark { get; set; }
+ }
+
+ // Self-declared without a discriminator value: resolved by type name in the registered
+ // assembly rather than by a discriminator value. Lives in an assembly with no value-mapped
+ // subtypes, so the name-based path stays active.
+ [KnownSubTypeOf(typeof(SelfDeclaredCatBase))]
+ public class SelfDeclaredCat : SelfDeclaredCatBase
+ {
+ public bool Purrs { get; set; }
+ }
+
+ public class SelfDeclaredCatBase
+ {
+ public string? Kind { get; set; }
+ }
+
+ // Self-declared by property presence: identified by the presence of "JobTitle" in the JSON,
+ // in an assembly the host registers at runtime. The base type knows nothing about it.
+ [KnownSubTypeWithPropertyOf(typeof(SelfDeclaredEmployeeBase), "JobTitle")]
+ public class SelfDeclaredEmployee : SelfDeclaredEmployeeBase
+ {
+ public string? JobTitle { get; set; }
+ }
+
+ public class SelfDeclaredEmployeeBase
+ {
+ public string? FirstName { get; set; }
+ }
+}
diff --git a/JsonSubTypes.Text.Json.Tests.Shared/SelfDeclaredBase.cs b/JsonSubTypes.Text.Json.Tests.Shared/SelfDeclaredBase.cs
new file mode 100644
index 0000000..50765a0
--- /dev/null
+++ b/JsonSubTypes.Text.Json.Tests.Shared/SelfDeclaredBase.cs
@@ -0,0 +1,10 @@
+namespace JsonSubTypes.Text.Json.Tests.Shared
+{
+ // A base type without a [JsonSubTypeConverter] attribute or a KnownSubTypeOtherAssembly:
+ // used to verify the self-declaring plugin pattern, where the subtype registers itself and
+ // the host registers the plugin assembly at runtime.
+ public class SelfDeclaredBase
+ {
+ public string? Kind { get; set; }
+ }
+}
diff --git a/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs b/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs
index 9d41974..f3ed827 100644
--- a/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs
+++ b/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs
@@ -1,6 +1,7 @@
namespace JsonSubTypes.Text.Json.Tests.Shared
{
[JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")]
+ [KnownSubTypeOtherAssembly("JsonSubTypes.Text.Json.Tests.Plugin")]
public class SharedAnimal
{
public string? Kind { get; set; }
diff --git a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs
index 06f78b9..1dd5302 100644
--- a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs
+++ b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs
@@ -208,37 +208,101 @@ public void NameBasedResolutionRejectsNonSubtypes()
[Test]
public void CrossAssemblyResolvedWhenAssemblyRegistered()
{
- JsonSubTypesTypeResolution.ClearAssemblies();
- JsonSubTypesTypeResolution.AddAssembly(typeof(PluginDog).Assembly);
- try
- {
- var dog = JsonSerializer.Deserialize(
- $"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}");
+ var dog = JsonSerializer.Deserialize(
+ $"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}");
- Assert.IsInstanceOf(dog);
- Assert.IsTrue((dog as PluginDog)?.CanBark == true);
- }
- finally
- {
- JsonSubTypesTypeResolution.ClearAssemblies();
- }
+ Assert.IsInstanceOf(dog);
+ Assert.IsTrue((dog as PluginDog)?.CanBark == true);
}
[Test]
public void CrossAssemblyNotResolvedByDefault()
{
- JsonSubTypesTypeResolution.ClearAssemblies();
- try
- {
- var animal = JsonSerializer.Deserialize(
- $"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}");
+ var animal = JsonSerializer.Deserialize(
+ $"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}");
- Assert.IsInstanceOf(animal);
- }
- finally
- {
- JsonSubTypesTypeResolution.ClearAssemblies();
- }
+ Assert.IsInstanceOf(animal);
+ }
+
+ // A base type without the attribute: name-based resolution stays in its own assembly.
+ [JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")]
+ public class OtherBase
+ {
+ public string Kind { get; set; }
+ }
+
+ [Test]
+ public void SelfDeclaredSubtypeResolvedViaRegisteredAssembly()
+ {
+ // SelfDeclaredDog declares itself through [KnownSubTypeOf(typeof(SelfDeclaredBase), "Dog")]
+ // in the plugin assembly. The host registers that assembly at runtime; the base type
+ // knows nothing about the subtype or its assembly. The scan picks up the mapping.
+ var options = new JsonSerializerOptions();
+ options.Converters.Add(JsonSubtypesConverterBuilder
+ .Of("Kind")
+ .RegisterSubtypeAssembly(typeof(SelfDeclaredDog).Assembly)
+ .Build());
+
+ var dog = JsonSerializer.Deserialize("{\"Kind\":\"Dog\",\"CanBark\":true}", options);
+
+ Assert.IsInstanceOf(dog);
+ Assert.IsTrue((dog as SelfDeclaredDog)?.CanBark == true);
+ }
+
+ [Test]
+ public void SelfDeclaredSubtypeResolvedByNameInRegisteredAssembly()
+ {
+ // SelfDeclaredCat declares itself without a value, so it is resolved by type name in
+ // the registered plugin assembly rather than by a discriminator value.
+ var options = new JsonSerializerOptions();
+ options.Converters.Add(JsonSubtypesConverterBuilder
+ .Of("Kind")
+ .RegisterSubtypeAssembly(typeof(SelfDeclaredCat).Assembly)
+ .Build());
+
+ var cat = JsonSerializer.Deserialize(
+ $"{{\"Kind\":\"{typeof(SelfDeclaredCat).FullName}\",\"Purrs\":true}}", options);
+
+ Assert.IsInstanceOf(cat);
+ Assert.IsTrue((cat as SelfDeclaredCat)?.Purrs == true);
+ }
+
+ [Test]
+ public void DynamicSubtypeRegisteredAtRuntime()
+ {
+ // The runtime hook: register a subtype after the converter is built, without editing
+ // the plugin or scanning an assembly. Mirrors the generator's RegisterDynamicSubtype.
+ var converter = (JsonSubtypes)JsonSubtypesConverterBuilder
+ .Of("Kind")
+ .Build();
+ converter.RegisterDynamicSubtype("dog", typeof(SelfDeclaredDog));
+
+ var options = new JsonSerializerOptions();
+ options.Converters.Add(converter);
+
+ var dog = JsonSerializer.Deserialize("{\"Kind\":\"dog\",\"CanBark\":true}", options);
+
+ Assert.IsInstanceOf(dog);
+ Assert.IsTrue((dog as SelfDeclaredDog)?.CanBark == true);
+ }
+
+ [Test]
+ public void SelfDeclaredSubtypeByPropertyPresence()
+ {
+ // SelfDeclaredEmployee declares itself through
+ // [KnownSubTypeWithPropertyOf(typeof(SelfDeclaredEmployeeBase), "JobTitle")]. The host
+ // registers the plugin assembly; the scan maps the property presence.
+ var options = new JsonSerializerOptions();
+ options.Converters.Add(JsonSubtypesWithPropertyConverterBuilder
+ .Of()
+ .RegisterSubtypeAssembly(typeof(SelfDeclaredEmployee).Assembly)
+ .Build());
+
+ var employee = JsonSerializer.Deserialize(
+ "{\"FirstName\":\"A\",\"JobTitle\":\"Dev\"}", options);
+
+ Assert.IsInstanceOf(employee);
+ Assert.AreEqual("Dev", (employee as SelfDeclaredEmployee)?.JobTitle);
}
}
}
diff --git a/JsonSubTypes.Text.Json/JsonSubTypesTypeResolution.cs b/JsonSubTypes.Text.Json/JsonSubTypesTypeResolution.cs
deleted file mode 100644
index 9477bce..0000000
--- a/JsonSubTypes.Text.Json/JsonSubTypesTypeResolution.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using System.Reflection;
-
-namespace JsonSubTypes.Text.Json;
-
-public static class JsonSubTypesTypeResolution
-{
- private static readonly ConcurrentDictionary Assemblies = new();
-
- public static void AddAssembly(Assembly assembly)
- {
- Assemblies.TryAdd(assembly, 0);
- }
-
- public static void RemoveAssembly(Assembly assembly)
- {
- Assemblies.TryRemove(assembly, out _);
- }
-
- public static void ClearAssemblies()
- {
- Assemblies.Clear();
- }
-
- public static IReadOnlyCollection SearchAssemblies => Assemblies.Keys.ToArray();
-
- internal static IEnumerable GetSearchAssemblies(Assembly parentAssembly)
- {
- yield return parentAssembly;
- foreach (Assembly assembly in Assemblies.Keys)
- {
- if (assembly != parentAssembly)
- {
- yield return assembly;
- }
- }
- }
-}
\ No newline at end of file
diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs
index 15343e4..5cea367 100644
--- a/JsonSubTypes.Text.Json/JsonSubtypes.cs
+++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs
@@ -106,7 +106,7 @@ internal interface IJsonSubtypes
/// Name-based resolution (used only when no mapping is
/// declared) instantiates the type whose name matches the discriminator, provided it is
/// assignable from the polymorphic base type and lives in the base type's assembly or in an
-/// assembly registered via . Any such type present in
+/// assembly registered via . Any such type present in
/// those assemblies can be instantiated with attacker-controlled JSON.
///
///
@@ -147,9 +147,11 @@ private static readonly ConditionalWeakTable? _runtimeTypeToDiscriminator;
+ private readonly Assembly[] _additionalAssemblies;
public JsonSubtypes()
{
+ _additionalAssemblies = [];
}
public JsonSubtypes(string? jsonDiscriminatorPropertyName)
@@ -157,6 +159,7 @@ public JsonSubtypes(string? jsonDiscriminatorPropertyName)
JsonDiscriminatorPropertyName = jsonDiscriminatorPropertyName;
_serializeDiscriminatorProperty = jsonDiscriminatorPropertyName != null;
_addDiscriminatorFirst = true;
+ _additionalAssemblies = [];
}
internal JsonSubtypes(string? jsonDiscriminatorPropertyName,
@@ -164,13 +167,15 @@ internal JsonSubtypes(string? jsonDiscriminatorPropertyName,
List? typesByPropertyPresence,
Type? fallbackType,
bool serializeDiscriminatorProperty,
- bool addDiscriminatorFirst) : this(jsonDiscriminatorPropertyName)
+ bool addDiscriminatorFirst,
+ Assembly[] additionalAssemblies) : this(jsonDiscriminatorPropertyName)
{
_subTypeMapping = subTypeMapping;
_typesByPropertyPresence = typesByPropertyPresence;
_fallbackType = fallbackType;
_serializeDiscriminatorProperty = serializeDiscriminatorProperty;
_addDiscriminatorFirst = addDiscriminatorFirst;
+ _additionalAssemblies = additionalAssemblies;
if (subTypeMapping != null)
{
_runtimeTypeToDiscriminator = new Dictionary();
@@ -186,6 +191,27 @@ public override bool CanConvert(Type objectType)
return objectType == typeof(T);
}
+ ///
+ /// Registers a subtype at runtime, after the converter is built. This is the runtime hook for
+ /// hierarchies whose subtypes are only known at runtime (plugins, loaded assemblies): it maps
+ /// to without editing the plugin or
+ /// scanning an assembly. The last registration for a discriminator wins, like the builder.
+ ///
+ public void RegisterDynamicSubtype(object discriminator, Type type)
+ {
+ if (_subTypeMapping == null)
+ {
+ throw new InvalidOperationException(
+ "RegisterDynamicSubtype requires a builder-built converter. Build one with JsonSubtypesConverterBuilder.Of(...).Build() first.");
+ }
+
+ _subTypeMapping.Set(discriminator, type);
+ if (_runtimeTypeToDiscriminator != null)
+ {
+ _runtimeTypeToDiscriminator[type] = discriminator;
+ }
+ }
+
public override T? Read(ref Utf8JsonReader reader, Type objectType, JsonSerializerOptions serializer)
{
return ReadJson(ref reader, objectType, serializer);
@@ -733,7 +759,7 @@ .. GetAttributes(parentType.GetTypeInfo())
JsonValueKind.String => discriminatorValue.GetString(),
_ => discriminatorValue.ToString()
};
- return GetTypeByName(discriminatorStringValue, parentType.GetTypeInfo());
+ return GetTypeByName(discriminatorStringValue, parentType.GetTypeInfo(), _additionalAssemblies);
}
private static bool TryGetValueInJson(JsonElement root, string propertyName,
@@ -801,7 +827,7 @@ private static bool TryGetProperty(JsonElement obj, string name, JsonSerializerO
return false;
}
- private static Type? GetTypeByName(string? typeName, TypeInfo parentType)
+ private static Type? GetTypeByName(string? typeName, TypeInfo parentType, Assembly[] instanceAssemblies)
{
if (typeName == null)
{
@@ -813,7 +839,10 @@ private static bool TryGetProperty(JsonElement obj, string name, JsonSerializerO
? null
: parentTypeFullName.Substring(0, parentTypeFullName.Length - parentType.Name.Length);
- foreach (Assembly assembly in JsonSubTypesTypeResolution.GetSearchAssemblies(parentType.Assembly))
+ Assembly[] attributeAssemblies = TypeResolution.GetSearchAssemblies(parentType);
+ IEnumerable assemblies = attributeAssemblies
+ .Concat(instanceAssemblies.Where(a => !attributeAssemblies.Contains(a)));
+ foreach (Assembly assembly in assemblies)
{
Type? typeByName = assembly.GetType(typeName);
if (typeByName == null && searchLocation != null)
diff --git a/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs b/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs
index 75cbe5d..bb6ac5e 100644
--- a/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs
+++ b/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs
@@ -16,6 +16,7 @@ public class JsonSubtypesConverterBuilder
private readonly Type _baseType;
private readonly string _discriminatorProperty;
private readonly NullableDictionary