Skip to content

Bounds and char: runtime checks, depth limit, conformance 0.3 (core#98) - #78

Merged
Kinflou merged 8 commits into
mainfrom
feat/bounds-runtime
Nov 3, 2026
Merged

Kinflou merged 8 commits into
mainfrom
feat/bounds-runtime

Conversation

@Kinflou

@Kinflou Kinflou commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Step 7 of the bounds rollout (ComlineProject/core#98; resume note ComlineProject/core#197): the runtime side of char and bounds, and their conformance vectors.

char on the wire

As decided on 2026-11-02:

Format One char
packed@1 its raw UTF-8 bytes, 1 to 4, no prefix. Decoding reads the first byte's width and runs the strict UTF-8 check: an overlong form, a surrogate, a value past U+10FFFF or a truncated character is refused. This changes packed@1's char, which used to be a one-character string with a length. No generator emits char yet: comline-rust and comline-typescript refuse it.
msgpack@1 a one-character string (fixstr), unchanged (rmp-serde's own)
json@1 a one-character string, unchanged (serde_json's own)

comline_runtime::bounds (new, no_std)

The checks generated code will call in steps 8–9:

  • Checks: check_chars(&str, max) (counts characters, not bytes; a string of at most max bytes isn't counted, and one over 4 × max bytes is refused without counting), check_items(len, max), chars_from_utf8(&[u8], max) (refuses a byte length over 4 × max before the UTF-8 check, then counts), char_from_utf8 / utf8_width (a single char).
  • The depth limit: Depth::new(max), with enter() before each value of the recursive type and leave() after it. The outermost value is level 1, and entering level max + 1 is refused before anything deeper is read.
  • serde seeds that refuse before allocating:
    • CharsSeed<T> (&str or String) measures a borrowed string where it lies.
    • ItemsSeed<S, C> refuses a count prefix above max (its size hint, in packed@1 and msgpack@1) before allocating. In json@1 it stops at item max + 1, which it skips without building it (IgnoredAny).
    • The two seeds compose, so nested bounds like char[max 32][max 10] work.
  • Field adapters for #[serde(deserialize_with/serialize_with)]: decode::string::<_, N>, decode::vec::<_, T, N>, encode::string::<_, N> and encode::vec::<_, T, N>. An encoder refuses an over-bound value before writing anything.
  • Error kind:
    • A new RuntimeError::BoundExceeded { measure: Measure }, where Measure is Chars, Items or Depth. Every format's encode and decode return it for a bound failure, instead of Serialization.
    • The checks themselves return bounds::BoundExceeded { measure, max }, maximum included.
    • The maximum isn't in RuntimeError itself. Carrying it grew packed@1's internal error from 1 byte to 8, and every Result on the decode path grew with it. The first CI run measured +15% to +20% on format/decode/*/packed@1. The caller knows the maximum from the type anyway.
    • A test pins packed@1's error at 1 byte and RuntimeError at 4.
    • A bound failure goes through serde as the BoundExceeded message ("more than 4 characters"). Each format recognises it in its error's first 128 bytes, without allocating.
    • RuntimeError is #[non_exhaustive], and the ffi's status_of already has a catch-all, so the ffi is unchanged: no bound check runs across that ABI.

Conformance: spec 0.2 → 0.3

  • The model:
    • New type descriptions: "char", {"bounded_string": {"max": N}}, {"bounded_array": {"of": T, "max": N}}, and {"recursive": {"max_depth": D, "of": T}}, with "self" inside T.
    • A new value, {"char": "é"}.
    • An encode case may carry a type. An encode rejection asks only to encode, and the encode must fail.
    • TypeSeed decodes the bounded types through the runtime's own CharsSeed, ItemsSeed and Depth, so the vectors prove the primitives that generated code will use.
  • New cases per format:
packed@1 msgpack@1 json@1
single char, encode (ASCII, 2-, 3-, 4-byte) 4 4 4
single char, decode 4 4 4
bytes that aren't one char (refused) 7 6 4
bounds, decode (9 string, 5 array, 4 depth) 18 18 18
bounds, encode (the same values; over-bound ones refused) 18 18 18
total (rejections) 51 (23) 50 (22) 48 (20)
  • What the bound cases cover:
    • empty, at the maximum, and one over;
    • "é€😀a": 4 characters in 10 bytes (under by characters, over by bytes);
    • "😀😀😀😀": 16 bytes, exactly 4 × N;
    • "ééééé": over by characters, under 4 × N bytes;
    • a 17-byte length over 4 × N;
    • max 0;
    • nested char[max 2][max 2];
    • an optional chain and a tree under @max_depth, at and over the limit.
  • Profiles: no profile facts change. Bounds are checked the same way in every format, and how a format encodes a char is bytes, not a version-comparison fact.
  • comline-reference passes every vector. Its replay test now expects an error reply for an encode rejection (README updated).
  • Not bumped here: comline-typescript's VECTORS_REV. Its runtime will fail the new cases until steps 8–9 implement them, and those steps bump it.

Benchmarks

The instruction-count gate passes with two accepted regressions, recorded in accepted-regressions.toml:

Benchmark Growth Why
format/decode/small-scalar/packed@1 +3.26% packed@1 now tells a bound failure apart from other malformed input when it maps its error to RuntimeError. That's a few instructions, which shows only on the smallest message.
format/encode/small-scalar/packed@1 +3.66% the same, on encode

Every other benchmark is within 1%.

Two other attempts measured worse:

  • Carrying the maximum in RuntimeError: packed@1's error grew from 1 byte to 8, which cost +15% to +20% on packed@1 decoding.
  • Moving the error mapping out of line (#[cold]): +14% to +16% on decoding. It was reverted.

@Kinflou
Kinflou merged commit 17c3b70 into main Nov 3, 2026
24 checks passed
@Kinflou
Kinflou deleted the feat/bounds-runtime branch November 3, 2026 08:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant