Skip to content

Commit 0e27444

Browse files
committed
Fix settings recovery edge cases
1 parent fa5417c commit 0e27444

2 files changed

Lines changed: 270 additions & 135 deletions

File tree

Lines changed: 193 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
using System.Reflection;
12
using System.Text;
23
using System.Text.Json;
34
using System.Text.Json.Nodes;
5+
using System.Text.Json.Serialization;
46
using Microsoft.Extensions.Logging;
57
using StabilityMatrix.Core.Models.Settings;
68

@@ -11,6 +13,11 @@ namespace StabilityMatrix.Core.Services;
1113
/// </summary>
1214
public 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

Comments
 (0)