Wymcp. Bound
(Wymcp v0.8.3)
View Source
A value is bounded when it is rendered under wymcp's one bound — 256, read in the value's own unit: bytes for a string and for a rendering, digits for an integer, codepoints for a string inside an inspected term — so that whatever a reader is handed, a log line's field or the wire's message, is at most the bound and one line.
Two forms apply that rule and they differ on scalars alone. render/1 is
the rendering form: it always answers a string, so a fault diagnostic's
message is a JSON string whatever a tool raised, exited or threw, and a
string interpolation reads the same whatever it was handed. value/1 is
the line form: the same rule, except that an atom, a float, nil and an
integer within the bound pass as themselves, which is what a log line's
metadata promises its reader. Everything else value/1 hands to
render/1, so the rule below has one implementation and not two.
The cut is String.byte_slice/3, never a grapheme function such as
String.slice/3, for two reasons. A grapheme cut walks to the end of the
cluster it stops in, and one cluster can be the whole value: a letter
followed by a megabyte of combining marks is walked whole and returned
whole. And on OTP 28.4, String.slice/3 and String.length/1 raise on a
zero-width joiner followed by a byte no UTF-8 sequence admits — a header
value reaches these functions as the client sent it.
String.byte_slice/3 cuts the bytes and drops a malformed sequence at
either end of what it keeps — the bytes of a codepoint split there, and at
the tail a whole codepoint followed by stray continuation bytes — so a
cluster cut at the bound renders as its parts, and a header value that
opens or ends partway through a codepoint renders without those bytes. It
finds continuation bytes at the front by scanning them, so it is handed
the first 256 bytes and no more: a value opening on a megabyte of them
costs what 256 do, and one whose first 256 bytes are continuation bytes
leaves the cut nothing, which sends it down the inspected path below.
The cut repairs the ends of what it keeps and nothing between them, so a
byte no UTF-8 sequence admits in the middle of a value survives it — and a
string carrying one is no JSON string: JSON.encode!/1 raises on it, on
the request process and inside the very arm that was answering a fault. A
tool wrapping a port, a NIF or a subprocess can exit or throw with the
bytes it read, so that string is one a caller can hand this module. The
cut's result is therefore checked, and a non-empty string that fails the
check — or whose cut kept nothing of it — takes the inspected path instead,
which renders it as its bytes; the empty string is itself, not inspected. A
rendering is checked the same way, because a struct's own Inspect
implementation can write such bytes too, or ones the cut's front scan leaves
nothing of, and one that fails is inspected again as a string would be,
which renders its bytes and cannot fail: inspect/2 writes a binary it will
not show as text as its byte values. Each check reads what the cut kept, so
it costs the bound and not the value.
An integer is compared by magnitude and never rendered, because rendering
one costs time quadratic in its digits; the placeholder is fixed text,
since a digit count would cost the render it avoids. Inside a term the
visitor replaces such an integer wherever inspect/2 meets it — the whole
term, a list or tuple element, a map key or value, a struct field — and
cuts a map wider than inspect's limit before inspect would list it whole.
That limit is a budget over the whole term, so a term's depth and breadth
past it cost nothing. A list renders as a list, never as a charlist: left
to infer, inspect/2 reads a list opening on printable integers as text
and converts it whole, outside the visitor and its limit, raising on the
first element that is no codepoint. A struct with an Inspect
implementation of its own renders as that implementation makes it —
MapSet lists every member, and one that formats an integer itself
renders every digit — and one that raises reads #Inspect.Error<…>, which
renders the struct again with inspect's defaults. Inspect also walks a
keyword list whole to tell it from a plain list, and copies a tuple to a
list, before its limit applies. Such terms reach these functions only from
a consumer's own code — a server.reject reason, a tool's own exit — and
a JSON body decodes to no struct, tuple or keyword list.
The line form lets a float, boolean or nil through — none can be long or
carry a control character — and so any other atom, which is the
consumer's own: no client value decodes to one beyond those three, and a
consumer that writes a control character into a reason atom writes it onto
its own line. The rendering form inspects them instead, which is why an
exit of :timeout reaches the wire as ":timeout" and not as a bare
word.
The control-character replacement is what stops a client forging log
lines. A JSON-RPC string may decode to an embedded newline, and several of
the values bounded here come straight out of the client's body or headers,
so rendering one raw would let a client write a whole line of its own into
the operator's stream — including a line that looks like one of wymcp's.
The scrub runs after the cut and replaces byte for byte, so it keeps the
bound. It is byte-wise rather than a regex because a header value need not
be valid UTF-8 and a ~r/.../u match raises on the bytes that are not. A
rendering is scrubbed too: inspect/2 escapes a control character inside
any string it renders, but a struct's own Inspect implementation can
emit one raw. It is also why a value that spans lines arrives on one: an
exception message written across three lines reaches the wire as one.
Totality
The renderer is total: on every term it returns a value — it raises on
no byte sequence — and its work is bounded by the constant it cuts to,
never by the input; the one exception is a consumer's own term that
inspect/2 walks or copies before its limit applies — a struct with an
Inspect implementation of its own, a keyword list, a tuple. The validity
check and the inspected path a failed one falls back to are bounded the
same way: the check reads what the cut kept, inspect/2 stops at
:printable_limit on a value it will not render as text, then renders its
bytes under :limit, and a rendering inspected again is at most the bound
long when it goes in.
Two callers stand on that property. It runs on the request process, before
authentication on a rejection and inside the call a tool already occupies,
so a value whose cost grew with its size would be a cost the sender chose.
And it runs inside a :telemetry handler — Wymcp.Telemetry.Logger's —
which :telemetry detaches from every event it was attached to if it
raises, after which every line is silently gone.
Summary
Functions
The rendering form: always a string.
The line form: the same rule, except that an atom, a float, nil and an
integer within the bound pass as themselves.
Functions
The rendering form: always a string.
A non-empty string is cut and scrubbed as it is where the cut leaves valid
UTF-8 and keeps something of it, and is inspected instead where it does not;
the empty string is returned as itself. Every other term is inspected under
the visitor and then cut, and a rendering the cut leaves invalid or empty is
inspected once more, so a reader is handed ":timeout" rather than a bare
word and a diagnostic's message stays a string — and a JSON string —
whatever the term was.
This is the form the wire takes, and the form a string interpolation takes — there the value would be rendered anyway, and rendering it here is what keeps the bound and the scrub on it.
Examples
iex> Wymcp.Bound.render("tools/call")
"tools/call"
iex> Wymcp.Bound.render("id\n[error] forged line")
"id [error] forged line"
iex> Wymcp.Bound.render("")
""
iex> Wymcp.Bound.render(:timeout)
":timeout"
iex> Wymcp.Bound.render(%{"a" => 1})
"%{\"a\" => 1}"
iex> Wymcp.Bound.render({:error, %{"id" => Integer.pow(10, 256)}})
"{:error, %{\"id\" => <integer of more than 256 digits>}}"
The line form: the same rule, except that an atom, a float, nil and an
integer within the bound pass as themselves.
Wymcp.Telemetry.Logger renders every ★ value through this form, because
its line table promises an operator that an atom is the consumer's own and
passes as written, and that a message_id rides as the integer it is.
Examples
iex> Wymcp.Bound.value(:invalid_token)
:invalid_token
iex> Wymcp.Bound.value(42)
42
iex> Wymcp.Bound.value(nil)
nil
iex> Wymcp.Bound.value("tools/call")
"tools/call"
iex> Wymcp.Bound.value(Integer.pow(10, 256))
"<integer of more than 256 digits>"