DrinkMeAlice.Json (drink_me_alice v0.1.0)

Copy Markdown

Bounded decoder/encoder boundary for SurrealDB JSON-RPC payloads.

Surreal replies are ordinary JSON (floats may appear, integer tokens may exceed i64, object key order is unspecified), so this decoder runs OTP :json.decode/3 behind explicit structural and decoded-term limits:

  • input bytes bounded by the caller (the WebSocket frame guard already caps a message at max_bytes, default 1048576); checked again here so the bound does not depend on transport state;
  • container depth <= 16 (RPC envelopes plus one level of record content stay far below this);
  • object keys <= 128 per object, duplicate decoded keys rejected;
  • aggregate document elements <= 65536;
  • each decoded string <= 65536 bytes and valid UTF-8;
  • integer tokens <= 40 digits excluding the optional sign (Erlang integers are exact at any width; the bound stops digit-string amplification);
  • float tokens <= 64 bytes, decoded with Float semantics; the dialect may legally carry floats, so they are accepted and counted rather than rejected by shape.

Two enforcement layers, honestly labeled:

  1. preflight/1 — a single linear, allocation-free scan over the raw bytes before :json.decode runs. It counts every string literal's DECODED UTF-8 size (escape-aware: \uXXXX pairs count at their recombined width, so a 40 KiB value of \" escapes — whose raw span doubles its decoded size — passes while an oversized decoded value is rejected) and container nesting, rejecting violations before OTP has allocated anything for them. This is the pre-allocation bound — the string callback below is only a post-construction backstop, and is documented as such.
  2. Decode callbacks — container start/push and every scalar enforce the bounds while decoding, aborting before an unbounded term is built. term_words/1 and process-memory measurements in the tests supply the decode-amplification evidence the card requires.

Encoding (encode/1) accepts only canonical terms under the same structural bounds, checked before OTP :json.encode/1 runs.

Summary

Functions

The declared structural/decoded-term bounds for this profile.

Decode one Surreal-dialect JSON document under the bounded profile.

Encode an outbound message. Only canonical terms travel: string-keyed maps, lists, valid UTF-8 binaries, i64 integers, booleans and nil — no floats, atoms or structs (send those as strings with a SurrealQL cast). The decode bounds apply (depth, keys, string bytes, elements), checked before OTP :json.encode/1 runs; output is at most 1048576 bytes.

Configured input byte bound (the 1 MiB message bound).

Lexical pre-pass over raw input before :json.decode allocates. Returns :ok or {:error, %Error{}}. Enforces, allocation-free

Heap size of a decoded term in machine words (:erts_debug.flat_size, 8 bytes/word on this target). Honest scope: this counts heap words only — off-heap binary bytes (>64B refc binaries) are excluded by flat_size. RSS/binary-allocation evidence is a separate Q01 measurement; this helper must not be cited as covering it.

Types

json()

@type json() :: map() | list() | binary() | integer() | float() | boolean() | nil

Functions

bounds()

@spec bounds() :: map()

The declared structural/decoded-term bounds for this profile.

decode(input)

@spec decode(binary()) :: {:ok, json()} | {:error, DrinkMeAlice.Error.t()}

Decode one Surreal-dialect JSON document under the bounded profile.

{:ok, term} | {:error, %Error{}} — malformed_message for syntax violations, bounded_limit_exceeded for bound breaches, both with a bounded tag and never an inspected input fragment.

encode(value)

@spec encode(term()) :: {:ok, binary()} | {:error, DrinkMeAlice.Error.t()}

Encode an outbound message. Only canonical terms travel: string-keyed maps, lists, valid UTF-8 binaries, i64 integers, booleans and nil — no floats, atoms or structs (send those as strings with a SurrealQL cast). The decode bounds apply (depth, keys, string bytes, elements), checked before OTP :json.encode/1 runs; output is at most 1048576 bytes.

max_bytes()

@spec max_bytes() :: pos_integer()

Configured input byte bound (the 1 MiB message bound).

preflight(input)

@spec preflight(binary()) :: :ok | {:error, DrinkMeAlice.Error.t()}

Lexical pre-pass over raw input before :json.decode allocates. Returns :ok or {:error, %Error{}}. Enforces, allocation-free:

  • every string literal's decoded UTF-8 size <= 65536 bytes — counted while scanning, so a 40 KiB value of \" escapes (whose raw span is double its decoded size) passes while an oversized decoded value is rejected before OTP allocates it. Escape widths: \X → 1 byte; \uXXXX → the code point's UTF-8 width (1–3), with a high surrogate held pending so a \uD800..\uDBFF + \uDC00..\uDFFF pair counts exactly 4. An unterminated string keeps counting to end of input — oversized decoded content is rejected at the bound even though the malformed literal would fail decode anyway;
  • container nesting depth <= 16.

This is a bound check, not a validator — malformed JSON still reaches the decoder, which rejects it through the same typed error surface.

term_words(term)

@spec term_words(term()) :: non_neg_integer()

Heap size of a decoded term in machine words (:erts_debug.flat_size, 8 bytes/word on this target). Honest scope: this counts heap words only — off-heap binary bytes (>64B refc binaries) are excluded by flat_size. RSS/binary-allocation evidence is a separate Q01 measurement; this helper must not be cited as covering it.