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 _subTypeMapping = new(); + private readonly List _additionalAssemblies = []; private Type? _fallbackType; private bool _serializeDiscriminatorProperty; private bool _addDiscriminatorFirst; @@ -49,6 +50,47 @@ public JsonSubtypesConverterBuilder RegisterSubtype(object? value) return RegisterSubtype(typeof(T), value); } + /// + /// Adds an assembly to search, in addition to the base type's own assembly, when resolving + /// subtypes by name from the discriminator. Unlike [KnownSubTypeOtherAssembly], this + /// accepts an assembly loaded at runtime, which the attribute cannot name at compile time. + /// Types in the assembly that carry [KnownSubTypeOf(base)] with a discriminator value + /// are also registered as subtypes of the base type (the self-declaring plugin pattern). + /// + public JsonSubtypesConverterBuilder RegisterSubtypeAssembly(Assembly assembly) + { + _additionalAssemblies.Add(assembly); + ScanForSelfDeclaredSubtypes(assembly); + return this; + } + + private void ScanForSelfDeclaredSubtypes(Assembly assembly) + { + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + // An assembly can reference types it cannot load (e.g. an optional dependency that is + // not deployed). Scan the types that did load; skipping the rest is safe because a + // self-declared subtype must be loadable to be instantiated. + types = e.Types.Where(t => t != null).Cast().ToArray(); + } + + foreach (Type type in types) + { + foreach (KnownSubTypeOfAttribute attribute in type.GetCustomAttributes(inherit: false)) + { + if (attribute.BaseType == _baseType && attribute.DiscriminatorValue != null) + { + _subTypeMapping.Add(attribute.DiscriminatorValue, type); + } + } + } + } + public JsonSubtypesConverterBuilder SetFallbackSubtype(Type fallbackSubtype) { _fallbackType = fallbackSubtype; @@ -127,12 +169,14 @@ public JsonConverter Build() typeof(List), typeof(Type), typeof(bool), - typeof(bool) + typeof(bool), + typeof(Assembly[]) ], null)!; return (JsonConverter)constructor.Invoke( [ _discriminatorProperty, _subTypeMapping, null, _fallbackType, - _serializeDiscriminatorProperty, _addDiscriminatorFirst + _serializeDiscriminatorProperty, _addDiscriminatorFirst, + _additionalAssemblies.ToArray() ]); } diff --git a/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs b/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs index c44702c..c89ec93 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs @@ -58,6 +58,38 @@ public JsonSubtypesWithPropertyConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } + /// + /// Adds an assembly whose self-declared subtypes are registered by property presence. Types + /// in the assembly that carry [KnownSubTypeWithPropertyOf(base, "Property")] are added + /// as subtypes of the base type, identified by the presence of that property in the JSON. + /// + public JsonSubtypesWithPropertyConverterBuilder RegisterSubtypeAssembly(Assembly assembly) + { + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + // Skip unloadable types; a self-declared subtype must be loadable to be instantiated. + types = e.Types.Where(t => t != null).Cast().ToArray(); + } + + foreach (Type type in types) + { + foreach (KnownSubTypeWithPropertyOfAttribute attribute in type.GetCustomAttributes(inherit: false)) + { + if (attribute.BaseType == _baseType) + { + _types[attribute.PropertyName] = new TypeWithPropertyMatchingAttributes(type, attribute.PropertyName, false); + } + } + } + + return this; + } + [RequiresUnreferencedCode("JsonSubTypes.Text.Json uses reflection to create the subtype converter.")] [RequiresDynamicCode("JsonSubTypes.Text.Json uses reflection to create the subtype converter.")] public JsonConverter Build() @@ -71,9 +103,10 @@ public JsonConverter Build() typeof(List), typeof(Type), typeof(bool), - typeof(bool) + typeof(bool), + typeof(Assembly[]) ], null)!; return (JsonConverter)constructor.Invoke( - [null, null, _types.Values.ToList(), _fallbackType, false, false]); + [null, null, _types.Values.ToList(), _fallbackType, false, false, Array.Empty()]); } } diff --git a/JsonSubTypes.Text.Json/KnownSubTypeOfAttribute.cs b/JsonSubTypes.Text.Json/KnownSubTypeOfAttribute.cs new file mode 100644 index 0000000..938ab33 --- /dev/null +++ b/JsonSubTypes.Text.Json/KnownSubTypeOfAttribute.cs @@ -0,0 +1,22 @@ +using System; + +namespace JsonSubTypes.Text.Json; + +/// +/// Declares the decorated type as a subtype of , discovered by +/// when the containing +/// assembly is registered. With set, the type is also +/// mapped to that discriminator value (a fully self-declaring plugin); without it, the type is +/// resolved by name like any other type in the registered assembly. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true)] +public class KnownSubTypeOfAttribute(Type baseType, object? discriminatorValue = null) : Attribute +{ + public Type BaseType { get; } = baseType; + + /// + /// The discriminator value this subtype maps to, when the plugin declares its own mapping. + /// null (the default) means the subtype is only resolved by name. + /// + public object? DiscriminatorValue { get; } = discriminatorValue; +} diff --git a/JsonSubTypes.Text.Json/KnownSubTypeOtherAssembly.cs b/JsonSubTypes.Text.Json/KnownSubTypeOtherAssembly.cs new file mode 100644 index 0000000..61923d8 --- /dev/null +++ b/JsonSubTypes.Text.Json/KnownSubTypeOtherAssembly.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; + +namespace JsonSubTypes.Text.Json; + +/// +/// Declares another assembly to search, in addition to the base type's own assembly, when +/// resolving subtypes by name from the JSON discriminator for the decorated polymorphic base +/// type. The assembly is referenced by name so the base type does not need a compile-time +/// reference to it, which is what keeps the plugin pattern cycle-free: the plugin references the +/// base, the base merely names the plugin. +/// +/// +/// The assignability guard still applies: only types assignable from the base type are +/// considered. This attribute replaces the global JsonSubTypesTypeResolution.AddAssembly +/// registry, which leaked state across serialization profiles; resolution is now declared on the +/// base type and cached per type. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true)] +public class KnownSubTypeOtherAssembly(string assemblyName) : Attribute +{ + public string AssemblyName { get; } = assemblyName; +} + +internal static class TypeResolution +{ + private static readonly ConcurrentDictionary AssembliesByBaseType = new(); + + public static Assembly[] GetSearchAssemblies(TypeInfo baseType) + { + return AssembliesByBaseType.GetOrAdd(baseType, static type => + { + List assemblies = [type.Assembly]; + foreach (object attribute in type.GetCustomAttributes(false)) + { + if (attribute is KnownSubTypeOtherAssembly otherAssembly) + { + Assembly? assembly = FindAssembly(otherAssembly.AssemblyName); + if (assembly != null && !assemblies.Contains(assembly)) + { + assemblies.Add(assembly); + } + } + } + + return [.. assemblies]; + }); + } + + private static Assembly? FindAssembly(string assemblyName) + { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (string.Equals(assembly.GetName().Name, assemblyName, StringComparison.Ordinal)) + { + return assembly; + } + } + + return null; + } +} diff --git a/JsonSubTypes.Text.Json/KnownSubTypeWithPropertyOfAttribute.cs b/JsonSubTypes.Text.Json/KnownSubTypeWithPropertyOfAttribute.cs new file mode 100644 index 0000000..b55a010 --- /dev/null +++ b/JsonSubTypes.Text.Json/KnownSubTypeWithPropertyOfAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace JsonSubTypes.Text.Json; + +/// +/// Declares the decorated type as a subtype of , identified by the +/// presence of in the JSON. Discovered by +/// when the +/// containing assembly is registered — the self-declaring plugin pattern for property-presence +/// discrimination. Mirrors from the subtype side. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true)] +public class KnownSubTypeWithPropertyOfAttribute(Type baseType, string propertyName) : Attribute +{ + public Type BaseType { get; } = baseType; + public string PropertyName { get; } = propertyName; +} diff --git a/JsonSubTypes.Text.Json/NullableDictionary.cs b/JsonSubTypes.Text.Json/NullableDictionary.cs index 281e548..5d44267 100644 --- a/JsonSubTypes.Text.Json/NullableDictionary.cs +++ b/JsonSubTypes.Text.Json/NullableDictionary.cs @@ -44,6 +44,19 @@ public void Add(TKey? key, TValue value) } } + public void Set(TKey? key, TValue value) + { + if (key is null) + { + _hasNullKey = true; + _nullKeyValue = value; + } + else + { + _dictionary[key] = value; + } + } + public IEnumerable NotNullKeys() { return _dictionary.Keys; diff --git a/MIGRATION.md b/MIGRATION.md index 3b5b336..da5eb56 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -53,7 +53,7 @@ Behaviour that actually differs — check your tests against these: - **Property order differs.** STJ emits most-derived-first; there is no `[JsonProperty(Order = N)]` support. - **`MaxDepth` needs one more level** because the write path round-trips through a `JsonDocument`. - **Fallback paths are narrower**: serializing the base type directly or an unknown discriminator uses a reflection-based path that honors `[JsonIgnore]`, `[JsonPropertyName]`, naming policy and `DefaultIgnoreCondition`, but not per-property `[JsonConverter]`, `[JsonInclude]` fields, `required` members or parameterized constructors. -- **Cross-assembly subtypes** require opt-in (`JsonSubTypesTypeResolution.AddAssembly`); Newtonsoft never supported them. +- **Cross-assembly subtypes** require opt-in: `[KnownSubTypeOtherAssembly("AssemblyName")]` on the base type; Newtonsoft never supported them. - **Security**: the name-based resolution warning in the README applies to both; see the [security section](./#security) there. ## Between the System.Text.Json engines diff --git a/README.md b/README.md index cc0f004..3e685c4 100644 --- a/README.md +++ b/README.md @@ -368,7 +368,7 @@ public interface IExpression { } - A property declared with a base class or interface type is serialized using the **declared type's contract**: subtype members are omitted unless a converter that claims the declared type is applied (attribute on the type, or builder registered in `JsonSerializerOptions`). The Newtonsoft version serialized the runtime type by default. - Property order differs: `System.Text.Json` emits properties most-derived-first, while the Newtonsoft version honored `[JsonProperty(Order = N)]`. There is no `Order` support in `System.Text.Json`. - Deeply nested graphs need `MaxDepth` about one level higher than with the Newtonsoft/plain serialization: the discriminator write path round-trips through a `JsonDocument`, which consumes one depth level. (A 64-level chain requires `MaxDepth = 66` instead of 65.) -- Name-based type resolution stays scoped to the base type's assembly by default. Cross-assembly subtypes require an explicit opt-in: `JsonSubTypesTypeResolution.AddAssembly(...)`, a capability the Newtonsoft version does not have. +- Name-based type resolution stays scoped to the base type's assembly by default. Cross-assembly subtypes require an explicit opt-in: `[KnownSubTypeOtherAssembly("AssemblyName")]` on the base type, a capability the Newtonsoft version does not have. - `JsonNamingPolicy` and `PropertyNameCaseInsensitive` are respected when matching the discriminator property, and `JsonStringEnumConverter` is respected when mapping discriminator values. Note that `JsonStringEnumConverter` (.NET 8) does **not** honor `[EnumMember(Value = ...)]` — use enum names or `[JsonStringEnumMemberName]` (.NET 9+). - Dotted or nested discriminator property paths (e.g. `"nested.property"`) are supported. - **Fallback paths**: serializing the base type itself (rather than a subtype) and deserializing an unknown discriminator back to the base use a reflection-based writer/reader, because the base type's contract is owned by the converter (`System.Text.Json` exposes no property metadata for converter-owned types). `[JsonPropertyName]`, `[JsonIgnore]` (including `JsonIgnoreCondition`), the naming policy and `DefaultIgnoreCondition` are honored; per-property `[JsonConverter]`, `[JsonInclude]` fields, `required` members and parameterized constructors are not supported on these two paths. @@ -380,7 +380,7 @@ public interface IExpression { } When a subtype is resolved by *name* — which happens for both packages **only when no subtype mapping is declared at all** (no `[KnownSubType]` attribute, no `RegisterSubtype` builder call) — the converter turns the JSON discriminator string into a type name and instantiates the matching type. Declaring a mapping at all switches the converter to that mapping, even when no entry matches; the name-based path is never used then. -Only types assignable from the polymorphic base type can be resolved, but any such type present in the base type's assembly (for Newtonsoft.Json) or in that assembly plus any assembly registered via `JsonSubTypesTypeResolution` (for `System.Text.Json`) can be instantiated with attacker-controlled JSON. Do **not** expose a name-based hierarchy to untrusted JSON without validating the payload upstream; prefer explicit `[KnownSubType]` or builder mappings whenever the discriminator can come from outside your own code. +Only types assignable from the polymorphic base type can be resolved, but any such type present in the base type's assembly (for Newtonsoft.Json) or in that assembly plus any assembly named by a `[KnownSubTypeOtherAssembly]` attribute on the base type (for `System.Text.Json`) can be instantiated with attacker-controlled JSON. Do **not** expose a name-based hierarchy to untrusted JSON without validating the payload upstream; prefer explicit `[KnownSubType]` or builder mappings whenever the discriminator can come from outside your own code. ### Which engine should I use?