Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/Argon.FSharp/FSharpMapConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s

public static void WriteMap<T, K>(JsonWriter writer, FSharpMap<T, K> value, JsonSerializer serializer)
where T : notnull =>
serializer.Serialize(writer, value.ToDictionary(_ => _.Key, _ => _.Value));
// wrap rather than copy: ReadOnlyDictionary is an O(1) view over the map. Its runtime type is not
// FSharpMap<,> so it routes through the dictionary contract instead of re-entering this converter,
// whereas ToDictionary allocated a whole Dictionary and rehashed every entry up front.
serializer.Serialize(writer, new System.Collections.ObjectModel.ReadOnlyDictionary<T, K>(value));

public override object? ReadJson(JsonReader reader, Type type, object? existingValue, JsonSerializer serializer)
{
Expand Down
42 changes: 31 additions & 11 deletions src/Argon.JsonPath/BooleanQueryExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,32 @@ class BooleanQueryExpression(QueryOperator @operator, object left, object? right
readonly JToken[]? leftConstant = left is JToken leftToken ? [leftToken] : null;
readonly JToken[]? rightConstant = right is JToken rightToken ? [rightToken] : null;

// the regex operand is parsed once here instead of re-sliced and re-parsed per candidate token;
// a malformed pattern is surfaced as a JsonException at parse time rather than an
// ArgumentOutOfRangeException during evaluation
readonly (string Pattern, RegexOptions Options)? regex =
@operator == QueryOperator.RegexEquals ? ParseRegex(right) : null;

static (string Pattern, RegexOptions Options) ParseRegex(object? right)
{
if (right is not JValue {Value: string regexText})
{
throw new JsonException("A regex query operator '=~' requires a regex operand.");
}

var patternOptionDelimiterIndex = regexText.LastIndexOf('/');

// a valid pattern is enclosed in slashes: /pattern/ or /pattern/options
if (regexText.Length < 2 || regexText[0] != '/' || patternOptionDelimiterIndex < 1)
{
throw new JsonException($"Path regex must be enclosed in slashes, for example /pattern/: {regexText}");
}

var pattern = regexText.Substring(1, patternOptionDelimiterIndex - 1);
var options = MiscellaneousUtils.GetRegexOptions(regexText.AsSpan(patternOptionDelimiterIndex + 1));
return (pattern, options);
}

static IEnumerable<JToken> GetFilterResult(JToken root, JToken t, object? o)
{
if (o is List<PathFilter> pathFilters)
Expand Down Expand Up @@ -80,7 +106,7 @@ bool MatchTokens(JToken? leftResult, JToken? rightResult, JsonSelectSettings set
switch (Operator)
{
case QueryOperator.RegexEquals:
if (RegexEquals(leftValue, rightValue, settings))
if (RegexEquals(leftValue, settings))
{
return true;
}
Expand Down Expand Up @@ -161,22 +187,16 @@ QueryOperator.Exists or
return false;
}

static bool RegexEquals(JValue input, JValue pattern, JsonSelectSettings settings)
bool RegexEquals(JValue input, JsonSelectSettings settings)
{
if (input.Type != JTokenType.String ||
pattern.Type != JTokenType.String)
if (input.Type != JTokenType.String)
{
return false;
}

var regexText = ((string) pattern.GetValue()).AsSpan();
var patternOptionDelimiterIndex = regexText.LastIndexOf('/');

var patternText = regexText.Slice(1, patternOptionDelimiterIndex - 1);
var optionsText = regexText[(patternOptionDelimiterIndex + 1)..];

var (pattern, options) = regex!.Value;
var timeout = settings.RegexMatchTimeout ?? Regex.InfiniteMatchTimeout;
return Regex.IsMatch((string) input.GetValue(), patternText.ToString(), MiscellaneousUtils.GetRegexOptions(optionsText), timeout);
return Regex.IsMatch((string) input.GetValue(), pattern, options, timeout);
}

static bool EqualsWithStringCoercion(JValue value, JValue queryValue)
Expand Down
122 changes: 68 additions & 54 deletions src/Argon.JsonPath/JPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ void ParseMain()

if (expression[currentIndex] == '$')
{
if (expression.Length == 1)
// '$' is the last character (guard against leading whitespace, where currentIndex > 0)
if (currentIndex == expression.Length - 1)
{
return;
}
Expand Down Expand Up @@ -186,6 +187,9 @@ PathFilter ParseIndexer(char indexerOpenChar, bool scan)

EatWhitespace();

// EatWhitespace can consume the remaining characters (e.g. "$[ ")
EnsureLength("Path ended with open indexer.");

if (expression[currentIndex] == '\'')
{
return ParseQuotedField(indexerCloseChar, scan);
Expand Down Expand Up @@ -387,6 +391,9 @@ PathFilter ParseQuery(char indexerCloseChar, bool scan)
EnsureLength("Path ended with open indexer.");
EatWhitespace();

// EatWhitespace can consume the remaining characters
EnsureLength("Path ended with open indexer.");

if (this.expression[currentIndex] != indexerCloseChar)
{
throw new JsonException($"Unexpected character while parsing path indexer: {this.expression[currentIndex]}");
Expand Down Expand Up @@ -449,90 +456,97 @@ object ParseSide()
return new JValue(value);
}

// a value that ran to the end of the expression (e.g. "$[?(1") leaves currentIndex at the end;
// report it as an open query rather than indexing past the end
EnsureLength("Path ended with open query.");

throw CreateUnexpectedCharacterException();
}

QueryExpression ParseExpression()
{
QueryExpression? rootExpression = null;
CompositeExpression? parentExpression = null;
// '&&' binds tighter than '||': collect '&&'-joined terms into an And group, then
// join the And groups with '||' into an Or. So "a && b || c" parses as "(a && b) || c".
var orGroups = new List<QueryExpression>();
var andGroup = new List<QueryExpression>();

while (currentIndex < expression.Length)
while (true)
{
var left = ParseSide();
object? right = null;

QueryOperator op;
if (expression[currentIndex] == ')'
|| expression[currentIndex] == '|'
|| expression[currentIndex] == '&')
{
op = QueryOperator.Exists;
}
else
{
op = ParseOperator();
// guards re-entry after consuming a '&&'/'||' that was the last thing in the expression
EnsureLength("Path ended with open query.");

right = ParseSide();
}
andGroup.Add(ParseBooleanExpression());

var booleanExpression = new BooleanQueryExpression(op, left, right);
// ParseBooleanExpression always leaves currentIndex on an in-bounds terminator
var currentChar = expression[currentIndex];

if (expression[currentIndex] == ')')
if (currentChar == '&')
{
if (parentExpression != null)
if (!Match("&&"))
{
parentExpression.Expressions.Add(booleanExpression);
return rootExpression!;
throw CreateUnexpectedCharacterException();
}

return booleanExpression;
continue;
}

if (expression[currentIndex] == '&')
// the '&&' group is complete: fold it into a single expression
orGroups.Add(Combine(andGroup, QueryOperator.And));
andGroup = [];

if (currentChar == '|')
{
if (!Match("&&"))
if (!Match("||"))
{
throw CreateUnexpectedCharacterException();
}

if (parentExpression is not {Operator: QueryOperator.And})
{
var andExpression = new CompositeExpression(QueryOperator.And);

parentExpression?.Expressions.Add(andExpression);

parentExpression = andExpression;

rootExpression ??= parentExpression;
}

parentExpression.Expressions.Add(booleanExpression);
continue;
}

if (expression[currentIndex] == '|')
if (currentChar == ')')
{
if (!Match("||"))
{
throw CreateUnexpectedCharacterException();
}
return Combine(orGroups, QueryOperator.Or);
}

if (parentExpression is not {Operator: QueryOperator.Or})
{
var orExpression = new CompositeExpression(QueryOperator.Or);
throw CreateUnexpectedCharacterException();
}
}

// wraps the expressions in a composite of the given operator, or returns the single expression as-is
static QueryExpression Combine(List<QueryExpression> expressions, QueryOperator @operator)
{
if (expressions.Count == 1)
{
return expressions[0];
}

parentExpression?.Expressions.Add(orExpression);
return new CompositeExpression(@operator)
{
Expressions = expressions
};
}

parentExpression = orExpression;
BooleanQueryExpression ParseBooleanExpression()
{
var left = ParseSide();
object? right = null;

rootExpression ??= parentExpression;
}
QueryOperator op;
if (expression[currentIndex] == ')'
|| expression[currentIndex] == '|'
|| expression[currentIndex] == '&')
{
op = QueryOperator.Exists;
}
else
{
op = ParseOperator();

parentExpression.Expressions.Add(booleanExpression);
}
right = ParseSide();
}

throw new JsonException("Path ended with open query.");
return new(op, left, right);
}

bool TryParseValue(out object? value)
Expand Down
21 changes: 21 additions & 0 deletions src/Argon/Converters/ConverterReaderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) 2007 James Newton-King. All rights reserved.
// Use of this source code is governed by The MIT License,
// as found in the license.md file.

namespace Argon;

static class ConverterReaderExtensions
{
// Reads the current token as a string, throwing a JsonSerializationException that carries the
// JSON path/line info when the token is not a string. A direct (string)reader.Value cast would
// otherwise surface a raw InvalidCastException (or null-reference) with no context.
public static string GetConverterString(this JsonReader reader, Type targetType)
{
if (reader.Value is string value)
{
return value;
}

throw JsonSerializationException.Create(reader, $"Unexpected token {reader.TokenType} when parsing a {targetType.Name}. Expected a string.");
}
}
2 changes: 1 addition & 1 deletion src/Argon/Converters/DriveInfoConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ public override void WriteJson(JsonWriter writer, DriveInfo value, JsonSerialize
writer.WriteValue(value.Name.Replace('\\', '/'));

public override DriveInfo ReadJson(JsonReader reader, Type type, DriveInfo? existingValue, bool hasExisting, JsonSerializer serializer) =>
new(reader.StringValue);
new(reader.GetConverterString(type));
}
2 changes: 1 addition & 1 deletion src/Argon/Converters/EncodingConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ public override void WriteJson(JsonWriter writer, Encoding value, JsonSerializer
writer.WriteValue(value.WebName);

public override Encoding ReadJson(JsonReader reader, Type type, Encoding? existingValue, bool hasExisting, JsonSerializer serializer) =>
Encoding.GetEncoding(reader.StringValue);
Encoding.GetEncoding(reader.GetConverterString(type));
}
9 changes: 8 additions & 1 deletion src/Argon/Converters/PathInfoConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s

public override object? ReadJson(JsonReader reader, Type type, object? existingValue, JsonSerializer serializer)
{
if (reader.Value is not string value)
if (reader.TokenType is JsonToken.Null or JsonToken.Undefined)
{
return null;
}

// only a genuine null maps to null; a non-string token (number, bool, ...) would otherwise
// be silently dropped to null, losing data instead of reporting the mismatch
if (reader.Value is not string value)
{
throw JsonSerializationException.Create(reader, $"Unexpected token {reader.TokenType} when parsing a {type.Name}. Expected a string or null.");
}

var path = value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);

if (type == typeof(DirectoryInfo))
Expand Down
2 changes: 1 addition & 1 deletion src/Argon/Converters/StringBuilderConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ public override void WriteJson(JsonWriter writer, StringBuilder value, JsonSeria
writer.WriteValue(value);

public override StringBuilder ReadJson(JsonReader reader, Type type, StringBuilder? existingValue, bool hasExisting, JsonSerializer serializer) =>
new(reader.StringValue);
new(reader.GetConverterString(type));
}
2 changes: 1 addition & 1 deletion src/Argon/Converters/StringWriterConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ public override void WriteJson(JsonWriter writer, StringWriter value, JsonSerial
writer.WriteValue(value.GetStringBuilder());

public override StringWriter ReadJson(JsonReader reader, Type type, StringWriter? existingValue, bool hasExisting, JsonSerializer serializer) =>
new(new StringBuilder(reader.StringValue));
new(new StringBuilder(reader.GetConverterString(type)));
}
2 changes: 1 addition & 1 deletion src/Argon/Converters/TimeZoneInfoConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ public override void WriteJson(JsonWriter writer, TimeZoneInfo value, JsonSerial
writer.WriteValue(value.Id);

public override TimeZoneInfo ReadJson(JsonReader reader, Type type, TimeZoneInfo? existingValue, bool hasExisting, JsonSerializer serializer) =>
TimeZoneInfo.FindSystemTimeZoneById(reader.StringValue);
TimeZoneInfo.FindSystemTimeZoneById(reader.GetConverterString(type));
}
2 changes: 1 addition & 1 deletion src/Argon/Converters/VersionConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ public override void WriteJson(JsonWriter writer, Version value, JsonSerializer
writer.WriteValue(value.ToString());

public override Version ReadJson(JsonReader reader, Type type, Version? existingValue, bool hasExisting, JsonSerializer serializer) =>
new(reader.StringValue);
new(reader.GetConverterString(type));
}
5 changes: 4 additions & 1 deletion src/Argon/DefaultJsonNameTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public class DefaultJsonNameTable : JsonNameTable
// used to defeat hashtable DoS attack where someone passes in lots of strings that hash to the same hash code
static readonly int hashCodeRandomizer;

// dedicated lock: Add mutates the entries field (Grow replaces it), so the field
// itself cannot be the monitor - locking it would break mutual exclusion across a resize
readonly object addLock = new();
int count;
Entry[] entries;
int mask = 31;
Expand Down Expand Up @@ -76,7 +79,7 @@ public DefaultJsonNameTable() =>
/// <returns>The resolved string.</returns>
public override string Add(string key)
{
lock (entries)
lock (addLock)
{
return InnerAdd(key);
}
Expand Down
18 changes: 13 additions & 5 deletions src/Argon/JsonPosition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,22 @@ internal static string BuildPath(List<JsonPosition> positions, JsonPosition? cur
var builder = new StringBuilder(capacity);
StringWriter? writer = null;
char[]? buffer = null;
foreach (var state in positions)
try
{
state.WriteTo(builder, ref writer, ref buffer);
}
foreach (var state in positions)
{
state.WriteTo(builder, ref writer, ref buffer);
}

currentPosition?.WriteTo(builder, ref writer, ref buffer);
currentPosition?.WriteTo(builder, ref writer, ref buffer);

return builder.ToString();
return builder.ToString();
}
finally
{
// WriteTo rents a pooled buffer when escaping property names with special characters; return it
BufferUtils.ReturnBuffer(buffer);
}
}

internal static string FormatMessage(IJsonLineInfo? lineInfo, string path, string message)
Expand Down
Loading
Loading