Wymcp. Bound
(Wymcp v0.8.7)
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 in that unit. The rendering form and the
line form hand it over on one line. The echo form does not scrub, so a
line break the request sent survives it, and a string it cuts carries the
… marker past the bound.
Three forms apply that rule. 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. echo/1 is the echo form, for a value a refusal quotes from the
request: it cuts a string at the bound and marks the cut … without
scrubbing it, passes an integer as the line form does and a float, a
boolean or nil as itself, and inspects any other term, which is written
in angle brackets where it stands as a key. A refusal that lists values
lists the first ten, chosen by first/1, with their count. Everything the
three do not keep as it came they hand to render/1's inspected path, so
the rule below has one implementation and not three.
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.
The echo form does not scrub. What it answers goes onto the wire inside a
JSON string, where escaping already makes every byte safe, and a key
rewritten there is one its sender can no longer find in its own request.
A refusal's telemetry event carries the same text, and
Wymcp.Telemetry.Logger renders it through the line form, which scrubs.
What a refusal quotes
A refusal quotes back what the request sent
so its reader can see which value was wrong, and the sender never picks
how much that is. Every value a refusal quotes from the request — from its
body or its headers — goes through echo/1, and every refusal quotes at
most ten such values, with their count beside them. A refusal's size is
therefore its own server text plus a constant: the bound, times ten, times
how many times the refusal quotes each value, times the caller keys one
data path can carry — which an action's
schema fixes — times what JSON escaping can make of a byte. This module
states the rule once: the modules that refuse call echo/1 and point
here, and a test holds every refusal wymcp can send to it.
The one value outside the rule is the JSON-RPC id, which every answer
returns as sent so the client can correlate it. A consumer's own text — an
authentication reason, a tool's error — is not a quote of the request, and
where wymcp itself has to render a consumer's term onto the wire,
Wymcp.JsonRpc uses the rendering form.
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 first 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.
first/1 is the second exception: it reads every element to choose the
first ten and to count them, so its work grows with the list's length.
That list is one the request already carried whole, and what a refusal
quotes of it stays within the constant.
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
echo/1 is the echo form: the bound applied to a value a refusal quotes
from the request — a string cut at the bound and marked …, unscrubbed;
an integer as the line form keeps it; a float, a boolean or nil as
itself; any other term inspected, and written in angle brackets where it
stands as a key. A refusal that lists values lists the first ten, chosen
by first/1, with their count.
The first ten elements of list in term order, and the number of its
elements, as {first, count} — the ten a refusal lists and the count it
gives beside them. The caller echoes the ten, since only it knows whether
they stand as keys. The ten are kept as a sorted list while the rest pass
by, never a sort of them all. An improper tail is not an element.
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
echo/1 is the echo form: the bound applied to a value a refusal quotes
from the request — a string cut at the bound and marked …, unscrubbed;
an integer as the line form keeps it; a float, a boolean or nil as
itself; any other term inspected, and written in angle brackets where it
stands as a key. A refusal that lists values lists the first ten, chosen
by first/1, with their count.
A string within the bound comes back whole. One past it is cut to at most
the bound, a codepoint split there dropped whole, and marked; one that is
not valid UTF-8, or that the cut keeps nothing of, is inspected as
render/1 inspects it, so what comes back always encodes as a JSON
string. Past the bound, validity is read on the bytes the cut keeps, and
those must open the string: a cut that had to drop bytes from its front
takes the inspected rendering too, so what an echo shows is never a
string its sender did not send. A list is a term like any other, so what comes back is one JSON
value whatever the term: a refusal that lists values chooses them with
first/1 and echoes each. The angle brackets are the caller's to write,
because only the caller knows the value stands as a key.
Examples
iex> Wymcp.Bound.echo("limt")
"limt"
iex> Wymcp.Bound.echo(String.duplicate("k", 300)) == String.duplicate("k", 256) <> "…"
true
iex> Wymcp.Bound.echo("a\tb")
"a\tb"
iex> Wymcp.Bound.echo(42.0)
42.0
iex> Wymcp.Bound.echo(Integer.pow(10, 256))
"<integer of more than 256 digits>"
iex> Wymcp.Bound.echo({:a, 1})
"{:a, 1}"
The first ten elements of list in term order, and the number of its
elements, as {first, count} — the ten a refusal lists and the count it
gives beside them. The caller echoes the ten, since only it knows whether
they stand as keys. The ten are kept as a sorted list while the rest pass
by, never a sort of them all. An improper tail is not an element.
Examples
iex> Wymcp.Bound.first(Enum.to_list(20..1//-1))
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 20}
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 a fault diagnostic's message and a consumer's
non-string reason take on the wire, 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>"