Skip to content

Commit 6102519

Browse files
committed
Detect duplicate keys in decoded JSON objects
Closes #433.
1 parent 25637fd commit 6102519

3 files changed

Lines changed: 80 additions & 30 deletions

File tree

conformance/exemptions.txt

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,6 @@
2020
# JSON extension field names are not supported yet.
2121
Recommended.Proto2.JsonInput.FieldNameExtension.Validator
2222

23-
# Rejecting JSON objects with duplicate keys (including the same field spelled in two different
24-
# casings, e.g. "optionalInt32" and "optional_int32") requires a JSON parser that surfaces
25-
# duplicate keys, which neither Jason nor the built-in JSON module does. The pinned protoc accepts
26-
# duplicates; Protobuf's `main` requires them to be rejected. These are "Recommended".
27-
Recommended.Proto2.JsonInput.FieldNameDuplicate # only-on: latest
28-
Recommended.Proto2.JsonInput.FieldNameDuplicateDifferentCasing1 # only-on: latest
29-
Recommended.Proto2.JsonInput.FieldNameDuplicateDifferentCasing2 # only-on: latest
30-
Recommended.Proto3.JsonInput.FieldNameDuplicate # only-on: latest
31-
Recommended.Proto3.JsonInput.FieldNameDuplicateDifferentCasing1 # only-on: latest
32-
Recommended.Proto3.JsonInput.FieldNameDuplicateDifferentCasing2 # only-on: latest
33-
3423
# The proto2-only MessageSet wire format is not implemented. These tests do not exist in the
3524
# pinned Protobuf dependency yet.
3625
Recommended.Proto2.ProtobufInput.ValidMessageSetEncoding.SubmessageEncoding.NotUnknown.ProtobufOutput # only-on: latest

lib/protobuf/json/json_library.ex

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,67 @@
11
defmodule Protobuf.JSON.JSONLibrary do
22
@moduledoc false
3-
# Uses `JSON` for Elixir >= 1.18, Jason if Elixir < 1.18 and Jason available,
4-
# or returns error otherwise
53

6-
cond do
7-
Code.ensure_loaded?(JSON) ->
8-
def encode_to_iodata(encodable) do
4+
alias Protobuf.JSON.{DecodeError, EncodeError, Object}
5+
6+
# Uses `JSON` for encoding on Elixir >= 1.18 because it supports the Elixir encoder protocol,
7+
# Jason otherwise.
8+
9+
def encode_to_iodata(encodable) do
10+
cond do
11+
Code.ensure_loaded?(JSON) ->
912
try do
10-
{:ok, JSON.encode_to_iodata!(encodable)}
13+
{:ok, apply(JSON, :encode_to_iodata!, [encodable])}
1114
rescue
1215
exception ->
1316
{:error, exception}
1417
end
15-
end
1618

17-
def decode(data) do
18-
case JSON.decode(data) do
19-
{:ok, decoded} -> {:ok, decoded}
20-
{:error, error} -> {:error, Protobuf.JSON.DecodeError.new(error)}
21-
end
22-
end
19+
Code.ensure_loaded?(Jason) ->
20+
apply(Jason, :encode_to_iodata, [encodable])
21+
22+
true ->
23+
{:error, EncodeError.new(:no_json_lib)}
24+
end
25+
end
26+
27+
def decode(data) do
28+
cond do
29+
Code.ensure_loaded?(JSON) and function_exported?(JSON, :decode, 3) ->
30+
decode_with_elixir_json(data)
31+
32+
Code.ensure_loaded?(Jason) ->
33+
apply(Jason, :decode, [data])
34+
35+
true ->
36+
{:error, DecodeError.new(:no_json_lib)}
37+
end
38+
end
39+
40+
defp decode_with_elixir_json(data) do
41+
case apply(JSON, :decode, [data, nil, json_decoders()]) do
42+
{decoded, nil, rest} -> finalize_decode(decoded, rest)
43+
{:error, reason} -> {:error, DecodeError.new(reason)}
44+
end
45+
end
2346

24-
Code.ensure_loaded?(Jason) ->
25-
def encode_to_iodata(encodable), do: Jason.encode_to_iodata(encodable)
26-
def decode(data), do: Jason.decode(data)
47+
defp finalize_decode(decoded, rest) do
48+
if json_whitespace?(rest) do
49+
{:ok, decoded}
50+
else
51+
{:error, DecodeError.new({:trailing_data, rest})}
52+
end
53+
end
2754

28-
true ->
29-
def encode_to_iodata(_), do: {:error, EncodeError.new(:no_json_lib)}
30-
def decode(_), do: {:error, EncodeError.new(:no_json_lib)}
55+
defp json_decoders do
56+
[object_finish: &object_finish/2]
3157
end
58+
59+
defp object_finish(members, old_acc), do: {Object.new(Enum.reverse(members)), old_acc}
60+
61+
defp json_whitespace?(<<>>), do: true
62+
63+
defp json_whitespace?(<<byte, rest::binary>>) when byte in [?\s, ?\t, ?\n, ?\r],
64+
do: json_whitespace?(rest)
65+
66+
defp json_whitespace?(_rest), do: false
3267
end

lib/protobuf/json/object.ex

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
defmodule Protobuf.JSON.Object do
2+
@moduledoc false
3+
4+
# This is an internal structure that we use to avoid building maps right away
5+
# when decoding JSON; with maps, we cannot detect duplicate keys (by definition).
6+
7+
defstruct [:members]
8+
9+
@type t() :: %__MODULE__{members: [{binary(), term()}]}
10+
11+
@spec new([{binary(), term()}]) :: t()
12+
def new(members) when is_list(members) do
13+
%__MODULE__{members: members}
14+
end
15+
16+
@spec to_map(t()) :: %{optional(binary()) => term()}
17+
def to_map!(%__MODULE__{members: members}) do
18+
Enum.reduce(members, %{}, fn {key, value}, acc ->
19+
if Map.has_key?(acc, key) do
20+
throw({:duplicated_json_key, key})
21+
else
22+
Map.put(acc, key, value)
23+
end
24+
end)
25+
end
26+
end

0 commit comments

Comments
 (0)