Skip to content

Commit cd90bb7

Browse files
authored
Merge pull request #8 from pkgodara/fix-invalid-utf8-binaries
Added utf-8 validation and error messages
2 parents bac37da + 92d249f commit cd90bb7

7 files changed

Lines changed: 66 additions & 17 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
88
TORQUE_BUILD=true mix deps.get # fetch deps + force local Rust build
99
TORQUE_BUILD=true mix compile # build (includes Rust NIF compilation)
1010
TORQUE_BUILD=true mix test # run all tests
11-
mix test test/torque_test.exs:42 # run single test by line number
11+
mix test test/pointer_test.exs:42 # run single test by line number
1212
mix compile --warnings-as-errors # build with strict warnings
1313
mix format # format Elixir code
1414
mix format --check-formatted # check Elixir formatting
@@ -67,4 +67,4 @@ Inputs larger than 10 KB are automatically dispatched to dirty CPU schedulers to
6767
- `native/torque_nif/src/decoder.rs` — parse, get, get_many, decode NIFs
6868
- `native/torque_nif/src/encoder.rs` — direct term-walking JSON encoder
6969
- `native/torque_nif/src/types.rs` — sonic_rs Value → Erlang term conversion
70-
- `native/torque_nif/src/atoms.rs` — cached atoms (ok, error, no_such_field, nil)
70+
- `native/torque_nif/src/atoms.rs` — cached atoms (ok, error, nil, no_such_field, nesting_too_deep, unsupported_type, non_finite_float, invalid_key, malformed_proplist, invalid_utf8)

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,29 @@ For objects with duplicate keys, the last value wins.
116116
| atom | string |
117117
| `{keyword_list}` | object |
118118

119+
## Errors
120+
121+
Functions return `{:error, reason}` tuples (or raise `ArgumentError` for bang/iodata variants). Possible `reason` atoms:
122+
123+
### Decode / Parse
124+
125+
| Atom | Returned by | Meaning |
126+
|------|-------------|---------|
127+
| `:nesting_too_deep` | `decode/1`, `parse/1`, `get/2,3` | Document exceeds 512 nesting levels |
128+
129+
`parse/1` and `decode/1` also return `{:error, binary}` with a message from sonic-rs for malformed JSON.
130+
131+
### Encode
132+
133+
| Atom | Returned by | Meaning |
134+
|------|-------------|---------|
135+
| `:unsupported_type` | `encode/1` | Term has no JSON representation (PID, reference, port, …) |
136+
| `:invalid_utf8` | `encode/1` | Binary string or map key is not valid UTF-8 |
137+
| `:invalid_key` | `encode/1` | Map key is not an atom or binary (e.g. integer key) |
138+
| `:malformed_proplist` | `encode/1` | `{proplist}` contains a non-`{key, value}` element |
139+
| `:non_finite_float` | `encode/1` | Float is infinity or NaN (unreachable from normal BEAM code) |
140+
| `:nesting_too_deep` | `encode/1` | Term exceeds 512 nesting levels |
141+
119142
## Benchmarks
120143

121144
Apple M2 Pro, OTP 28, Elixir 1.19:

native/torque_nif/src/atoms.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ rustler::atoms! {
1212
non_finite_float,
1313
invalid_key,
1414
malformed_proplist,
15+
invalid_utf8,
1516
}

native/torque_nif/src/encoder.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ enum EncodeError {
1616
InvalidKey,
1717
MalformedProplist,
1818
DepthExceeded,
19+
InvalidUtf8,
1920
}
2021

2122
/// Read an atom's name into a stack buffer without heap allocation.
@@ -67,6 +68,7 @@ fn encode<'a>(env: Env<'a>, term: Term<'a>) -> Term<'a> {
6768
EncodeError::NonFiniteFloat => atoms::non_finite_float().as_c_arg(),
6869
EncodeError::InvalidKey => atoms::invalid_key().as_c_arg(),
6970
EncodeError::MalformedProplist => atoms::malformed_proplist().as_c_arg(),
71+
EncodeError::InvalidUtf8 => atoms::invalid_utf8().as_c_arg(),
7072
};
7173
make_tuple2(env, atoms::error().as_c_arg(), reason)
7274
}
@@ -92,6 +94,7 @@ fn encode_iodata<'a>(env: Env<'a>, term: Term<'a>) -> Term<'a> {
9294
EncodeError::NonFiniteFloat => atoms::non_finite_float().as_c_arg(),
9395
EncodeError::InvalidKey => atoms::invalid_key().as_c_arg(),
9496
EncodeError::MalformedProplist => atoms::malformed_proplist().as_c_arg(),
97+
EncodeError::InvalidUtf8 => atoms::invalid_utf8().as_c_arg(),
9598
};
9699
Term::new(env, rustler::sys::enif_raise_exception(env_raw, reason))
97100
},
@@ -165,6 +168,9 @@ fn encode_map_key(
165168
}
166169
let bin = bin.assume_init();
167170
let slice = std::slice::from_raw_parts(bin.data, bin.size);
171+
if std::str::from_utf8(slice).is_err() {
172+
return Err(EncodeError::InvalidUtf8);
173+
}
168174
escape_bytes(slice, buf);
169175
}
170176
}
@@ -215,6 +221,9 @@ fn encode_binary(
215221
}
216222
let bin = bin.assume_init();
217223
let slice = std::slice::from_raw_parts(bin.data, bin.size);
224+
if std::str::from_utf8(slice).is_err() {
225+
return Err(EncodeError::InvalidUtf8);
226+
}
218227
buf.push(b'"');
219228
escape_bytes(slice, buf);
220229
buf.push(b'"');

test/encode_test.exs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,18 @@ defmodule Torque.EncodeTest do
9696
assert [%{"id" => 1}, %{"id" => 2}] = Jason.decode!(json)
9797
end
9898

99-
test "atom values encoded as strings" do
99+
test "atom map values encoded as strings" do
100100
assert {:ok, json} = Torque.encode(%{status: :active})
101101
assert %{"status" => "active"} = Jason.decode!(json)
102102
end
103+
104+
test "invalid UTF-8 binary returns error" do
105+
assert {:error, :invalid_utf8} = Torque.encode(<<0x80>>)
106+
end
107+
108+
test "invalid UTF-8 binary map key returns error" do
109+
assert {:error, :invalid_utf8} = Torque.encode(%{<<0x80>> => "value"})
110+
end
103111
end
104112

105113
describe "encode!/1" do
@@ -108,10 +116,16 @@ defmodule Torque.EncodeTest do
108116
end
109117

110118
test "unsupported term raises" do
111-
assert_raise ArgumentError, fn ->
119+
assert_raise ArgumentError, ~r/unsupported_type/, fn ->
112120
Torque.encode!(self())
113121
end
114122
end
123+
124+
test "invalid UTF-8 binary raises" do
125+
assert_raise ArgumentError, ~r/invalid_utf8/, fn ->
126+
Torque.encode!(<<0x80>>)
127+
end
128+
end
115129
end
116130

117131
describe "encode_to_iodata/1" do
@@ -126,9 +140,15 @@ defmodule Torque.EncodeTest do
126140
end
127141

128142
test "unsupported term raises ArgumentError" do
129-
assert_raise ArgumentError, fn ->
143+
assert_raise ArgumentError, ~r/unsupported_type/, fn ->
130144
Torque.encode_to_iodata(self())
131145
end
132146
end
147+
148+
test "invalid UTF-8 binary raises ArgumentError" do
149+
assert_raise ArgumentError, ~r/invalid_utf8/, fn ->
150+
Torque.encode_to_iodata(<<0x80>>)
151+
end
152+
end
133153
end
134154
end

test/property_test.exs

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ defmodule Torque.PropertyTest do
475475

476476
test "encode_to_iodata raises at depth 513" do
477477
term = Enum.reduce(1..513, "leaf", fn _, acc -> %{"x" => acc} end)
478-
assert_raise ArgumentError, fn -> Torque.encode_to_iodata(term) end
478+
assert_raise ArgumentError, ~r/nesting_too_deep/, fn -> Torque.encode_to_iodata(term) end
479479
end
480480
end
481481

@@ -541,6 +541,13 @@ defmodule Torque.PropertyTest do
541541
assert {:error, :malformed_proplist} = Torque.encode({[{:a, :b, :c}]})
542542
end
543543

544+
# BEAM rejects non-finite floats (both arithmetic overflow and binary_to_term
545+
# validation), so this NIF code path is not reachable from Elixir.
546+
@tag :skip
547+
test "non-finite float returns non_finite_float" do
548+
assert {:error, :non_finite_float} = Torque.encode(:infinity)
549+
end
550+
544551
test "encode_to_iodata raises with unsupported_type message" do
545552
assert_raise ArgumentError, ~r/unsupported_type/, fn ->
546553
Torque.encode_to_iodata(self())
@@ -589,17 +596,6 @@ defmodule Torque.PropertyTest do
589596
end
590597
end
591598

592-
property "numeric-looking string keys work alongside integer array indexes" do
593-
check all(n <- integer(0..100)) do
594-
# Object key that looks like an integer — should be treated as a string key
595-
json = ~s({"#{n}": "string_val"})
596-
{:ok, doc} = Torque.parse(json)
597-
# Accessing "/N" on an object should still find the string key "N"
598-
result = Torque.get(doc, "/#{n}")
599-
assert match?({:ok, _}, result) or match?({:error, :no_such_field}, result)
600-
end
601-
end
602-
603599
test "path without leading slash returns no_such_field" do
604600
{:ok, doc} = Torque.parse(~s({"a":1}))
605601
assert {:error, :no_such_field} = Torque.get(doc, "a")

0 commit comments

Comments
 (0)