Skip to content

Commit d594f1d

Browse files
authored
Merge pull request #237 from petrsnd/bugfix/petrsnd/aot-deserialize-and-analyzers
Make SafeguardDotNet fully consumable by AOT-publishing apps
2 parents d4eb9ad + 2f18873 commit d594f1d

33 files changed

Lines changed: 325 additions & 367 deletions

.agents/skills/testing-guide/SKILL.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,42 @@ live Safeguard appliance for testing.** If they do, ask for:
3232
This is not required for documentation or minor fixes, but it is **strongly encouraged**
3333
for any change that touches authentication, API calls, connection logic, or event handling.
3434

35+
## AOT cleanliness — how reflection regressions are caught
36+
37+
Every CLI tool csproj under `Test/` carries the **full AOT property set**: `IsAotCompatible`,
38+
`IsTrimmable`, `EnableAotAnalyzer`, `EnableTrimAnalyzer`, `EnableSingleFileAnalyzer`,
39+
`JsonSerializerIsReflectionEnabledByDefault=false`. This gives two complementary guards:
40+
41+
1. **Build-time** — analyzers run against tool source; any reflection-based JSON
42+
introduced in a tool fails the build via repo-wide `TreatWarningsAsErrors`.
43+
2. **Runtime** — when the PowerShell suite `dotnet run`s these tools against a live
44+
appliance, they execute with reflection disabled, so any new SDK code path that needs
45+
reflection fails loudly in regression instead of silently shipping to consumers.
46+
47+
**Every new tool csproj under `Test/` must carry this full property set.** If a regression
48+
run starts failing event listeners, A2A retrieval, or auth, the answer is **not** "remove
49+
the AOT switches from the tool" — it's "fix the SDK code path that's trying to use
50+
reflection." The canonical example: SignalR registrations must be typed
51+
(`On<JsonElement>(...)`, never `On("name", (object) => ...)`) so `JsonHubProtocol` can
52+
deserialize without reflection.
53+
54+
### Gap to be aware of
55+
56+
The SDK targets `netstandard2.0`; trim/AOT analyzers do not run on its own source. A
57+
regression in the SDK that switches `SafeguardJson.Deserialize<T>` (or any other facade
58+
method) to a `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`-annotated overload
59+
**will not** be caught at SDK build time. It only surfaces either:
60+
61+
- as `IL2026` / `IL3050` warnings in a downstream `PublishAot=true` consumer's build
62+
(e.g. safeguard-mcp), or
63+
- as runtime failures during live appliance regression (broken event listeners, A2A
64+
retrieval, auth).
65+
66+
**For changes touching `SafeguardDotNet/Serialization/`, `SafeguardJsonContext`, the
67+
`SafeguardJson` facade, any `JsonSerializer.*` site, or any SignalR `HubConnection`
68+
registration, the live appliance regression is the primary safety net.** Run the full
69+
PowerShell suite (see below) before declaring such a change done.
70+
3571
## Connecting to the appliance (PKCE vs Resource Owner Grant)
3672

3773
**Resource Owner Grant (ROG) is disabled by default** on Safeguard appliances. The SDK's

SafeguardDotNet.BrowserLogin/SafeguardDotNet.BrowserLogin.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<TargetFramework>netstandard2.0</TargetFramework>

SafeguardDotNet.Core.sln

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
Microsoft Visual Studio Solution File, Format Version 12.00
23
# Visual Studio Version 18
34
VisualStudioVersion = 18.2.11415.280
@@ -56,8 +57,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SafeguardDotNetUnitTest", "
5657
EndProject
5758
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SafeguardDotNet.SerializationTests", "Test\SafeguardDotNet.SerializationTests\SafeguardDotNet.SerializationTests.csproj", "{F5591287-9C28-4778-951A-31F6A6766B63}"
5859
EndProject
59-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SafeguardDotNetAotTest", "Test\SafeguardDotNetAotTest\SafeguardDotNetAotTest.csproj", "{3E63CCAE-63D0-4E10-85F4-03D35091144D}"
60-
EndProject
6160
Global
6261
GlobalSection(SolutionConfigurationPlatforms) = preSolution
6362
Debug|Any CPU = Debug|Any CPU
@@ -112,18 +111,6 @@ Global
112111
{0149D659-78E3-476B-81F1-A80BDFA6F8A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
113112
{0149D659-78E3-476B-81F1-A80BDFA6F8A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
114113
{0149D659-78E3-476B-81F1-A80BDFA6F8A9}.Release|Any CPU.Build.0 = Release|Any CPU
115-
{1B11B367-2F76-49EE-A820-2A0A8B1721B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
116-
{1B11B367-2F76-49EE-A820-2A0A8B1721B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
117-
{1B11B367-2F76-49EE-A820-2A0A8B1721B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
118-
{1B11B367-2F76-49EE-A820-2A0A8B1721B1}.Release|Any CPU.Build.0 = Release|Any CPU
119-
{F5591287-9C28-4778-951A-31F6A6766B63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
120-
{F5591287-9C28-4778-951A-31F6A6766B63}.Debug|Any CPU.Build.0 = Debug|Any CPU
121-
{F5591287-9C28-4778-951A-31F6A6766B63}.Release|Any CPU.ActiveCfg = Release|Any CPU
122-
{F5591287-9C28-4778-951A-31F6A6766B63}.Release|Any CPU.Build.0 = Release|Any CPU
123-
{3E63CCAE-63D0-4E10-85F4-03D35091144D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
124-
{3E63CCAE-63D0-4E10-85F4-03D35091144D}.Debug|Any CPU.Build.0 = Debug|Any CPU
125-
{3E63CCAE-63D0-4E10-85F4-03D35091144D}.Release|Any CPU.ActiveCfg = Release|Any CPU
126-
{3E63CCAE-63D0-4E10-85F4-03D35091144D}.Release|Any CPU.Build.0 = Release|Any CPU
127114
{6802F7A9-CD3D-4608-9EEC-C98636DA2708}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
128115
{6802F7A9-CD3D-4608-9EEC-C98636DA2708}.Debug|Any CPU.Build.0 = Debug|Any CPU
129116
{6802F7A9-CD3D-4608-9EEC-C98636DA2708}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -132,6 +119,14 @@ Global
132119
{20D9ED51-6852-44FC-A413-5EC7631139F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
133120
{20D9ED51-6852-44FC-A413-5EC7631139F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
134121
{20D9ED51-6852-44FC-A413-5EC7631139F9}.Release|Any CPU.Build.0 = Release|Any CPU
122+
{1B11B367-2F76-49EE-A820-2A0A8B1721B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
123+
{1B11B367-2F76-49EE-A820-2A0A8B1721B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
124+
{1B11B367-2F76-49EE-A820-2A0A8B1721B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
125+
{1B11B367-2F76-49EE-A820-2A0A8B1721B1}.Release|Any CPU.Build.0 = Release|Any CPU
126+
{F5591287-9C28-4778-951A-31F6A6766B63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
127+
{F5591287-9C28-4778-951A-31F6A6766B63}.Debug|Any CPU.Build.0 = Debug|Any CPU
128+
{F5591287-9C28-4778-951A-31F6A6766B63}.Release|Any CPU.ActiveCfg = Release|Any CPU
129+
{F5591287-9C28-4778-951A-31F6A6766B63}.Release|Any CPU.Build.0 = Release|Any CPU
135130
EndGlobalSection
136131
GlobalSection(SolutionProperties) = preSolution
137132
HideSolutionNode = FALSE
@@ -150,7 +145,6 @@ Global
150145
{1EB6D64D-7AFB-41DC-B11B-58934D053684} = {9249E337-656D-4970-B45C-7A077C56FA44}
151146
{1B11B367-2F76-49EE-A820-2A0A8B1721B1} = {DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}
152147
{F5591287-9C28-4778-951A-31F6A6766B63} = {DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}
153-
{3E63CCAE-63D0-4E10-85F4-03D35091144D} = {DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}
154148
EndGlobalSection
155149
GlobalSection(ExtensibilityGlobals) = postSolution
156150
SolutionGuid = {5A3B2C1D-4E6F-7A8B-9C0D-1E2F3A4B5C6D}

SafeguardDotNet.Framework.sln

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
1+
22
Microsoft Visual Studio Solution File, Format Version 12.00
33
# Visual Studio Version 18
44
VisualStudioVersion = 18.2.11415.280 d18.0

SafeguardDotNet.sln

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeguardDotNet.PkceNoninte
5252
EndProject
5353
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeguardDotNetPkceNoninteractiveLoginTester", "Test\SafeguardDotNetPkceNoninteractiveLoginTester\SafeguardDotNetPkceNoninteractiveLoginTester.csproj", "{C6D34567-3456-4321-9876-345678901CDE}"
5454
EndProject
55-
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeguardDotNetAotTest", "Test\SafeguardDotNetAotTest\SafeguardDotNetAotTest.csproj", "{3E63CCAE-63D0-4E10-85F4-03D35091144D}"
56-
EndProject
5755
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{867EB36D-7893-444D-900D-29733E8E2636}"
5856
EndProject
5957
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SampleA2aService", "Samples\SampleA2aService\SampleA2aService.csproj", "{0149D659-78E3-476B-81F1-A80BDFA6F8A9}"
@@ -152,10 +150,6 @@ Global
152150
{BE0C60E2-002E-42A0-A1D1-3BB9AE90D607}.Debug|Any CPU.Build.0 = Debug|Any CPU
153151
{BE0C60E2-002E-42A0-A1D1-3BB9AE90D607}.Release|Any CPU.ActiveCfg = Release|Any CPU
154152
{BE0C60E2-002E-42A0-A1D1-3BB9AE90D607}.Release|Any CPU.Build.0 = Release|Any CPU
155-
{3E63CCAE-63D0-4E10-85F4-03D35091144D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
156-
{3E63CCAE-63D0-4E10-85F4-03D35091144D}.Debug|Any CPU.Build.0 = Debug|Any CPU
157-
{3E63CCAE-63D0-4E10-85F4-03D35091144D}.Release|Any CPU.ActiveCfg = Release|Any CPU
158-
{3E63CCAE-63D0-4E10-85F4-03D35091144D}.Release|Any CPU.Build.0 = Release|Any CPU
159153
EndGlobalSection
160154
GlobalSection(SolutionProperties) = preSolution
161155
HideSolutionNode = FALSE
@@ -171,7 +165,6 @@ Global
171165
{1EB6D64D-7AFB-41DC-B11B-58934D053684} = {9249E337-656D-4970-B45C-7A077C56FA44}
172166
{D0009DBA-16E1-4E6F-AC1E-D11B16AB3C41} = {DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}
173167
{C6D34567-3456-4321-9876-345678901CDE} = {DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}
174-
{3E63CCAE-63D0-4E10-85F4-03D35091144D} = {DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}
175168
{0149D659-78E3-476B-81F1-A80BDFA6F8A9} = {867EB36D-7893-444D-900D-29733E8E2636}
176169
{BE0C60E2-002E-42A0-A1D1-3BB9AE90D607} = {867EB36D-7893-444D-900D-29733E8E2636}
177170
{FB679533-9E9A-416B-BDE6-CC2D5EE52D49} = {DD89D86B-68DA-4EB0-8EBC-60DE3DC2B084}

SafeguardDotNet/Event/SafeguardEventListener.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ namespace OneIdentity.SafeguardDotNet.Event;
88
using System.Net.Http;
99
using System.Net.Security;
1010
using System.Security;
11+
using System.Text.Json;
1112
using System.Threading.Tasks;
1213

1314
using Microsoft.AspNetCore.SignalR.Client;
15+
using Microsoft.Extensions.DependencyInjection;
1416

1517
using OneIdentity.SafeguardDotNet.A2A;
18+
using OneIdentity.SafeguardDotNet.Serialization;
1619

1720
using Serilog;
1821

@@ -246,11 +249,16 @@ public void Start()
246249
return message;
247250
};
248251
})
252+
.AddJsonProtocol(options =>
253+
{
254+
options.PayloadSerializerOptions.TypeInfoResolver = SafeguardJsonContext.Default;
255+
})
249256
.Build();
250257

251258
try
252259
{
253-
_signalrConnection.On("NotifyEventAsync", (object message) => HandleEvent(message.ToString()));
260+
// Typed JsonElement keeps SignalR off the reflection path so this works when consumers publish AOT.
261+
_signalrConnection.On<JsonElement>("NotifyEventAsync", message => HandleEvent(message.GetRawText()));
254262
_signalrConnection.Closed += _signalrConnection_Closed;
255263

256264
_signalrConnection.StartAsync().Wait();

SafeguardDotNet/SafeguardDotNet.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<TargetFramework>netstandard2.0</TargetFramework>
@@ -47,6 +47,7 @@ Bug Fixes:
4747

4848
<ItemGroup>
4949
<InternalsVisibleTo Include="SafeguardDotNet.SerializationTests" />
50+
<InternalsVisibleTo Include="SafeguardDotNetA2aTool" />
5051
</ItemGroup>
5152

5253
<ItemGroup>

SafeguardDotNet/Serialization/SafeguardJson.cs

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ namespace OneIdentity.SafeguardDotNet.Serialization;
44

55
using System;
66
using System.Text.Json;
7+
using System.Text.Json.Serialization.Metadata;
78

89
/// <summary>
910
/// Centralized facade for all JSON serialization/deserialization in the SDK.
@@ -12,12 +13,6 @@ namespace OneIdentity.SafeguardDotNet.Serialization;
1213
/// </summary>
1314
internal static class SafeguardJson
1415
{
15-
private static readonly JsonSerializerOptions Options = new JsonSerializerOptions
16-
{
17-
PropertyNameCaseInsensitive = true,
18-
TypeInfoResolver = SafeguardJsonContext.Default,
19-
};
20-
2116
/// <summary>
2217
/// Serializes a value using the source-generated context.
2318
/// </summary>
@@ -26,20 +21,28 @@ internal static class SafeguardJson
2621
/// <returns>A JSON string representation of the value.</returns>
2722
public static string Serialize<T>(T value)
2823
{
29-
var typeInfo = SafeguardJsonContext.Default.GetTypeInfo(typeof(T))
30-
?? throw new InvalidOperationException($"Type {typeof(T).Name} is not registered in SafeguardJsonContext.");
31-
return JsonSerializer.Serialize(value, typeInfo);
24+
return JsonSerializer.Serialize(value, GetTypeInfo<T>());
3225
}
3326

3427
/// <summary>
3528
/// Deserializes JSON into the specified type using the source-generated context.
29+
/// Throws <see cref="SafeguardDotNetException"/> if the input is empty or deserializes to null,
30+
/// so callers fail with an actionable error instead of an opaque <see cref="NullReferenceException"/>.
3631
/// </summary>
3732
/// <typeparam name="T">The type to deserialize into.</typeparam>
3833
/// <param name="json">The JSON string to deserialize.</param>
3934
/// <returns>The deserialized object.</returns>
4035
public static T Deserialize<T>(string json)
4136
{
42-
return (T)JsonSerializer.Deserialize(json, typeof(T), Options);
37+
if (string.IsNullOrWhiteSpace(json))
38+
{
39+
throw new SafeguardDotNetException(
40+
$"Cannot deserialize empty response into {typeof(T).Name}.");
41+
}
42+
43+
var result = JsonSerializer.Deserialize(json, GetTypeInfo<T>());
44+
return result ?? throw new SafeguardDotNetException(
45+
$"Deserialization of {typeof(T).Name} produced null. Response body: {json}");
4346
}
4447

4548
/// <summary>
@@ -49,4 +52,12 @@ public static T Deserialize<T>(string json)
4952
/// <param name="json">The JSON string to parse.</param>
5053
/// <returns>A parsed <see cref="JsonDocument"/>.</returns>
5154
public static JsonDocument Parse(string json) => JsonDocument.Parse(json);
55+
56+
private static JsonTypeInfo<T> GetTypeInfo<T>()
57+
{
58+
return SafeguardJsonContext.Default.GetTypeInfo(typeof(T)) is JsonTypeInfo<T> typeInfo
59+
? typeInfo
60+
: throw new InvalidOperationException(
61+
$"Type {typeof(T).Name} is not registered in {nameof(SafeguardJsonContext)}.");
62+
}
5263
}

SafeguardDotNet/Serialization/SafeguardJsonContext.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace OneIdentity.SafeguardDotNet.Serialization;
44

55
using System.Collections.Generic;
6+
using System.Text.Json;
67
using System.Text.Json.Serialization;
78

89
using OneIdentity.SafeguardDotNet.A2A;
@@ -11,6 +12,7 @@ namespace OneIdentity.SafeguardDotNet.Serialization;
1112
/// Source-generated JSON serializer context for all types that require serialization in the SDK.
1213
/// Guarantees AOT-compatible, reflection-free serialization at compile time.
1314
/// </summary>
15+
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
1416
[JsonSerializable(typeof(BrokeredAccessRequest))]
1517
[JsonSerializable(typeof(A2ARetrievableAccount))]
1618
[JsonSerializable(typeof(ApiKeySecret))]
@@ -23,6 +25,7 @@ namespace OneIdentity.SafeguardDotNet.Serialization;
2325
[JsonSerializable(typeof(List<A2ARetrievableAccount>))]
2426
[JsonSerializable(typeof(List<ApiKeySecret>))]
2527
[JsonSerializable(typeof(string))]
28+
[JsonSerializable(typeof(JsonElement))]
2629
internal partial class SafeguardJsonContext : JsonSerializerContext
2730
{
2831
}

Test/SafeguardDotNetA2aTool/Program.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
using OneIdentity.SafeguardDotNet;
1111
using OneIdentity.SafeguardDotNet.A2A;
12+
using OneIdentity.SafeguardDotNet.Serialization;
1213

1314
using SafeguardDotNetA2aTool;
1415

@@ -122,7 +123,8 @@ void Execute(ToolOptions opts)
122123
var accounts = string.IsNullOrEmpty(opts.Filter)
123124
? context.GetRetrievableAccounts()
124125
: context.GetRetrievableAccounts(opts.Filter);
125-
Log.Information(System.Text.Json.JsonSerializer.Serialize(accounts));
126+
var accountList = accounts as List<A2ARetrievableAccount> ?? [.. accounts];
127+
Log.Information(SafeguardJson.Serialize(accountList));
126128
}
127129

128130
if (opts.PrivateKey)

0 commit comments

Comments
 (0)