1+ using System . Reflection ;
12using System . Text ;
23using System . Text . Json ;
34using System . Text . Json . Nodes ;
5+ using System . Text . Json . Serialization ;
46using Microsoft . Extensions . Logging ;
57using StabilityMatrix . Core . Models . Settings ;
68
@@ -11,6 +13,11 @@ namespace StabilityMatrix.Core.Services;
1113/// </summary>
1214public static class SettingsJsonSanitizer
1315{
16+ private static readonly Dictionary < string , PropertyInfo > SettingsProperties = typeof ( Settings )
17+ . GetProperties ( BindingFlags . Public | BindingFlags . Instance )
18+ . Where ( property => property . CanWrite && property . GetCustomAttribute < JsonIgnoreAttribute > ( ) is null )
19+ . ToDictionary ( GetJsonPropertyName , StringComparer . OrdinalIgnoreCase ) ;
20+
1421 /// <summary>
1522 /// Strips null bytes (0x00) from raw file content.
1623 /// </summary>
@@ -29,96 +36,10 @@ public static byte[] SanitizeBytes(byte[] rawBytes)
2936 /// </summary>
3037 public static string TryFixBraces ( string jsonText )
3138 {
32- var stack = new Stack < char > ( ) ;
33- var inString = false ;
34- var escaped = false ;
35-
36- foreach ( var c in jsonText )
37- {
38- if ( escaped )
39- {
40- escaped = false ;
41- continue ;
42- }
43-
44- if ( c == '\\ ' && inString )
45- {
46- escaped = true ;
47- continue ;
48- }
49-
50- if ( c == '"' )
51- {
52- inString = ! inString ;
53- continue ;
54- }
55-
56- if ( inString )
57- continue ;
58-
59- switch ( c )
60- {
61- case '{' :
62- stack . Push ( '}' ) ;
63- break ;
64- case '[' :
65- stack . Push ( ']' ) ;
66- break ;
67- case '}' or ']' when stack . Count > 0 :
68- stack . Pop ( ) ;
69- break ;
70- }
71- }
72-
73- if ( stack . Count == 0 )
74- return jsonText ;
75-
76- // Trim trailing garbage after the last valid content
77- var trimmed = jsonText . TrimEnd ( ) ;
78-
79- // If we end in the middle of a value (e.g. truncated number or string),
80- // trim back to the last structural character
81- if ( trimmed . Length > 0 )
82- {
83- var lastChar = trimmed [ ^ 1 ] ;
84- if ( lastChar != '"' && lastChar != '}' && lastChar != ']'
85- && ! char . IsDigit ( lastChar ) && lastChar != 'e' && lastChar != 'l'
86- && lastChar != 's' && lastChar != ',' )
87- {
88- var lastSafe = trimmed . LastIndexOfAny ( [ ',' , '}' , ']' , '{' , '[' ] ) ;
89- if ( lastSafe > 0 )
90- {
91- trimmed = trimmed [ ..( lastSafe + 1 ) ] ;
92- }
93- }
94-
95- // Remove trailing comma before we add closing brackets
96- trimmed = trimmed . TrimEnd ( ) ;
97- if ( trimmed . EndsWith ( ',' ) )
98- {
99- trimmed = trimmed [ ..^ 1 ] ;
100- }
101- }
102-
103- // Re-scan trimmed text to rebuild the stack
104- stack . Clear ( ) ;
105- inString = false ;
106- escaped = false ;
107- foreach ( var c in trimmed )
108- {
109- if ( escaped ) { escaped = false ; continue ; }
110- if ( c == '\\ ' && inString ) { escaped = true ; continue ; }
111- if ( c == '"' ) { inString = ! inString ; continue ; }
112- if ( inString ) continue ;
113- switch ( c )
114- {
115- case '{' : stack . Push ( '}' ) ; break ;
116- case '[' : stack . Push ( ']' ) ; break ;
117- case '}' or ']' when stack . Count > 0 : stack . Pop ( ) ; break ;
118- }
119- }
120-
121- var sb = new StringBuilder ( trimmed ) ;
39+ var normalized = NormalizeClosures ( jsonText , out var stack , out _ , out _ ) ;
40+ var trimmed = TrimIncompleteValue ( normalized ) ;
41+ var rescanned = NormalizeClosures ( trimmed , out stack , out var inString , out var escaped ) ;
42+ var sb = new StringBuilder ( rescanned ) ;
12243
12344 // If truncated inside a string literal, close it first
12445 if ( inString )
@@ -159,7 +80,10 @@ public static string TryFixBraces(string jsonText)
15980 }
16081 catch ( JsonException ex )
16182 {
162- logger ? . LogWarning ( ex , "Sanitized text still failed to deserialize, attempting property-level recovery" ) ;
83+ logger ? . LogWarning (
84+ ex ,
85+ "Sanitized text still failed to deserialize, attempting property-level recovery"
86+ ) ;
16387 }
16488
16589 // Step 3: Property-level recovery using JsonNode
@@ -179,7 +103,7 @@ public static string TryFixBraces(string jsonText)
179103 documentOptions : new JsonDocumentOptions
180104 {
181105 AllowTrailingCommas = true ,
182- CommentHandling = JsonCommentHandling . Skip
106+ CommentHandling = JsonCommentHandling . Skip ,
183107 }
184108 ) ;
185109 }
@@ -200,7 +124,7 @@ public static string TryFixBraces(string jsonText)
200124 documentOptions : new JsonDocumentOptions
201125 {
202126 AllowTrailingCommas = true ,
203- CommentHandling = JsonCommentHandling . Skip
127+ CommentHandling = JsonCommentHandling . Skip ,
204128 }
205129 ) ;
206130 }
@@ -217,25 +141,188 @@ public static string TryFixBraces(string jsonText)
217141 return null ;
218142 }
219143
220- // Re-serialize the cleaned node tree and attempt typed deserialization.
221- // JsonNode.Parse with lenient options may accept JSON that the typed deserializer
222- // can handle, and any properties with incompatible types will get their defaults
223- // from the Settings class property initializers.
224- try
144+ var settings = new Settings ( ) ;
145+ var recoveredPropertyCount = 0 ;
146+
147+ foreach ( var property in rootObject )
225148 {
226- var cleanedJson = rootObject . ToJsonString ( ) ;
227- var settings = JsonSerializer . Deserialize ( cleanedJson , SettingsSerializerContext . Default . Settings ) ;
228- if ( settings is not null )
149+ if ( property . Value is null )
150+ continue ;
151+
152+ if ( ! SettingsProperties . TryGetValue ( property . Key , out var targetProperty ) )
153+ continue ;
154+
155+ if ( ! TryDeserializePropertyValue ( property . Value , targetProperty . PropertyType , out var value ) )
229156 {
230- logger ? . LogInformation ( "Settings recovered via property-level recovery" ) ;
231- return settings ;
157+ logger ? . LogWarning (
158+ "Skipping corrupted settings property {PropertyName} during recovery" ,
159+ property . Key
160+ ) ;
161+ continue ;
232162 }
163+
164+ targetProperty . SetValue ( settings , value ) ;
165+ recoveredPropertyCount ++ ;
233166 }
234- catch ( JsonException ex )
167+
168+ logger ? . LogInformation (
169+ "Settings recovered via property-level recovery with {RecoveredPropertyCount} properties" ,
170+ recoveredPropertyCount
171+ ) ;
172+ return settings ;
173+ }
174+
175+ private static string NormalizeClosures (
176+ string jsonText ,
177+ out Stack < char > stack ,
178+ out bool inString ,
179+ out bool escaped
180+ )
181+ {
182+ stack = new Stack < char > ( ) ;
183+ inString = false ;
184+ escaped = false ;
185+ var normalized = new StringBuilder ( jsonText . Length + 8 ) ;
186+
187+ foreach ( var c in jsonText )
235188 {
236- logger ? . LogWarning ( ex , "Property-level recovery failed during final deserialization" ) ;
189+ if ( escaped )
190+ {
191+ normalized . Append ( c ) ;
192+ escaped = false ;
193+ continue ;
194+ }
195+
196+ if ( c == '\\ ' && inString )
197+ {
198+ normalized . Append ( c ) ;
199+ escaped = true ;
200+ continue ;
201+ }
202+
203+ if ( c == '"' )
204+ {
205+ normalized . Append ( c ) ;
206+ inString = ! inString ;
207+ continue ;
208+ }
209+
210+ if ( inString )
211+ {
212+ normalized . Append ( c ) ;
213+ continue ;
214+ }
215+
216+ switch ( c )
217+ {
218+ case '{' :
219+ normalized . Append ( c ) ;
220+ stack . Push ( '}' ) ;
221+ break ;
222+ case '[' :
223+ normalized . Append ( c ) ;
224+ stack . Push ( ']' ) ;
225+ break ;
226+ case '}'
227+ or ']' :
228+ ConsumeClosingToken ( c , stack , normalized ) ;
229+ break ;
230+ default :
231+ normalized . Append ( c ) ;
232+ break ;
233+ }
237234 }
238235
239- return null ;
236+ return normalized . ToString ( ) ;
237+ }
238+
239+ private static void ConsumeClosingToken ( char token , Stack < char > stack , StringBuilder normalized )
240+ {
241+ if ( stack . Count == 0 )
242+ return ;
243+
244+ if ( stack . Peek ( ) == token )
245+ {
246+ normalized . Append ( token ) ;
247+ stack . Pop ( ) ;
248+ return ;
249+ }
250+
251+ if ( ! stack . Contains ( token ) )
252+ return ;
253+
254+ while ( stack . Count > 0 && stack . Peek ( ) != token )
255+ {
256+ normalized . Append ( stack . Pop ( ) ) ;
257+ }
258+
259+ if ( stack . Count == 0 )
260+ return ;
261+
262+ normalized . Append ( token ) ;
263+ stack . Pop ( ) ;
264+ }
265+
266+ private static string TrimIncompleteValue ( string jsonText )
267+ {
268+ var trimmed = jsonText . TrimEnd ( ) ;
269+ if ( trimmed . Length == 0 )
270+ return trimmed ;
271+
272+ var lastChar = trimmed [ ^ 1 ] ;
273+ if (
274+ lastChar != '"'
275+ && lastChar != '}'
276+ && lastChar != ']'
277+ && ! char . IsDigit ( lastChar )
278+ && lastChar != 'e'
279+ && lastChar != 'l'
280+ && lastChar != 's'
281+ && lastChar != ','
282+ )
283+ {
284+ var lastSafe = trimmed . LastIndexOfAny ( [ ',' , '}' , ']' , '{' , '[' ] ) ;
285+ if ( lastSafe > 0 )
286+ {
287+ trimmed = trimmed [ ..( lastSafe + 1 ) ] ;
288+ }
289+ }
290+
291+ trimmed = trimmed . TrimEnd ( ) ;
292+ if ( trimmed . EndsWith ( ',' ) )
293+ {
294+ trimmed = trimmed [ ..^ 1 ] ;
295+ }
296+
297+ return trimmed ;
298+ }
299+
300+ private static string GetJsonPropertyName ( PropertyInfo property )
301+ {
302+ return property . GetCustomAttribute < JsonPropertyNameAttribute > ( ) ? . Name ?? property . Name ;
303+ }
304+
305+ private static bool TryDeserializePropertyValue ( JsonNode node , Type propertyType , out object ? value )
306+ {
307+ try
308+ {
309+ value = JsonSerializer . Deserialize (
310+ node . ToJsonString ( ) ,
311+ propertyType ,
312+ SettingsSerializerContext . Default . Options
313+ ) ;
314+
315+ if ( value is null && propertyType . IsValueType && Nullable . GetUnderlyingType ( propertyType ) is null )
316+ {
317+ return false ;
318+ }
319+
320+ return true ;
321+ }
322+ catch ( JsonException )
323+ {
324+ value = null ;
325+ return false ;
326+ }
240327 }
241328}
0 commit comments