- have's `token` field gains `maxLength` + pattern
(`wire::token_wire_schema`); the pattern is case-insensitive
(`^[0-9a-fA-F]{64}$`) to agree exactly with `from_hex`'s
case-insensitive ingress contract — the canonical mint form stays
lowercase, the put response's pattern unchanged
- `PinToken::from_hex` length-guards before decoding — a wrong-length
input answers `None` with no proportional allocation
- the malformed-payload sweep's have arm gains the over-length /
non-hex token payloads (`INVALID_INPUT` at the gate); the
renewal-form test's malformed arm restructured (wire rejects, the
handler's present:false mapping keeps direct calls; uppercased wire
form renews — the gate/ingress agreement asserted)
- embedder-facing records: the registry's process-lifetime retention
posture (a put loop without confirms grows without bound) with the
admission cap named as the future hardening (decomposer's deferred
judgment stands — a refused mint answers a committed put, a mint-
contract decision), and the custodial-never-confirm warning
(GC can never reap an unconfirmed custodial pin)
- gates: cargo test (default), --all-features server-less + live pg
harness, clippy both variants -D warnings, fmt --check, doc
(one pre-existing local warning, untouched)
Verification:
- cargo test: 149+54+4 ok
- cargo test --all-features (live harness): 326+125+4 ok
- cargo clippy --all-targets -- -D warnings: clean (+ --all-features)
- cargo fmt --check: clean
9.0 KiB
id, name, status, depends_on, scope, risk, impact, level, tags
| id | name | status | depends_on | scope | risk | impact | level | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| s5-token-field-bounds | S-5 — the have renewal form's token field bounded before the hex decode; the registry/custodial retention recorded embedder-facing | completed |
|
narrow | low | component | implementation |
|
Description
Review-001 S-5 — unbounded capability resources on an authenticated surface. Three parts:
- The token field's schema bound + the decode order. The
renewal form's
tokenfield is a bare{"type": "string"}(src/ops/specs.rs:131) — nomaxLength, nopattern— and [PinToken::from_hex] (src/ops/tokens.rs:56-59) hex-decodes proportionally before any length check: an arbitrarily large string is decoded (allocating proportionally) before thetry_intorejects. Fix both: the schema gainsmaxLength/pattern(the canonical wire form is lowercase 64-char hex —^[0-9a-f]{64}$, the same pattern the put response's token field already carries, src/ops/put.rs:89; the decode is case-insensitive perfrom_hex's contract, so decide whether the schema pattern is the canonical-lowercase form or the ingress stays case-insensitive with the schema bound only on length — design point, record in Notes; the schema gate and the ingress must not disagree silently), andfrom_hexchecks the length before decoding (a cheaplen() == 64guard ahead ofhex::decode— the proportional allocation never fires). - The embedder-facing retention records. Two named behaviors
are currently silent:
- The token registry's process-lifetime retention (src/ops/tokens.rs — the registry doc; entries retire at confirm or process end): one put per loop without confirm is unbounded memory growth. Record it embedder-facing (the module doc's token-registry section) as the documented posture.
- The custodial-never-confirm warning (a custodial pin held forever by an embedder that never confirms — GC can never reap): record the embedder-facing warning on the custody/ hand-over doc section.
- The admission cap — judged at decomposition (the plan's clause), recorded here for the task to land. Judgment (decomposer, 2026-10-16): deferred-and-documented, not implemented. The registry is process-lifetime by decided design; growth requires an authenticated write-granted principal looping puts without confirm — an embedder misuse the custodial-never-confirm warning addresses; a cap is an eviction/refusal policy decision on the mint contract (which entry dies, what a refused mint answers) — a behavior change beyond the security round's fix-shaped scope. The task lands the retention record naming the cap as the known future hardening (the review's "name it, then judge" satisfied: named here, judged deferred). If the implementation session finds the cap trivially expressible (e.g. a mint-refusal at a config bound with no eviction question), it may land it instead — the judgment flip recorded in Notes either way.
Tests: the schema bound rejects an over-length/non-hex token at the
registry (INVALID_INPUT — extend the malformed-payload sweep's
have-renewal arm); from_hex's length-first order unit-tested (a
huge string answers None without a proportional decode — assert
the rejection, the order itself is inspection-verified).
Acceptance Criteria
- The renewal form's
tokenfield carriesmaxLength+pattern(the schema/ingress case posture decided and recorded) PinToken::from_hexlength-checks before decoding- The token registry's process-lifetime retention and the custodial-never-confirm warning recorded embedder-facing, the admission cap named as the future hardening (or landed, with the judgment flip recorded)
- Tests: the over-bound token rejected at the schema;
from_hexrejects wrong-length input without proportional decode - Gates green:
cargo test(default),cargo test --all-featuresserver-less,cargo test --all-features+ live harness, clippy--all-targets -- -D warningsacross the feature variants,cargo fmt --check
References
- docs/reviews/001-storage-surface-general-review.md (S-5 — pre-split coordinates)
- docs/plans/implementation.md (§Wave 10 — the "admission cap judged at decomposition" clause; §Review gates — the security gate)
- src/ops/specs.rs (the have spec's token field)
- src/ops/tokens.rs (
PinToken::from_hex; the registry + custody doc sections) - src/ops/put.rs:89 (the put response's token pattern — the consistency contrast)
- src/ops/tests.rs (the malformed-payload sweep — the schema-bound test's home)
Notes
The case posture (part 1's design point): the ingress stays
case-insensitive and the schema pattern matches it exactly. The
renewal form's token field schema is now {"type": "string", "maxLength": 64, "pattern": "^[0-9a-fA-F]{64}$"} — the new
wire::token_wire_schema helper, beside the digest field shapes. It is
case-insensitive because PinToken::from_hex's decode contract is
case-insensitive (a public API the embedders call directly, its
unit-backed contract unchanged), so the gate and the ingress agree on
every input: nothing the gate passes decodes differently below it,
and no divergence exists to record. The canonical mint form stays
lowercase (to_hex); the put response's token pattern keeps the
canonical-lowercase ^[0-9a-f]{64}$ (put.rs) — responses are minted,
not presented, so that strictness was never the ingress's shape. The
renewal wire test asserts the agreement end to end (an uppercased
wire form renews).
from_hex is length-first (len() != 64 → None ahead of
hex::decode) — the proportional allocation never fires; the doc
records the order and its relationship to the wire bound.
The doc records (part 2): the module doc's token-registry section
gains the embedder-facing retention posture (process-lifetime memory;
a put loop without confirms grows without bound, one held guard each
non-fleet) naming the admission cap as the known future hardening
— the cap is deferred, not landed (below). The custody split
section gains the custodial-never-confirm warning (GC can never reap
a custodial pin whose embedder never confirms; the confirm verb is
the hand-over contract's closing act). TokenRegistry's own doc
carries the pointer.
The admission cap (part 3): the decomposer's judgment stands —
deferred-and-documented, not flipped. The escape-hatch shape (a
mint-refusal at a config bound) is not trivially expressible here:
the put handler mints after the commit, so a refused mint answers a
committed stream — what the putter is told (an internal on a
landed blob? a new typed error?) and what happens to the hand-over is
exactly the mint-contract policy decision the judgment named, not a
mechanical refusal. No eviction question avoided either: refusing
leaves the landed entry to GC with no pin handle. Deferred, recorded
in the retention posture.
Test shape: the malformed-payload sweep's have arm gained the
over-length ("a".repeat(65)) and non-hex ("G".repeat(64)) token
payloads → INVALID_INPUT at the gate; the renewal-form test's
malformed arm restructured — the wire now answers INVALID_INPUT
(the old bare-string schema let it through to present: false), and
the handler-level direct invocation keeps the malformed-token →
present: false mapping (the store-free arm survives for calls
riding past the wire gate, doc-aligned in specs.rs); from_hex's
huge-string rejection unit-tested in tokens.rs (~1 MiB of as, plus
the 63/65/0-length near-misses).
One incidental: the ops feature is not in the default feature set,
so gates 1 and the ops-bearing runs are distinct runs (default =
326-free; --all-features = 326+125+4 lib/integration tests).
Summary
All three parts landed. PinToken::from_hex length-guards before
decoding; the have renewal form's token field carries
maxLength: 64 + the case-insensitive hex pattern (^[0-9a-fA-F]{64}$),
chosen to agree exactly with from_hex's case-insensitive ingress
contract (the agreement asserted wire-level via an uppercased renewal);
the malformed-payload sweep's have arm and the renewal-form test cover
the gate's INVALID_INPUT and the handler's surviving present: false
mapping; the registry retention posture, the custodial-never-confirm
warning, and the admission cap named-as-future-hardening are recorded
embedder-facing in the module doc (token-registry + custody sections)
and TokenRegistry's doc; the decomposer's deferred cap judgment
holds (the refused-mint-answers-a-committed-put question is not
trivially expressible). Gates: cargo test (default, 207), cargo test --all-features server-less and against the live pg harness
(326/125/4), clippy --all-targets -D warnings on both feature
variants, cargo fmt --check, cargo doc --no-deps (one pre-existing
warning in local, untouched here).