lrgp: harden protocol and align 0.4.0

This commit is contained in:
DeFiDude 2026-08-05 08:04:56 -06:00
parent 0b55361ebf
commit 0d2598109c
25 changed files with 3959 additions and 345 deletions

View file

@ -1,5 +1,24 @@
# Changelog
## 0.4.0 — 2026-08-04
### Breaking
- Canonical envelope, session ID, native LXMF field types, strict built-in
payloads, participant binding, and scoped replay semantics now match
`lrgp-py` and the normative specification.
- `LrgpStore::save_session` is insert-only. Existing records must be changed
through the explicit mutable-field allowlist in `update_session`.
### Added
- Typed router results, explicit-participant outgoing preparation, TTL-aware
hydration/list/removal, challenge-admission limits, and public transactional
snapshot/rollback helpers for durable inbound and outbound integration.
- Draw-offer ownership, legacy hydration normalization, strict terminal claim
verification, canonical Python interoperability vectors, and duplicate-key /
trailing-byte decoder rejection.
## 0.3.1 — 2026-05-01
### Added

View file

@ -1,6 +1,6 @@
[package]
name = "lrgp"
version = "0.3.1"
version = "0.4.0"
edition = "2024"
license = "MIT"
rust-version = "1.85"

View file

@ -12,7 +12,9 @@ LRGP enables turn-based and real-time multiplayer games to run over LoRa radios,
- **`LrgpRouter`** — register games, dispatch moves, manage manifests
- **`LrgpStore`** — SQLite persistence for game sessions and move history
- **Transport bridge** — zero-copy conversion between LRGP envelopes and LXMF fields
- **Replay protection** — every envelope carries an 8-byte CSPRNG nonce; receivers maintain a per-session bounded LRU (10-min TTL) and drop duplicates
- **Replay protection** — every envelope carries an 8-byte CSPRNG nonce; receivers maintain identity-scoped bounded LRUs with an absolute 10-minute TTL
- **Participant binding** — every session is bound to its authenticated remote peer before state-changing actions are accepted
- **Bounded admission** — unsolicited pending challenges are capped per participant and local identity without evicting active games
- **Built-in games** — Tic-Tac-Toe and Chess (via `cozy-chess`)
## Quick Start
@ -80,7 +82,7 @@ fields[0xFB] = "lrgp.v1" # protocol marker
fields[0xFD] = { # envelope (≤200 bytes)
"a": "ttt.1", # game_id.version
"c": "move", # command
"s": "a1b2c3d4e5f6g7h8", # session_id (16-char hex)
"s": "a1b2c3d4e5f60718", # session_id (16-char lowercase hex)
"p": {"i": 4, "b": "____X____", ...}, # payload (game-specific)
"n": <8 bytes>, # CSPRNG nonce (replay-dedup)
}
@ -90,7 +92,35 @@ Non-LRGP clients see human-readable fallback text (e.g., `"[LRGP TTT] Move 3"` o
### Replay protection
Every outbound envelope carries an 8-byte CSPRNG nonce under key `n`. Receivers run each decoded envelope through `ReplayDedup::check`; the cache is a per-session LRU of `(session_id, nonce)` pairs bounded to 512 entries with a 10-minute TTL. Duplicates are reported as `DedupVerdict::Replay` and should be dropped silently. Drop the per-session cache via `drop_session(session_id)` when a game reaches a terminal state.
Every outbound envelope carries an 8-byte CSPRNG nonce under key `n`. Receivers probe each validated envelope without insertion, authorize its transport sender, then atomically check-and-record it before application mutation. The cache is keyed by `(receiving_identity_id, session_id, nonce)`, bounded to 512 nonces per namespace and 1,024 namespaces, and uses an absolute 10-minute TTL from first observation. Duplicates are reported as `DedupVerdict::Replay` and dropped silently. Unauthorized fresh nonces never consume or evict cache entries. Terminal-session nonces remain until that TTL expires so late transport retransmits stay deduplicated; explicit user deletion may remove only the matching local identity/session namespace.
`pack_envelope`, `pack_lxmf_fields`, and byte decoders are checked APIs. They reject non-canonical fields, unsupported lexical forms, oversize envelopes, and trailing bytes rather than placing malformed LRGP data on the wire.
`pack_lxmf_fields` returns native MessagePack values. If an integration needs
pre-encoded field bytes, use `transport::pack_into_preencoded_fields` and, with
`lxmf-core::LxMessage`, install each value using `set_msgpack_field`.
`LxMessage::set_field` is intentionally **not** compatible with this output: it
would wrap the encoded string/map as MessagePack binary values, which Python
LRGP peers do not interpret as LRGP fields.
### Integration trust boundary
Pass `LrgpRouter::dispatch_incoming` only the remote identity derived from
authenticated LXMF/Reticulum delivery metadata. Never derive `sender_hash`
from fallback text, an envelope field, or a display name. LRGP binds the value
to a session and rejects later mismatches, but this transport-independent crate
cannot authenticate an arbitrary caller-supplied string itself. Incoming
dispatch rejects an empty sender or receiving identity before replay insertion
or game mutation.
For durable inbound processing, snapshot before dispatch. If the application
mutation succeeds but its external database transaction fails, call
`LrgpRouter::rollback_incoming` with that exact envelope nonce and snapshot.
The method restores/deletes the live session and releases only the matching
identity/session/nonce replay key so an exact transport retransmission can be
applied safely. If durable recording of an authenticated
`IncomingDispatch::RemoteError` fails, use `forget_incoming_nonce` instead:
that result consumed a nonce but did not mutate game state.
## Protocol Spec

236
SPEC.md
View file

@ -1,4 +1,4 @@
# LRGP Specification v0.3
# LRGP Specification v0.4
**Lightweight Reticulum Gaming Protocol**
@ -25,6 +25,13 @@ LRGP uses two LXMF custom extension fields:
All fields are serialized via **msgpack** (not JSON).
The two field values are native MessagePack values in the LXMF field map: `0xFB`
is a MessagePack string and `0xFD` is a MessagePack map. An implementation MUST
NOT first encode either value and then insert those bytes through an API that
wraps arbitrary bytes as MessagePack binary. Such `bin("lrgp.v1")` and
`bin(<encoded envelope>)` wrappers are not LRGP. With `lxmf-core::LxMessage`,
pre-encoded values MUST be installed with `set_msgpack_field`, not `set_field`.
---
## 3. Envelope Schema
@ -43,13 +50,37 @@ The envelope is a msgpack dict stored in `fields[0xFD]`:
All keys are single characters to minimize wire size. The `game_id` and `version` are combined into a single string to save one key-value pair.
### Required Fields
### Canonical Form
All five keys (`a`, `c`, `s`, `p`, `n`) MUST be present in every envelope. msgpack maps are unordered by spec, so implementations MUST NOT rely on a specific key ordering when comparing envelopes byte-for-byte.
Every envelope MUST contain exactly the five keys `a`, `c`, `s`, `p`, and
`n`. Missing or additional top-level keys are invalid. msgpack maps are
unordered by specification, so implementations MUST NOT rely on a specific
key ordering when comparing envelopes byte-for-byte. Duplicate map keys are
invalid at every level because collapsing them can produce divergent
sender/receiver interpretations.
Each field has one canonical type and lexical form:
| Key | Canonical value |
|-----|-----------------|
| `a` | msgpack string `<game_id>.<version>`; game ID matches `[a-z][a-z0-9_.-]*`, version is a canonical positive decimal `u32` with no sign or leading zero |
| `c` | msgpack string matching `[a-z][a-z0-9_]*` |
| `s` | msgpack string containing exactly 16 lowercase hexadecimal characters |
| `p` | msgpack map |
| `n` | exactly 8 bytes encoded as msgpack binary (`bin8`) |
An implementation MUST reject an envelope that is not canonical, exceeds the
200-byte packed envelope budget, names an unsupported app/version, or names an
action absent from the selected app manifest. The standard `error` action is
supported independently of an app manifest's action list. A byte-oriented
decoder MUST consume exactly one envelope and reject any trailing bytes.
### Session ID
Session IDs are 8 random bytes encoded as 16 hexadecimal characters. The challenger generates the session ID.
Session IDs are 8 random bytes encoded as exactly 16 lowercase hexadecimal
characters. The challenger generates the session ID. Implementations MAY
accept an empty session ID only as a local API request to generate a new
outgoing challenge ID; an empty ID is never valid on the wire.
### Nonce
@ -57,15 +88,38 @@ The `n` field is exactly 8 bytes of CSPRNG output, encoded as msgpack `bin8`. It
### 3.1 Replay Protection
Receivers MUST run each decoded envelope through a per-session bounded LRU before dispatch. The cache is keyed by `(session_id, nonce)` and bounded by:
Receivers MUST run each decoded and supported envelope through a bounded replay
cache before participant authorization or application dispatch. The cache is
keyed by `(receiving_identity_id, session_id, nonce)` and bounded by:
| Constant | Value | Description |
|---|---|---|
| `NONCE_BYTES` | 8 | nonce length |
| `DEDUP_CACHE_PER_SESSION` | 512 | max entries per session |
| `DEDUP_CACHE_SESSIONS` | 1024 | max receiving-identity/session namespaces |
| `DEDUP_TTL_SECONDS` | 600 | per-entry TTL (10 min) |
A `Fresh` verdict means the envelope has not been seen in the cache TTL window — dispatch normally and record the nonce. `Replay` means the `(session_id, nonce)` pair is already present — drop the envelope silently. Implementations SHOULD drop the per-session cache when a session reaches a terminal state (`completed` / `declined` / `expired`).
A receiver first probes the scoped cache without recording a fresh nonce or
evicting any existing entry. `Replay` means the scoped nonce is already
present: drop the envelope silently. For a fresh probe, authorize the transport
sender, then atomically check-and-record the nonce before application mutation.
The second check resolves concurrent duplicate races. An authorization failure
MUST NOT record the nonce, so unauthenticated traffic can neither reserve a
legitimate nonce nor evict legitimate replay entries. The TTL is absolute from
first observation; receiving a duplicate MUST NOT extend it.
If application dispatch succeeds but the receiver cannot durably commit the
result, it MUST restore the pre-dispatch application state and remove only that
accepted `(receiving_identity_id, session_id, nonce)` entry before accepting a
retry. Other replay entries MUST remain intact. An application-level rejection
does not use this transaction rollback path. An authenticated remote `error`
consumes replay state but does not mutate a game session; if durable recording
of that error fails, the receiver MUST remove only its accepted scoped nonce
without restoring or deleting session state.
Nonce entries for terminal sessions (`completed`, `declined`, or `expired`)
MUST remain until their normal nonce TTL expires, so late transport retransmits
remain replays. Explicit deletion of a session MAY also delete only that local
identity/session's replay namespace.
---
@ -101,6 +155,64 @@ Non-LRGP clients display this as a regular message.
## 6. Session Lifecycle
### Participant Binding and Session Identity
Every two-player session is scoped by `(local_identity_id, session_id)` and is
bound to exactly one transport-authenticated remote participant. This session
namespace is global across apps: two apps MUST NOT own the same session ID for
the same local identity. Incoming challenges, new outgoing challenges, and
hydrated records that collide with another app fail before application
mutation. A structurally valid incoming collision retains its replay nonce.
An incoming new challenge binds the session to its authenticated sender. A
local outgoing challenge MUST name its intended recipient before any session
is created; both its local identity and intended recipient MUST be non-empty.
The router stores that binding before accepting later responses.
Every subsequent action, including `error`, MUST be authorized against the
bound participant. Missing sessions, missing participant bindings, expired
sessions, and sender or recipient mismatches fail closed before application
mutation.
The LRGP integration MUST derive the remote participant identifier from
transport-authenticated LXMF/Reticulum delivery metadata. It MUST NOT use
fallback content, an LRGP envelope value, a display name, or other
attacker-controlled presentation data as the authenticated sender. LRGP
enforces the resulting participant binding but, as a transport-independent
protocol, does not independently authenticate a caller-supplied identifier.
Both the authenticated remote identifier and receiving local identity MUST be
non-empty; dispatch fails before replay insertion or application mutation when
either is absent.
An outgoing challenge MUST be rejected if its session ID already exists for
the local identity. A same-participant incoming challenge with an existing
session ID and a fresh nonce is an idempotent transport retry: it produces no
state mutation and no duplicate UI event. The same challenge from any other
sender is unauthorized. A byte-identical retry is handled earlier by replay
deduplication. To retry an outgoing transport send, a sender retransmits the
exact previously prepared envelope; asking the router to prepare a new
challenge with the same ID is a duplicate local action and MUST be rejected.
### Challenge Admission
Implementations MUST bound unsolicited pending challenges for each local
identity. After replay filtering and participant authorization, a new incoming
challenge for which the selected app has no existing session is admitted only
when both limits remain below their caps:
| Scope | Limit |
|---|---:|
| Pending sessions from one remote participant | 16 |
| Pending sessions for one local identity, across all apps | 128 |
Counts MUST apply status-specific TTL before counting and include only sessions
that remain `pending`. The participant limit is checked before the identity
limit. Admission and session creation MUST be atomic or serialized across
apps. A same-participant retry of an existing session bypasses admission and
remains idempotent. Rejection MUST NOT evict or modify any existing session,
and active or terminal sessions are never counted or evicted. The rejected
challenge's nonce remains consumed; an implementation MAY drop the challenge
without sending an error to avoid amplification.
### State Machine
```
@ -140,6 +252,21 @@ challenge --> accept --> action* --> end
| `active` | `draw_decline` | `active` |
| `active` | `error` | `active` |
### Draw Offer Ownership
An outstanding draw offer is local session state consisting of both a boolean
and the offering participant's authenticated identity (`draw_offered_by`). The
owner field is not transmitted; it is derived from the authenticated sender of
an incoming offer or the local identity that prepares an outgoing offer.
Only the other bound participant may send `draw_accept` or `draw_decline`. A
participant MUST NOT answer its own offer, and either response without a
complete outstanding offer MUST be rejected before mutation. A second plain
offer MUST NOT replace an outstanding offer or its owner. The offer and owner
MUST be cleared together on a move, valid response, resignation, terminal
transition, or verified claim. A hydrated legacy record whose offer flag lacks
an owner is not answerable and MUST be normalized to no outstanding offer.
---
## 7. Game Session Types
@ -167,11 +294,19 @@ value means corrupted or desynchronized state, and validators fail closed
rather than guess. Claimed terminal state (`x`/`r`/`w`) is never trusted —
receivers recompute it from their replayed local state and reject mismatches.
Routers MUST validate local outgoing intent before an application mutates its
session. An invalid outgoing action returns a typed local failure and leaves
state unchanged. For inbound application validation, the router snapshots the
session before dispatch and restores that snapshot whenever the application
returns a rejection. Rejected inbound actions retain their nonce in the replay
cache so retransmission cannot repeatedly exercise validation or produce
duplicate error responses.
---
## 9. Error Actions
When a receiver rejects an action:
When a receiver rejects an action, it may send the standard `error` action:
```
{
@ -186,6 +321,16 @@ When a receiver rejects an action:
}
```
The error payload MUST contain exactly the three keys `code`, `msg`, and `ref`.
All three values MUST be non-empty msgpack strings. `ref` names the command that
was rejected; it is not a nonce or action correlation identifier.
After canonical validation, replay filtering, and participant authorization,
an incoming `error` action is surfaced as a typed remote protocol error. It
MUST NOT be dispatched to the game handler, interpreted as a local rejection,
used to roll back session state, or answered with another `error`. Duplicate
remote errors are still silently dropped by replay protection.
### Standard Error Codes
| Code | Meaning |
@ -214,6 +359,12 @@ A 1-hour grace period is applied to account for clock skew between peers.
Games MAY override default TTLs via their manifest.
The status-specific TTL MUST be checked whenever a stored session is hydrated
or loaded for listing, before inbound authorization, and before outbound
validation. An expiry transition is durable local state. A hydrated session
MUST also have a canonical session ID, a non-empty local identity, and an
app/version matching the selected implementation.
---
## 11. Delivery Method Guidelines
@ -252,6 +403,13 @@ Each game declares a manifest:
}
```
These are the required interoperable core keys. Manifests are local
discovery/API metadata and are not transmitted in LRGP envelopes, so an
implementation MAY expose additional namespaced or implementation-specific
keys. Consumers MUST ignore unknown manifest keys. Such extensions do not
change the wire protocol and another implementation is not required to expose
the same local metadata.
---
## 13. Large Payloads
@ -303,6 +461,19 @@ All LRGP data MUST be serialized with msgpack. JSON is NOT supported on the wire
Primary key: `(session_id, identity_id)`
The router/application boundary MUST provide TTL-aware get, upsert (hydrate),
list, and remove operations. Explicit removal MUST delete only the selected
local identity/session record; it MAY also delete the matching scoped replay
cache as described in Section 3.1.
Persistent storage MUST distinguish initial insertion from mutation. An initial
session insert with an existing primary key MUST fail instead of replacing the
participant binding or state. Updates MUST target an explicit allowlist of
mutable columns and MUST NOT change `session_id`, `identity_id`, `app_id`,
`app_version`, `contact_hash`, or `initiator`. Action rows are append-only: a
duplicate action number MUST fail rather than replace history. Session deletion
and deletion of that session's action rows MUST be one transaction.
### game_actions (optional)
| Column | Type | Description |
@ -334,6 +505,30 @@ TicTacToe (`ttt.1`) is the built-in reference game demonstrating LRGP.
| `x` | str | move | Terminal status: `""`, `"win"`, `"draw"` |
| `w` | str | move | Winner's hash (only when `x == "win"`) |
#### Canonical Command Payloads
Inbound wire payloads MUST contain exactly the keys shown; missing keys,
additional keys, and values of the wrong MessagePack type are invalid and MUST
be rejected before session mutation.
| Command | Exact wire payload |
|---------|--------------------|
| `challenge` | `{}` |
| `accept` | `{b, t}` where `b="_________"` and `t` is the challenge's stored first-turn identity |
| `decline` | `{}` |
| non-terminal `move` | `{i, b, n, t, x}` with `x=""` |
| winning `move` | `{i, b, n, t, x, w}` with `x="win"`, empty `t`, and `w` equal to the authenticated mover |
| drawn `move` | `{i, b, n, t, x}` with `x="draw"` and empty `t` |
| `resign` | `{}` |
| `draw_offer` | `{}` |
| `draw_accept` | `{}` |
| `draw_decline` | `{}` |
The local outgoing API accepts concise intent rather than caller-forged state:
`move` accepts exactly `{i}` and all other TicTacToe actions above accept `{}`.
The game implementation derives the final canonical wire payload. `error`
uses the global schema in Section 9.
---
## B. Chess Reference Game
@ -359,6 +554,33 @@ Chess (`chess.1`) is the built-in chess implementation. App ID `"chess"`, versio
The `w` key reuses the same character in two payload contexts. Receivers MUST disambiguate by looking at the message command (`accept` → White-player; `move` with `x="win"` → winner).
#### Canonical Command Payloads
Inbound wire payloads MUST contain exactly the keys shown; missing keys,
additional keys, and values of the wrong MessagePack type are invalid and MUST
be rejected before session mutation.
| Command | Exact wire payload |
|---------|--------------------|
| `challenge` | `{}` |
| `accept` | `{w}` where `w` is one of the two bound participant identities |
| `decline` | `{}` |
| non-terminal `move` | `{m, n, x}` with `x=""` |
| winning `move` | `{m, n, x, r, w}` with `x="win"`, a non-empty terminal reason, and `w` equal to the authenticated mover |
| drawn `move` | `{m, n, x, r}` with `x="draw"` and a non-empty terminal reason |
| `resign` | `{}` |
| plain `draw_offer` | `{}` |
| claim `draw_offer` | `{r}` where `r` is exactly `3fr` or `50m` |
| `draw_accept` | `{}` |
| `draw_decline` | `{}` |
The local outgoing API accepts concise intent rather than caller-forged state:
`move` accepts exactly `{m}`; `draw_offer` accepts `{}` or exactly `{r}`; all
other Chess actions above accept `{}`. The game implementation derives the
final canonical wire payload. A recognized but locally ineligible claim reason
degrades to a plain outstanding offer while retaining `{r}` on the wire so the
receiver independently verifies eligibility. `error` uses Section 9.
### Terminal Reason Codes
| Code | Meaning |

View file

@ -7,7 +7,7 @@ use lrgp::envelope::*;
fn main() {
// Pack a challenge envelope
let env = pack_envelope("ttt", 1, "challenge", "a1b2c3d4e5f6g7h8", None, None);
let env = pack_envelope("ttt", 1, "challenge", "a1b2c3d4e5f60718", None, None).unwrap();
println!("Challenge envelope: {env:?}");
// Validate size fits OPPORTUNISTIC delivery
@ -35,12 +35,13 @@ fn main() {
payload.insert("b".to_string(), rmpv::Value::String("____X____".into()));
payload.insert("n".to_string(), rmpv::Value::Integer(1.into()));
let move_env = pack_envelope("ttt", 1, "move", "a1b2c3d4e5f6g7h8", Some(payload), None);
let move_env =
pack_envelope("ttt", 1, "move", "a1b2c3d4e5f60718", Some(payload), None).unwrap();
let move_size = validate_envelope_size(&move_env).unwrap();
println!("\nMove envelope size: {move_size} bytes");
// Pack into LXMF fields
let lxmf_fields = pack_lxmf_fields(&move_env);
let lxmf_fields = pack_lxmf_fields(&move_env).unwrap();
println!("LXMF fields: type=0x{FIELD_CUSTOM_TYPE:02X}, meta=0x{FIELD_CUSTOM_META:02X}");
// Extract back from LXMF fields

View file

@ -10,6 +10,7 @@
use std::collections::HashMap;
use lrgp::app_base::IncomingDispatch;
use lrgp::apps::chess::ChessApp;
#[cfg(feature = "test-helpers")]
use lrgp::apps::chess::force_coin;
@ -33,9 +34,19 @@ fn main() {
let player_b = "cccc3333dddd4444";
println!("=== A challenges B to Chess ===");
let (env, fallback) = router_a
.dispatch_outgoing("chess", 1, "challenge", "", &HashMap::new(), player_a)
let prepared = router_a
.dispatch_outgoing_to(
"chess",
1,
"challenge",
"",
&HashMap::new(),
player_a,
player_b,
)
.unwrap();
let env = prepared.envelope;
let fallback = prepared.fallback_text;
let session_id = value_as_str(env.get("s").unwrap()).unwrap().to_string();
let bytes = pack_to_bytes(&env).unwrap();
println!("Fallback: {fallback}");
@ -48,9 +59,19 @@ fn main() {
.unwrap();
println!("\n=== B accepts ===");
let (accept_env, fallback) = router_b
.dispatch_outgoing("chess", 1, "accept", &session_id, &HashMap::new(), player_b)
let prepared = router_b
.dispatch_outgoing_to(
"chess",
1,
"accept",
&session_id,
&HashMap::new(),
player_b,
player_a,
)
.unwrap();
let accept_env = prepared.envelope;
let fallback = prepared.fallback_text;
let bytes = pack_to_bytes(&accept_env).unwrap();
println!("Fallback: {fallback}");
println!("Envelope: {} bytes", bytes.len());
@ -61,9 +82,19 @@ fn main() {
println!("\n=== A plays 1.e4 ===");
let mut payload = HashMap::new();
payload.insert("m".to_string(), rmpv::Value::String("e2e4".into()));
let (move_env, fallback) = router_a
.dispatch_outgoing("chess", 1, "move", &session_id, &payload, player_a)
let prepared = router_a
.dispatch_outgoing_to(
"chess",
1,
"move",
&session_id,
&payload,
player_a,
player_b,
)
.unwrap();
let move_env = prepared.envelope;
let fallback = prepared.fallback_text;
let bytes = pack_to_bytes(&move_env).unwrap();
println!("Fallback: {fallback}");
println!("Envelope: {} bytes", bytes.len());
@ -71,7 +102,9 @@ fn main() {
let result = router_b
.dispatch_incoming(&move_env, player_a, player_b)
.unwrap();
if let Some(emit) = &result.emit {
if let IncomingDispatch::Applied(result) = result
&& let Some(emit) = &result.emit
{
if let Some(ev_type) = emit.get("type") {
println!("B inbound event: {ev_type:?}");
}

View file

@ -2,6 +2,7 @@
use std::collections::HashMap;
use lrgp::app_base::IncomingDispatch;
use lrgp::apps::tictactoe::TicTacToeApp;
use lrgp::envelope::value_as_str;
use lrgp::router::LrgpRouter;
@ -15,9 +16,19 @@ fn main() {
// Player A sends a challenge
println!("=== Player A challenges Player B ===");
let (env, fallback) = router
.dispatch_outgoing("ttt", 1, "challenge", "", &HashMap::new(), player_a)
let prepared = router
.dispatch_outgoing_to(
"ttt",
1,
"challenge",
"",
&HashMap::new(),
player_a,
player_b,
)
.unwrap();
let env = prepared.envelope;
let fallback = prepared.fallback_text;
let session_id = value_as_str(env.get("s").unwrap()).unwrap().to_string();
println!("Fallback: {fallback}");
println!("Session ID: {session_id}");
@ -25,22 +36,36 @@ fn main() {
// Player B receives the challenge
println!("\n=== Player B receives challenge ===");
let result = router.dispatch_incoming(&env, player_a, player_b).unwrap();
if let Some(emit) = &result.emit {
if let IncomingDispatch::Applied(result) = result
&& let Some(emit) = &result.emit
{
println!("Event: {:?}", emit.get("type"));
}
// Player B accepts
println!("\n=== Player B accepts ===");
let (accept_env, fallback) = router
.dispatch_outgoing("ttt", 1, "accept", &session_id, &HashMap::new(), player_b)
let prepared = router
.dispatch_outgoing_to(
"ttt",
1,
"accept",
&session_id,
&HashMap::new(),
player_b,
player_a,
)
.unwrap();
let accept_env = prepared.envelope;
let fallback = prepared.fallback_text;
println!("Fallback: {fallback}");
// Player A receives accept
let result = router
.dispatch_incoming(&accept_env, player_b, player_a)
.unwrap();
if let Some(emit) = &result.emit {
if let IncomingDispatch::Applied(result) = result
&& let Some(emit) = &result.emit
{
println!("Event: {:?}", emit.get("type"));
}
@ -62,21 +87,25 @@ fn main() {
let mut payload = HashMap::new();
payload.insert("i".to_string(), rmpv::Value::Integer((*cell as i64).into()));
let (move_env, fallback) = router
.dispatch_outgoing("ttt", 1, "move", &session_id, &payload, player)
.unwrap();
println!("Fallback: {fallback}");
// Other player receives
let other = if *player == player_a {
player_b
} else {
player_a
};
let prepared = router
.dispatch_outgoing_to("ttt", 1, "move", &session_id, &payload, player, other)
.unwrap();
let move_env = prepared.envelope;
let fallback = prepared.fallback_text;
println!("Fallback: {fallback}");
// Other player receives
let result = router.dispatch_incoming(&move_env, player, other).unwrap();
if let Some(emit) = &result.emit {
if let IncomingDispatch::Applied(result) = result
&& let Some(emit) = &result.emit
{
if let Some(payload_val) = emit.get("payload") {
if let Some(board_val) = payload_val.get("b") {
println!("Board: {}", board_val.as_str().unwrap_or("?"));

View file

@ -4,6 +4,8 @@ use std::collections::HashMap;
use serde_json::Value as JsonValue;
use crate::envelope::Envelope;
use crate::errors::LrgpError;
use crate::session::Session;
#[derive(Debug, Clone)]
@ -20,6 +22,36 @@ pub struct OutgoingResult {
pub fallback_text: String,
}
/// Explicit result of router-owned inbound replay filtering.
#[derive(Debug, Clone)]
pub enum IncomingDispatch {
Applied(IncomingResult),
/// A validated, authenticated protocol error reported by the remote peer.
/// This is observational and must never be answered with another `error`.
RemoteError(RemoteProtocolError),
Replay,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteProtocolError {
pub app_id: String,
pub session_id: String,
pub code: String,
pub message: String,
pub reference: String,
}
/// Fully validated outbound action, ready for the LXMF integration.
#[derive(Debug, Clone)]
pub struct PreparedOutgoing {
pub envelope: Envelope,
/// Canonical session ID. This matters when a challenge requested automatic
/// ID generation by passing an empty session ID.
pub session_id: String,
pub fallback_text: String,
pub delivery_method: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AppManifest {
pub app_id: String,
@ -61,9 +93,23 @@ pub trait GameApp: Send + Sync {
session_id: &str,
command: &str,
payload: &HashMap<String, rmpv::Value>,
sender_hash: &str,
identity_id: &str,
) -> (bool, Option<String>);
/// Validate a local user intent before any outgoing handler mutates state.
/// Incoming move validation often expects enriched wire fields, while the
/// outgoing UI supplies only the user's concise intent, so the two paths
/// are intentionally distinct.
fn validate_outgoing_action(
&self,
_session_id: &str,
_command: &str,
_payload: &HashMap<String, rmpv::Value>,
_identity_id: &str,
) -> (bool, Option<String>) {
(true, None)
}
fn get_session_state(&self, session_id: &str, identity_id: &str) -> HashMap<String, JsonValue>;
fn render_fallback(&self, command: &str, payload: &HashMap<String, rmpv::Value>) -> String;
@ -73,6 +119,66 @@ pub trait GameApp: Send + Sync {
"opportunistic".to_string()
}
/// Return a complete session record, applying app TTL policy before it is
/// returned. Implementations accepting challenges MUST implement this and
/// the list/binding/authorization methods below. An external persistence
/// integration must durably save any returned `expired` transition.
fn get_session_record(&self, _session_id: &str, _identity_id: &str) -> Option<Session> {
None
}
/// Restore or replace one persisted session in the app's live state.
fn upsert_session(&self, _session: Session) -> Result<(), LrgpError> {
Err(LrgpError::Validation {
code: "unsupported_operation".into(),
message: "app does not support session restore".into(),
})
}
/// Delete one session from the app's live state.
fn remove_session(&self, _session_id: &str, _identity_id: &str) -> bool {
false
}
/// List live session records, optionally restricted to one local identity.
/// Implementations accepting challenges MUST return all of their records
/// here so router-wide admission limits cannot be bypassed.
fn list_session_records(&self, _identity_id: Option<&str>) -> Vec<Session> {
Vec::new()
}
/// Bind the expected remote peer to a newly-created outgoing challenge.
fn bind_session_peer(
&self,
_session_id: &str,
_identity_id: &str,
_peer_hash: &str,
) -> Result<(), LrgpError> {
Err(LrgpError::Validation {
code: "unsupported_operation".into(),
message: "app does not support participant binding".into(),
})
}
/// Authorize the transport-authenticated sender for an inbound action.
/// Challenge handlers establish the binding; every other command must be
/// from the session's bound remote participant.
fn authorize_incoming(
&self,
session_id: &str,
command: &str,
sender_hash: &str,
identity_id: &str,
) -> Result<(), LrgpError> {
let _ = (command, sender_hash, identity_id);
Err(LrgpError::Validation {
code: "unsupported_operation".into(),
message: format!(
"app does not implement participant authorization for session {session_id}"
),
})
}
/// Snapshot pre-mutation state for transactional rollback. `None` for new
/// sessions or apps that don't support rollback. Call before `handle_outgoing`.
fn snapshot_session(&self, _session_id: &str, _identity_id: &str) -> Option<Session> {

View file

@ -10,7 +10,8 @@ use serde_json::Value as JsonValue;
use crate::app_base::{AppManifest, GameApp, IncomingResult, OutgoingResult};
use crate::constants::*;
use crate::envelope::{value_as_str, value_as_u64};
use crate::envelope::{has_exact_keys, value_as_str, value_as_u64};
use crate::errors::LrgpError;
use crate::session::{Session, SessionStateMachine};
const APP_ID: &str = "chess";
@ -264,10 +265,10 @@ impl ChessApp {
}
fn get_session(&self, session_id: &str, identity_id: &str) -> Option<Session> {
let sessions = self.sessions.lock().unwrap();
sessions
.get(&(session_id.to_string(), identity_id.to_string()))
.cloned()
let mut sessions = self.sessions.lock().unwrap();
let session = sessions.get_mut(&(session_id.to_string(), identity_id.to_string()))?;
SessionStateMachine::check_expiry(session, Some(&Self::ttl_policy()), None);
Some(session.clone())
}
fn save_session(&self, session: &Session) {
@ -278,6 +279,13 @@ impl ChessApp {
);
}
fn ttl_policy() -> HashMap<String, f64> {
let mut ttl = HashMap::new();
ttl.insert(STATUS_PENDING.into(), TTL_PENDING);
ttl.insert(STATUS_ACTIVE.into(), TTL_ACTIVE);
ttl
}
fn default_metadata() -> HashMap<String, JsonValue> {
let mut m = HashMap::new();
m.insert("fen".into(), JsonValue::String(STARTING_FEN.into()));
@ -290,6 +298,7 @@ impl ChessApp {
m.insert("terminal".into(), JsonValue::String("".into()));
m.insert("terminal_reason".into(), JsonValue::String("".into()));
m.insert("draw_offered".into(), JsonValue::Bool(false));
m.insert("draw_offered_by".into(), JsonValue::String("".into()));
m.insert("draw_offer_reason".into(), JsonValue::String("".into()));
m.insert("last_move".into(), JsonValue::String("".into()));
m.insert("in_check".into(), JsonValue::Bool(false));
@ -297,6 +306,97 @@ impl ChessApp {
m
}
fn validate_incoming_payload(
&self,
session_id: &str,
command: &str,
payload: &HashMap<String, rmpv::Value>,
identity_id: &str,
sender_hash: &str,
) -> Result<(), String> {
match command {
CMD_CHALLENGE | CMD_DECLINE | CMD_RESIGN | CMD_DRAW_ACCEPT | CMD_DRAW_DECLINE => {
if !payload.is_empty() {
return Err(format!("{command} payload must be empty"));
}
}
CMD_ACCEPT => {
if !has_exact_keys(payload, &[KEY_WHITE]) {
return Err("accept payload must contain exactly w".into());
}
let white = payload
.get(KEY_WHITE)
.and_then(value_as_str)
.ok_or_else(|| "accept w must be a string".to_string())?;
if white != identity_id && white != sender_hash {
return Err("accept w is not a bound participant".into());
}
}
CMD_DRAW_OFFER => {
if !(payload.is_empty() || has_exact_keys(payload, &[KEY_REASON])) {
return Err("draw_offer payload must be empty or exactly {r}".into());
}
if let Some(reason) = payload.get(KEY_REASON) {
let reason = value_as_str(reason)
.ok_or_else(|| "draw_offer r must be a string".to_string())?;
if !matches!(reason, R_THREEFOLD | R_FIFTY_MOVE) {
return Err(format!("unsupported draw claim reason '{reason}'"));
}
}
}
CMD_MOVE => {
let terminal = payload
.get(KEY_TERMINAL)
.and_then(value_as_str)
.ok_or_else(|| "move x must be a string".to_string())?;
let expected: &[&str] = match terminal {
"" => &[KEY_MOVE, KEY_PLY, KEY_TERMINAL],
"win" => &[KEY_MOVE, KEY_PLY, KEY_TERMINAL, KEY_REASON, KEY_WINNER],
"draw" => &[KEY_MOVE, KEY_PLY, KEY_TERMINAL, KEY_REASON],
other => return Err(format!("unsupported terminal marker '{other}'")),
};
if !has_exact_keys(payload, expected) {
return Err(format!(
"move payload has invalid keys for terminal marker '{terminal}'"
));
}
if payload
.get(KEY_MOVE)
.and_then(value_as_str)
.filter(|uci| !uci.is_empty())
.is_none()
|| payload.get(KEY_PLY).and_then(value_as_u64).is_none()
{
return Err("move payload contains a value with the wrong type".into());
}
if !terminal.is_empty()
&& payload
.get(KEY_REASON)
.and_then(value_as_str)
.filter(|reason| !reason.is_empty())
.is_none()
{
return Err("terminal move r must be a non-empty string".into());
}
if terminal == "win"
&& payload
.get(KEY_WINNER)
.and_then(value_as_str)
.filter(|winner| !winner.is_empty())
.is_none()
{
return Err("winning move w must be a non-empty string".into());
}
}
CMD_ERROR => {}
_ => return Err(format!("unsupported command '{command}'")),
}
if command != CMD_CHALLENGE && self.get_session(session_id, identity_id).is_none() {
return Err("Unknown session".into());
}
Ok(())
}
/// Refresh derived session metadata (fen, legal_moves, in_check, draw_offer_reason).
fn refresh_derived(session: &mut Session, board: &Board, moves: &[String]) {
let fen = format!("{}", board);
@ -379,6 +479,12 @@ impl ChessApp {
if white_hash.is_empty() {
return error_result(ERR_PROTOCOL_ERROR, "ACCEPT missing white-player hash");
}
if white_hash != identity_id && white_hash != sender_hash {
return error_result(
ERR_PROTOCOL_ERROR,
"ACCEPT white-player hash is not one of the bound participants",
);
}
let my_color = if white_hash == identity_id { "w" } else { "b" };
tracing::info!(
@ -546,13 +652,15 @@ impl ChessApp {
session
.metadata
.insert("winner".into(), JsonValue::String(winner_hash));
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(false));
session.clear_draw_offer();
Self::refresh_derived(&mut session, &board, &moves);
let _ = SessionStateMachine::apply_command(&mut session, CMD_MOVE, !terminal.is_empty());
if let Err(error) =
SessionStateMachine::apply_command(&mut session, CMD_MOVE, !terminal.is_empty())
{
return error_result(ERR_PROTOCOL_ERROR, &error.to_string());
}
session.unread = 1;
self.save_session(&session);
@ -584,7 +692,9 @@ impl ChessApp {
None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"),
};
let _ = SessionStateMachine::apply_command(&mut session, CMD_RESIGN, false);
if let Err(error) = SessionStateMachine::apply_command(&mut session, CMD_RESIGN, false) {
return error_result(ERR_PROTOCOL_ERROR, &error.to_string());
}
session
.metadata
.insert("terminal".into(), JsonValue::String("win".into()));
@ -598,6 +708,7 @@ impl ChessApp {
session
.metadata
.insert("turn".into(), JsonValue::String("".into()));
session.clear_draw_offer();
session.unread = 1;
self.save_session(&session);
@ -639,7 +750,11 @@ impl ChessApp {
};
if is_valid_claim {
let _ = SessionStateMachine::apply_command(&mut session, CMD_DRAW_ACCEPT, false);
if let Err(error) =
SessionStateMachine::apply_command(&mut session, CMD_DRAW_ACCEPT, false)
{
return error_result(ERR_PROTOCOL_ERROR, &error.to_string());
}
session
.metadata
.insert("terminal".into(), JsonValue::String("draw".into()));
@ -653,6 +768,7 @@ impl ChessApp {
session
.metadata
.insert("turn".into(), JsonValue::String("".into()));
session.clear_draw_offer();
session.unread = 1;
self.save_session(&session);
@ -663,9 +779,14 @@ impl ChessApp {
};
}
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(true));
if session.has_draw_offer() {
return error_result(ERR_PROTOCOL_ERROR, "A draw offer is already outstanding");
}
if let Err(error) = SessionStateMachine::apply_command(&mut session, CMD_DRAW_OFFER, false)
{
return error_result(ERR_PROTOCOL_ERROR, &error.to_string());
}
session.set_draw_offer(sender_hash);
session.unread = 1;
self.save_session(&session);
@ -687,7 +808,20 @@ impl ChessApp {
None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"),
};
let _ = SessionStateMachine::apply_command(&mut session, CMD_DRAW_ACCEPT, false);
let Some(offered_by) = session.draw_offered_by() else {
return error_result(ERR_PROTOCOL_ERROR, "No draw offer is outstanding");
};
if offered_by == sender_hash {
return error_result(
ERR_PROTOCOL_ERROR,
"A participant cannot accept its own draw offer",
);
}
if let Err(error) = SessionStateMachine::apply_command(&mut session, CMD_DRAW_ACCEPT, false)
{
return error_result(ERR_PROTOCOL_ERROR, &error.to_string());
}
session
.metadata
.insert("terminal".into(), JsonValue::String("draw".into()));
@ -695,9 +829,7 @@ impl ChessApp {
"terminal_reason".into(),
JsonValue::String(R_AGREEMENT.into()),
);
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(false));
session.clear_draw_offer();
session
.metadata
.insert("turn".into(), JsonValue::String("".into()));
@ -722,9 +854,22 @@ impl ChessApp {
None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"),
};
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(false));
let Some(offered_by) = session.draw_offered_by() else {
return error_result(ERR_PROTOCOL_ERROR, "No draw offer is outstanding");
};
if offered_by == sender_hash {
return error_result(
ERR_PROTOCOL_ERROR,
"A participant cannot decline its own draw offer",
);
}
if let Err(error) =
SessionStateMachine::apply_command(&mut session, CMD_DRAW_DECLINE, false)
{
return error_result(ERR_PROTOCOL_ERROR, &error.to_string());
}
session.clear_draw_offer();
session.unread = 1;
self.save_session(&session);
@ -989,9 +1134,7 @@ impl ChessApp {
session
.metadata
.insert("winner".into(), JsonValue::String(winner_hash));
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(false));
session.clear_draw_offer();
Self::refresh_derived(&mut session, &board, &moves);
@ -1021,6 +1164,7 @@ impl ChessApp {
session
.metadata
.insert("turn".into(), JsonValue::String("".into()));
session.clear_draw_offer();
self.save_session(&session);
}
OutgoingResult {
@ -1050,6 +1194,7 @@ impl ChessApp {
}
if let Some(mut session) = self.get_session(session_id, identity_id) {
let mut completed_claim = false;
// On claim, pre-terminate locally so UI reflects immediately.
if reason == R_THREEFOLD || reason == R_FIFTY_MOVE {
let moves = meta_string_list(&session.metadata, "moves");
@ -1067,8 +1212,14 @@ impl ChessApp {
session
.metadata
.insert("turn".into(), JsonValue::String("".into()));
session.clear_draw_offer();
completed_claim = true;
}
}
if !completed_claim {
let _ = SessionStateMachine::apply_command(&mut session, CMD_DRAW_OFFER, false);
session.set_draw_offer(identity_id);
}
self.save_session(&session);
}
@ -1096,9 +1247,7 @@ impl ChessApp {
"terminal_reason".into(),
JsonValue::String(R_AGREEMENT.into()),
);
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(false));
session.clear_draw_offer();
session
.metadata
.insert("turn".into(), JsonValue::String("".into()));
@ -1110,6 +1259,19 @@ impl ChessApp {
}
}
fn handle_draw_decline_out(&self, session_id: &str, identity_id: &str) -> OutgoingResult {
if let Some(mut session) = self.get_session(session_id, identity_id)
&& SessionStateMachine::apply_command(&mut session, CMD_DRAW_DECLINE, false).is_ok()
{
session.clear_draw_offer();
self.save_session(&session);
}
OutgoingResult {
payload: HashMap::new(),
fallback_text: "[LRGP Chess] Declined draw offer".into(),
}
}
fn validate_move(
&self,
session: &Session,
@ -1140,12 +1302,13 @@ impl ChessApp {
_ => return (false, Some("Missing UCI move".into())),
};
let ply = payload.get(KEY_PLY).and_then(value_as_u64).unwrap_or(0);
let claimed_terminal = payload
.get(KEY_TERMINAL)
.and_then(|v| value_as_str(v))
.unwrap_or("")
.to_string();
let Some(ply) = payload.get(KEY_PLY).and_then(value_as_u64) else {
return (false, Some("Missing ply index".into()));
};
let Some(claimed_terminal) = payload.get(KEY_TERMINAL).and_then(value_as_str) else {
return (false, Some("Missing terminal marker".into()));
};
let claimed_terminal = claimed_terminal.to_string();
let claimed_reason = payload
.get(KEY_REASON)
.and_then(|v| value_as_str(v))
@ -1210,6 +1373,14 @@ impl ChessApp {
)),
);
}
if terminal.is_empty() && !claimed_reason.is_empty() {
return (
false,
Some(format!(
"Reason must be empty on a non-terminal move, got '{claimed_reason}'"
)),
);
}
if terminal == "win" && claimed_winner != sender_hash {
return (
false,
@ -1218,6 +1389,12 @@ impl ChessApp {
)),
);
}
if terminal != "win" && !claimed_winner.is_empty() {
return (
false,
Some("Winner must be empty unless the move is a win".into()),
);
}
(true, None)
}
@ -1316,10 +1493,9 @@ impl GameApp for ChessApp {
preferred_delivery.insert(CMD_DRAW_OFFER.into(), "opportunistic".into());
preferred_delivery.insert(CMD_DRAW_ACCEPT.into(), "direct".into());
preferred_delivery.insert(CMD_DRAW_DECLINE.into(), "direct".into());
preferred_delivery.insert(CMD_ERROR.into(), "opportunistic".into());
let mut ttl = HashMap::new();
ttl.insert(STATUS_PENDING.into(), TTL_PENDING);
ttl.insert(STATUS_ACTIVE.into(), TTL_ACTIVE);
let ttl = Self::ttl_policy();
AppManifest {
app_id: APP_ID.into(),
@ -1338,6 +1514,7 @@ impl GameApp for ChessApp {
CMD_DRAW_OFFER.into(),
CMD_DRAW_ACCEPT.into(),
CMD_DRAW_DECLINE.into(),
CMD_ERROR.into(),
],
preferred_delivery,
ttl,
@ -1352,6 +1529,17 @@ impl GameApp for ChessApp {
sender_hash: &str,
identity_id: &str,
) -> IncomingResult {
if command != CMD_ERROR
&& let Err(message) = self.validate_incoming_payload(
session_id,
command,
payload,
identity_id,
sender_hash,
)
{
return error_result(ERR_PROTOCOL_ERROR, &message);
}
match command {
CMD_CHALLENGE => {
self.handle_challenge_in(session_id, payload, sender_hash, identity_id)
@ -1394,10 +1582,7 @@ impl GameApp for ChessApp {
CMD_RESIGN => self.handle_resign_out(session_id, identity_id),
CMD_DRAW_OFFER => self.handle_draw_offer_out(session_id, payload, identity_id),
CMD_DRAW_ACCEPT => self.handle_draw_accept_out(session_id, identity_id),
CMD_DRAW_DECLINE => OutgoingResult {
payload: HashMap::new(),
fallback_text: "[LRGP Chess] Declined draw offer".into(),
},
CMD_DRAW_DECLINE => self.handle_draw_decline_out(session_id, identity_id),
_ => OutgoingResult {
payload: payload.clone(),
fallback_text: format!("[LRGP Chess] {command}"),
@ -1410,9 +1595,9 @@ impl GameApp for ChessApp {
session_id: &str,
command: &str,
payload: &HashMap<String, rmpv::Value>,
sender_hash: &str,
identity_id: &str,
) -> (bool, Option<String>) {
let session = match self.get_session(session_id, "") {
let session = match self.get_session(session_id, identity_id) {
Some(s) => s,
None => {
return if command == CMD_CHALLENGE {
@ -1423,25 +1608,174 @@ impl GameApp for ChessApp {
}
};
let ttl = {
let mut m = HashMap::new();
m.insert(STATUS_PENDING.to_string(), TTL_PENDING);
m.insert(STATUS_ACTIVE.to_string(), TTL_ACTIVE);
m
};
let mut session = session;
if SessionStateMachine::check_expiry(&mut session, Some(&ttl), None) {
if SessionStateMachine::check_expiry(&mut session, Some(&Self::ttl_policy()), None) {
self.save_session(&session);
return (false, Some("Session expired".into()));
}
if command == CMD_MOVE {
return self.validate_move(&session, payload, sender_hash);
return self.validate_move(&session, payload, identity_id);
}
(true, None)
}
fn validate_outgoing_action(
&self,
session_id: &str,
command: &str,
payload: &HashMap<String, rmpv::Value>,
identity_id: &str,
) -> (bool, Option<String>) {
match command {
CMD_CHALLENGE | CMD_ACCEPT | CMD_DECLINE | CMD_RESIGN | CMD_DRAW_ACCEPT
| CMD_DRAW_DECLINE => {
if !payload.is_empty() {
return (false, Some(format!("{command} payload must be empty")));
}
}
CMD_MOVE => {
if !has_exact_keys(payload, &[KEY_MOVE])
|| payload
.get(KEY_MOVE)
.and_then(value_as_str)
.filter(|uci| !uci.is_empty())
.is_none()
{
return (
false,
Some("move intent must contain exactly non-empty string m".into()),
);
}
}
CMD_DRAW_OFFER => {
if !(payload.is_empty() || has_exact_keys(payload, &[KEY_REASON])) {
return (
false,
Some("draw_offer intent must be empty or exactly {r}".into()),
);
}
if let Some(reason) = payload.get(KEY_REASON) {
let Some(reason) = value_as_str(reason) else {
return (false, Some("draw_offer r must be a string".into()));
};
if !matches!(reason, R_THREEFOLD | R_FIFTY_MOVE) {
return (
false,
Some(format!("unsupported draw claim reason '{reason}'")),
);
}
}
}
_ => return (false, Some(format!("Unsupported action: {command}"))),
}
let Some(session) = self.get_session(session_id, identity_id) else {
return if command == CMD_CHALLENGE {
(true, None)
} else {
(false, Some("Session not found".into()))
};
};
if session.status == STATUS_EXPIRED {
return (false, Some("Session expired".into()));
}
if command == CMD_CHALLENGE {
return (false, Some("Session already exists".into()));
}
if command == CMD_DRAW_OFFER && session.has_draw_offer() {
let claim_is_immediately_valid = payload
.get(KEY_REASON)
.and_then(value_as_str)
.and_then(|reason| {
let moves = meta_string_list(&session.metadata, "moves");
replay_moves(&moves)
.ok()
.map(|board| claim_reason(&board, &moves) == Some(reason))
})
.unwrap_or(false);
if !claim_is_immediately_valid {
return (false, Some("A draw offer is already outstanding".into()));
}
}
if matches!(command, CMD_DRAW_ACCEPT | CMD_DRAW_DECLINE) {
let Some(offered_by) = session.draw_offered_by() else {
return (false, Some("No draw offer is outstanding".into()));
};
if offered_by == identity_id {
return (
false,
Some("A participant cannot answer its own draw offer".into()),
);
}
}
if command != CMD_MOVE {
let mut candidate = session;
let state_command = if command == CMD_DRAW_OFFER {
let claim_is_immediately_valid = payload
.get(KEY_REASON)
.and_then(value_as_str)
.and_then(|reason| {
let moves = meta_string_list(&candidate.metadata, "moves");
replay_moves(&moves)
.ok()
.map(|board| claim_reason(&board, &moves) == Some(reason))
})
.unwrap_or(false);
if claim_is_immediately_valid {
CMD_DRAW_ACCEPT
} else {
CMD_DRAW_OFFER
}
} else {
command
};
return match SessionStateMachine::apply_command(&mut candidate, state_command, false) {
Ok(_) => (true, None),
Err(error) => (false, Some(error.to_string())),
};
}
if session.status != STATUS_ACTIVE {
return (
false,
Some(format!("Session is not active ({})", session.status)),
);
}
if meta_str(&session.metadata, "turn") != identity_id {
return (false, Some("Not your turn".into()));
}
let Some(uci) = payload.get(KEY_MOVE).and_then(value_as_str) else {
return (false, Some("Missing UCI move".into()));
};
let moves = meta_string_list(&session.metadata, "moves");
let mut board = match replay_moves(&moves) {
Ok(board) => board,
Err(error) => return (false, Some(format!("Local replay failed: {error}"))),
};
let mv: Move = match uci.parse() {
Ok(mv) => mv,
Err(_) => return (false, Some(format!("Invalid UCI: {uci}"))),
};
let mut legal = Vec::new();
board.generate_moves(|moves| {
legal.extend(moves);
false
});
if !legal.contains(&mv) {
return (
false,
Some(format!("Move {uci} is not legal from current position")),
);
}
// Exercise the move on the cloned board so future cozy-chess validation
// failures cannot occur only after the app begins mutating state.
board.play(mv);
(true, None)
}
fn get_session_state(&self, session_id: &str, identity_id: &str) -> HashMap<String, JsonValue> {
match self.get_session(session_id, identity_id) {
Some(s) => session_to_json(&s),
@ -1460,6 +1794,113 @@ impl GameApp for ChessApp {
}
}
fn get_session_record(&self, session_id: &str, identity_id: &str) -> Option<Session> {
self.get_session(session_id, identity_id)
}
fn upsert_session(&self, session: Session) -> Result<(), LrgpError> {
if session.app_id != self.app_id() || session.app_version != self.version() {
return Err(LrgpError::Validation {
code: ERR_UNSUPPORTED_APP.into(),
message: "session app/version does not match Chess".into(),
});
}
if session.identity_id.is_empty() {
return Err(LrgpError::Validation {
code: ERR_PROTOCOL_ERROR.into(),
message: "restored session must include identity_id".into(),
});
}
if !crate::envelope::is_valid_session_id(&session.session_id) {
return Err(LrgpError::InvalidEnvelope(
"restored session id must be exactly 16 lowercase hexadecimal characters".into(),
));
}
let mut session = session;
SessionStateMachine::check_expiry(&mut session, Some(&Self::ttl_policy()), None);
if session.draw_offered_by().is_none() {
session.clear_draw_offer();
} else if let Some(owner) = session.draw_offered_by()
&& owner != session.identity_id
&& owner != session.contact_hash
{
return Err(LrgpError::Validation {
code: ERR_PROTOCOL_ERROR.into(),
message: "draw offer owner is not a bound participant".into(),
});
}
self.save_session(&session);
Ok(())
}
fn remove_session(&self, session_id: &str, identity_id: &str) -> bool {
self.sessions
.lock()
.unwrap()
.remove(&(session_id.to_string(), identity_id.to_string()))
.is_some()
}
fn list_session_records(&self, identity_id: Option<&str>) -> Vec<Session> {
let mut sessions = self.sessions.lock().unwrap();
let ttl = Self::ttl_policy();
sessions
.values_mut()
.filter(|session| identity_id.is_none_or(|id| session.identity_id == id))
.map(|session| {
SessionStateMachine::check_expiry(session, Some(&ttl), None);
session.clone()
})
.collect()
}
fn bind_session_peer(
&self,
session_id: &str,
identity_id: &str,
peer_hash: &str,
) -> Result<(), LrgpError> {
if peer_hash.is_empty() {
return Err(LrgpError::ParticipantRequired);
}
let mut sessions = self.sessions.lock().unwrap();
let session = sessions
.get_mut(&(session_id.to_string(), identity_id.to_string()))
.ok_or_else(|| LrgpError::SessionNotFound(session_id.into()))?;
if !session.contact_hash.is_empty() && session.contact_hash != peer_hash {
return Err(LrgpError::UnauthorizedPeer {
session_id: session_id.into(),
});
}
session.contact_hash = peer_hash.into();
Ok(())
}
fn authorize_incoming(
&self,
session_id: &str,
command: &str,
sender_hash: &str,
identity_id: &str,
) -> Result<(), LrgpError> {
let Some(session) = self.get_session(session_id, identity_id) else {
return if command == CMD_CHALLENGE {
Ok(())
} else {
Err(LrgpError::SessionNotFound(session_id.into()))
};
};
if session.status == STATUS_EXPIRED {
return Err(LrgpError::SessionExpired(session_id.into()));
}
if session.contact_hash.is_empty() || session.contact_hash != sender_hash {
return Err(LrgpError::UnauthorizedPeer {
session_id: session_id.into(),
});
}
Ok(())
}
fn snapshot_session(&self, session_id: &str, identity_id: &str) -> Option<Session> {
self.get_session(session_id, identity_id)
}
@ -1896,6 +2337,69 @@ mod tests {
assert_eq!(meta_str(&sess.metadata, "terminal_reason"), R_AGREEMENT);
}
#[test]
fn test_remote_draw_response_uses_offer_owner_and_clears_it() {
let _coin = pin_coin(true);
let app = ChessApp::new();
setup_active(&app, "alice", "bob");
app.handle_outgoing("g1", CMD_DRAW_OFFER, &HashMap::new(), "alice");
let offered = app.get_session("g1", "alice").unwrap();
assert_eq!(offered.draw_offered_by(), Some("alice"));
let accepted = app.handle_incoming("g1", CMD_DRAW_ACCEPT, &HashMap::new(), "bob", "alice");
assert!(accepted.error.is_none());
let completed = app.get_session("g1", "alice").unwrap();
assert_eq!(completed.status, STATUS_COMPLETED);
assert_eq!(completed.draw_offered_by(), None);
}
#[test]
fn test_draw_offer_cannot_be_overwritten_or_answered_by_owner() {
let _coin = pin_coin(true);
let app = ChessApp::new();
setup_active(&app, "alice", "bob");
let offer = app.handle_incoming("g1", CMD_DRAW_OFFER, &HashMap::new(), "bob", "alice");
assert!(offer.error.is_none());
let duplicate = app.handle_incoming("g1", CMD_DRAW_OFFER, &HashMap::new(), "bob", "alice");
assert!(duplicate.error.is_some());
let self_decline =
app.handle_incoming("g1", CMD_DRAW_DECLINE, &HashMap::new(), "bob", "alice");
assert!(self_decline.error.is_some());
let session = app.get_session("g1", "alice").unwrap();
assert_eq!(session.status, STATUS_ACTIVE);
assert_eq!(session.draw_offered_by(), Some("bob"));
}
#[test]
fn test_strict_payload_shapes_reject_before_mutation() {
let _coin = pin_coin(true);
let app = ChessApp::new();
let junk = HashMap::from([("extra".into(), rmpv::Value::Boolean(true))]);
let malformed_challenge = app.handle_incoming("g1", CMD_CHALLENGE, &junk, "alice", "bob");
assert!(malformed_challenge.error.is_some());
assert!(app.get_session("g1", "bob").is_none());
setup_active(&app, "alice", "bob");
let before = app.get_session("g1", "bob").unwrap();
let intent = HashMap::from([(KEY_MOVE.into(), rmpv::Value::String("e2e4".into()))]);
let mut wire_move = app
.handle_outgoing("g1", CMD_MOVE, &intent, "alice")
.payload;
wire_move.insert("extra".into(), rmpv::Value::Nil);
let malformed = app.handle_incoming("g1", CMD_MOVE, &wire_move, "alice", "bob");
assert!(malformed.error.is_some());
let after = app.get_session("g1", "bob").unwrap();
assert_eq!(after.metadata["moves"], before.metadata["moves"]);
let invalid_offer =
HashMap::from([(KEY_REASON.into(), rmpv::Value::String(String::new().into()))]);
let (valid, _) =
app.validate_outgoing_action("g1", CMD_DRAW_OFFER, &invalid_offer, "alice");
assert!(!valid);
}
#[test]
fn test_draw_claim_threefold_auto_accepts() {
let _coin = pin_coin(true);
@ -1993,7 +2497,15 @@ mod tests {
let out = app.handle_outgoing("g1", CMD_MOVE, &p, "alice");
use crate::envelope::{pack_envelope, validate_envelope_size};
let env = pack_envelope(APP_ID, APP_VERSION, CMD_MOVE, "g1", Some(out.payload), None);
let env = pack_envelope(
APP_ID,
APP_VERSION,
CMD_MOVE,
"0000000000000001",
Some(out.payload),
None,
)
.unwrap();
let size = validate_envelope_size(&env).expect("envelope size OK");
assert!(size <= 150, "move envelope {} bytes (budget ≤150)", size);
}
@ -2021,7 +2533,8 @@ mod tests {
"abcdef0123456789",
Some(p),
None,
);
)
.unwrap();
let size = validate_envelope_size(&env).expect("envelope size OK");
assert!(
size <= 150,
@ -2085,6 +2598,7 @@ mod tests {
let mut p = HashMap::new();
p.insert(KEY_MOVE.to_string(), rmpv::Value::String("e2e4".into()));
p.insert(KEY_PLY.to_string(), rmpv::Value::Integer(1.into()));
p.insert(KEY_TERMINAL.to_string(), rmpv::Value::String("".into()));
let (valid, err) = app.validate_move(&session, &p, "alice");
assert!(!valid);
assert!(err.unwrap().contains("Ply mismatch"));

View file

@ -7,7 +7,8 @@ use serde_json::Value as JsonValue;
use crate::app_base::{AppManifest, GameApp, IncomingResult, OutgoingResult};
use crate::constants::*;
use crate::envelope::{value_as_str, value_as_u64};
use crate::envelope::{has_exact_keys, value_as_str, value_as_u64};
use crate::errors::LrgpError;
use crate::session::{Session, SessionStateMachine};
const EMPTY_BOARD: &str = "_________";
@ -25,6 +26,9 @@ const WIN_LINES: [(usize, usize, usize); 8] = [
fn check_winner(board: &str) -> Option<char> {
let b: Vec<char> = board.chars().collect();
if b.len() != EMPTY_BOARD.len() {
return None;
}
for &(a, bi, c) in &WIN_LINES {
if b[a] != '_' && b[a] == b[bi] && b[bi] == b[c] {
return Some(b[a]);
@ -34,7 +38,9 @@ fn check_winner(board: &str) -> Option<char> {
}
fn check_draw(board: &str) -> bool {
!board.contains('_') && check_winner(board).is_none()
board.len() == EMPTY_BOARD.len()
&& board.bytes().all(|cell| matches!(cell, b'X' | b'O'))
&& check_winner(board).is_none()
}
fn marker_for_move(move_num: u64) -> char {
@ -98,10 +104,10 @@ impl TicTacToeApp {
}
fn get_session(&self, session_id: &str, identity_id: &str) -> Option<Session> {
let sessions = self.sessions.lock().unwrap();
sessions
.get(&(session_id.to_string(), identity_id.to_string()))
.cloned()
let mut sessions = self.sessions.lock().unwrap();
let session = sessions.get_mut(&(session_id.to_string(), identity_id.to_string()))?;
SessionStateMachine::check_expiry(session, Some(&Self::ttl_policy()), None);
Some(session.clone())
}
fn save_session(&self, session: &Session) {
@ -112,6 +118,13 @@ impl TicTacToeApp {
);
}
fn ttl_policy() -> HashMap<String, f64> {
let mut ttl = HashMap::new();
ttl.insert(STATUS_PENDING.into(), 86400.0);
ttl.insert(STATUS_ACTIVE.into(), 86400.0);
ttl
}
fn default_metadata(my_marker: &str, first_turn: &str) -> HashMap<String, JsonValue> {
let mut m = HashMap::new();
m.insert("board".into(), JsonValue::String(EMPTY_BOARD.into()));
@ -122,9 +135,74 @@ impl TicTacToeApp {
m.insert("winner".into(), JsonValue::String("".into()));
m.insert("terminal".into(), JsonValue::String("".into()));
m.insert("draw_offered".into(), JsonValue::Bool(false));
m.insert("draw_offered_by".into(), JsonValue::String("".into()));
m
}
fn validate_incoming_payload(
&self,
session_id: &str,
command: &str,
payload: &HashMap<String, rmpv::Value>,
identity_id: &str,
) -> Result<(), String> {
match command {
CMD_CHALLENGE | CMD_DECLINE | CMD_RESIGN | CMD_DRAW_OFFER | CMD_DRAW_ACCEPT
| CMD_DRAW_DECLINE => {
if !payload.is_empty() {
return Err(format!("{command} payload must be empty"));
}
}
CMD_ACCEPT => {
if !has_exact_keys(payload, &["b", "t"]) {
return Err("accept payload must contain exactly b and t".into());
}
let board = payload.get("b").and_then(value_as_str).unwrap_or("");
let turn = payload.get("t").and_then(value_as_str).unwrap_or("");
let expected_turn = self
.get_session(session_id, identity_id)
.map(|session| meta_str(&session.metadata, "first_turn"))
.unwrap_or_default();
if board != EMPTY_BOARD {
return Err("accept board must be empty".into());
}
if expected_turn.is_empty() || turn != expected_turn {
return Err("accept first turn does not match challenge".into());
}
}
CMD_MOVE => {
let terminal = payload
.get("x")
.and_then(value_as_str)
.ok_or_else(|| "move terminal marker must be a string".to_string())?;
let expected: &[&str] = if terminal == "win" {
&["i", "b", "n", "t", "x", "w"]
} else {
&["i", "b", "n", "t", "x"]
};
if !has_exact_keys(payload, expected) {
return Err(format!(
"move payload has invalid keys for terminal marker '{terminal}'"
));
}
if !matches!(terminal, "" | "win" | "draw") {
return Err(format!("unsupported terminal marker '{terminal}'"));
}
if payload.get("i").and_then(value_as_u64).is_none()
|| payload.get("n").and_then(value_as_u64).is_none()
|| payload.get("b").and_then(value_as_str).is_none()
|| payload.get("t").and_then(value_as_str).is_none()
|| (terminal == "win" && payload.get("w").and_then(value_as_str).is_none())
{
return Err("move payload contains a value with the wrong type".into());
}
}
CMD_ERROR => {}
_ => return Err(format!("unsupported command '{command}'")),
}
Ok(())
}
// --- Incoming handlers ---
fn handle_challenge_in(
@ -134,6 +212,13 @@ impl TicTacToeApp {
sender_hash: &str,
identity_id: &str,
) -> IncomingResult {
if let Some(existing) = self.get_session(session_id, identity_id) {
return IncomingResult {
session: Some(session_to_json(&existing)),
emit: None,
error: None,
};
}
let mut session = Session::new(session_id);
session.identity_id = identity_id.to_string();
session.app_id = "ttt".to_string();
@ -168,20 +253,22 @@ impl TicTacToeApp {
session.contact_hash = sender_hash.to_string();
}
let first_turn = meta_str(&session.metadata, "first_turn");
let board = payload.get("b").and_then(value_as_str).unwrap_or("");
let turn = payload.get("t").and_then(value_as_str).unwrap_or("");
if board != EMPTY_BOARD {
return error_result(ERR_PROTOCOL_ERROR, "ACCEPT must contain an empty board");
}
if first_turn.is_empty() || turn != first_turn {
return error_result(
ERR_PROTOCOL_ERROR,
"ACCEPT first turn does not match challenge",
);
}
if let Err(e) = SessionStateMachine::apply_command(&mut session, CMD_ACCEPT, false) {
return error_result(ERR_PROTOCOL_ERROR, &e.to_string());
}
let board = payload
.get("b")
.and_then(value_as_str)
.unwrap_or(EMPTY_BOARD);
let first_turn = meta_str(&session.metadata, "first_turn");
let turn = payload
.get("t")
.and_then(value_as_str)
.unwrap_or(&first_turn);
session
.metadata
.insert("board".into(), JsonValue::String(board.to_string()));
@ -272,11 +359,13 @@ impl TicTacToeApp {
session
.metadata
.insert("winner".into(), JsonValue::String(winner.to_string()));
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(false));
session.clear_draw_offer();
let _ = SessionStateMachine::apply_command(&mut session, CMD_MOVE, !terminal.is_empty());
if let Err(error) =
SessionStateMachine::apply_command(&mut session, CMD_MOVE, !terminal.is_empty())
{
return error_result(ERR_PROTOCOL_ERROR, &error.to_string());
}
session.unread = 1;
self.save_session(&session);
@ -309,7 +398,9 @@ impl TicTacToeApp {
None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"),
};
let _ = SessionStateMachine::apply_command(&mut session, CMD_RESIGN, false);
if let Err(error) = SessionStateMachine::apply_command(&mut session, CMD_RESIGN, false) {
return error_result(ERR_PROTOCOL_ERROR, &error.to_string());
}
session
.metadata
.insert("terminal".into(), JsonValue::String("resign".into()));
@ -322,6 +413,7 @@ impl TicTacToeApp {
session
.metadata
.insert("winner".into(), JsonValue::String(winner));
session.clear_draw_offer();
session.unread = 1;
self.save_session(&session);
@ -343,9 +435,14 @@ impl TicTacToeApp {
None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"),
};
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(true));
if session.has_draw_offer() {
return error_result(ERR_PROTOCOL_ERROR, "A draw offer is already outstanding");
}
if let Err(error) = SessionStateMachine::apply_command(&mut session, CMD_DRAW_OFFER, false)
{
return error_result(ERR_PROTOCOL_ERROR, &error.to_string());
}
session.set_draw_offer(sender_hash);
session.unread = 1;
self.save_session(&session);
@ -367,13 +464,24 @@ impl TicTacToeApp {
None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"),
};
let _ = SessionStateMachine::apply_command(&mut session, CMD_DRAW_ACCEPT, false);
let Some(offered_by) = session.draw_offered_by() else {
return error_result(ERR_PROTOCOL_ERROR, "No draw offer is outstanding");
};
if offered_by == sender_hash {
return error_result(
ERR_PROTOCOL_ERROR,
"A participant cannot accept its own draw offer",
);
}
if let Err(error) = SessionStateMachine::apply_command(&mut session, CMD_DRAW_ACCEPT, false)
{
return error_result(ERR_PROTOCOL_ERROR, &error.to_string());
}
session
.metadata
.insert("terminal".into(), JsonValue::String("draw".into()));
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(false));
session.clear_draw_offer();
session.unread = 1;
self.save_session(&session);
@ -395,9 +503,22 @@ impl TicTacToeApp {
None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"),
};
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(false));
let Some(offered_by) = session.draw_offered_by() else {
return error_result(ERR_PROTOCOL_ERROR, "No draw offer is outstanding");
};
if offered_by == sender_hash {
return error_result(
ERR_PROTOCOL_ERROR,
"A participant cannot decline its own draw offer",
);
}
if let Err(error) =
SessionStateMachine::apply_command(&mut session, CMD_DRAW_DECLINE, false)
{
return error_result(ERR_PROTOCOL_ERROR, &error.to_string());
}
session.clear_draw_offer();
session.unread = 1;
self.save_session(&session);
@ -606,9 +727,7 @@ impl TicTacToeApp {
String::new()
}),
);
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(false));
session.clear_draw_offer();
let _ = SessionStateMachine::apply_command(&mut session, CMD_MOVE, !terminal.is_empty());
self.save_session(&session);
@ -629,6 +748,7 @@ impl TicTacToeApp {
"winner".into(),
JsonValue::String(session.contact_hash.clone()),
);
session.clear_draw_offer();
self.save_session(&session);
}
OutgoingResult {
@ -637,15 +757,26 @@ impl TicTacToeApp {
}
}
fn handle_draw_offer_out(&self, session_id: &str, identity_id: &str) -> OutgoingResult {
if let Some(mut session) = self.get_session(session_id, identity_id)
&& SessionStateMachine::apply_command(&mut session, CMD_DRAW_OFFER, false).is_ok()
{
session.set_draw_offer(identity_id);
self.save_session(&session);
}
OutgoingResult {
payload: HashMap::new(),
fallback_text: "[LRGP TTT] Offered a draw".into(),
}
}
fn handle_draw_accept_out(&self, session_id: &str, identity_id: &str) -> OutgoingResult {
if let Some(mut session) = self.get_session(session_id, identity_id) {
let _ = SessionStateMachine::apply_command(&mut session, CMD_DRAW_ACCEPT, false);
session
.metadata
.insert("terminal".into(), JsonValue::String("draw".into()));
session
.metadata
.insert("draw_offered".into(), JsonValue::Bool(false));
session.clear_draw_offer();
self.save_session(&session);
}
OutgoingResult {
@ -654,6 +785,19 @@ impl TicTacToeApp {
}
}
fn handle_draw_decline_out(&self, session_id: &str, identity_id: &str) -> OutgoingResult {
if let Some(mut session) = self.get_session(session_id, identity_id)
&& SessionStateMachine::apply_command(&mut session, CMD_DRAW_DECLINE, false).is_ok()
{
session.clear_draw_offer();
self.save_session(&session);
}
OutgoingResult {
payload: HashMap::new(),
fallback_text: "[LRGP TTT] Declined draw offer".into(),
}
}
// --- Validation ---
fn validate_move(
@ -688,10 +832,19 @@ impl TicTacToeApp {
};
let board_str = payload.get("b").and_then(value_as_str).unwrap_or("");
let move_num = payload.get("n").and_then(value_as_u64).unwrap_or(0);
let terminal = payload.get("x").and_then(value_as_str).unwrap_or("");
let Some(terminal) = payload.get("x").and_then(value_as_str) else {
return (false, Some("Terminal marker is required".into()));
};
// 3. Cell must be empty
let old_board = meta_str(meta, "board");
if old_board.len() != EMPTY_BOARD.len()
|| !old_board
.bytes()
.all(|cell| matches!(cell, b'_' | b'X' | b'O'))
{
return (false, Some("Stored board is invalid".into()));
}
let old_chars: Vec<char> = old_board.chars().collect();
if index >= old_chars.len() || old_chars[index] != '_' {
return (false, Some(format!("Cell {index} is already occupied")));
@ -727,6 +880,7 @@ impl TicTacToeApp {
// 6. Terminal status must match computed result
let winner = check_winner(board_str);
let is_draw = check_draw(board_str);
let claimed_winner = payload.get("w").and_then(value_as_str).unwrap_or("");
if winner.is_some() && terminal != "win" {
return (
@ -746,6 +900,21 @@ impl TicTacToeApp {
Some(format!("No win/draw but terminal='{terminal}'")),
);
}
if winner.is_some() {
if claimed_winner != sender_hash {
return (
false,
Some(format!(
"Winner mismatch: expected {sender_hash}, got {claimed_winner}"
)),
);
}
} else if !claimed_winner.is_empty() {
return (
false,
Some("Winner must be empty on a non-winning move".into()),
);
}
// 7. Turn must be opponent (or empty if terminal)
let next_turn = payload.get("t").and_then(value_as_str).unwrap_or("");
@ -846,10 +1015,9 @@ impl GameApp for TicTacToeApp {
preferred_delivery.insert(CMD_DRAW_OFFER.into(), "opportunistic".into());
preferred_delivery.insert(CMD_DRAW_ACCEPT.into(), "direct".into());
preferred_delivery.insert(CMD_DRAW_DECLINE.into(), "direct".into());
preferred_delivery.insert(CMD_ERROR.into(), "opportunistic".into());
let mut ttl = HashMap::new();
ttl.insert(STATUS_PENDING.into(), 86400.0);
ttl.insert(STATUS_ACTIVE.into(), 86400.0);
let ttl = Self::ttl_policy();
AppManifest {
app_id: "ttt".into(),
@ -868,6 +1036,7 @@ impl GameApp for TicTacToeApp {
CMD_DRAW_OFFER.into(),
CMD_DRAW_ACCEPT.into(),
CMD_DRAW_DECLINE.into(),
CMD_ERROR.into(),
],
preferred_delivery,
ttl,
@ -882,6 +1051,12 @@ impl GameApp for TicTacToeApp {
sender_hash: &str,
identity_id: &str,
) -> IncomingResult {
if command != CMD_ERROR
&& let Err(message) =
self.validate_incoming_payload(session_id, command, payload, identity_id)
{
return error_result(ERR_PROTOCOL_ERROR, &message);
}
match command {
CMD_CHALLENGE => {
self.handle_challenge_in(session_id, payload, sender_hash, identity_id)
@ -920,15 +1095,9 @@ impl GameApp for TicTacToeApp {
CMD_DECLINE => self.handle_decline_out(session_id, identity_id),
CMD_MOVE => self.handle_move_out(session_id, payload, identity_id),
CMD_RESIGN => self.handle_resign_out(session_id, identity_id),
CMD_DRAW_OFFER => OutgoingResult {
payload: HashMap::new(),
fallback_text: "[LRGP TTT] Offered a draw".into(),
},
CMD_DRAW_OFFER => self.handle_draw_offer_out(session_id, identity_id),
CMD_DRAW_ACCEPT => self.handle_draw_accept_out(session_id, identity_id),
CMD_DRAW_DECLINE => OutgoingResult {
payload: HashMap::new(),
fallback_text: "[LRGP TTT] Declined draw offer".into(),
},
CMD_DRAW_DECLINE => self.handle_draw_decline_out(session_id, identity_id),
_ => OutgoingResult {
payload: payload.clone(),
fallback_text: format!("[LRGP TTT] {command}"),
@ -941,9 +1110,9 @@ impl GameApp for TicTacToeApp {
session_id: &str,
command: &str,
payload: &HashMap<String, rmpv::Value>,
sender_hash: &str,
identity_id: &str,
) -> (bool, Option<String>) {
let session = match self.get_session(session_id, "") {
let session = match self.get_session(session_id, identity_id) {
Some(s) => s,
None => {
return if command == CMD_CHALLENGE {
@ -954,25 +1123,107 @@ impl GameApp for TicTacToeApp {
}
};
let ttl = {
let mut m = HashMap::new();
m.insert(STATUS_PENDING.to_string(), 86400.0);
m.insert(STATUS_ACTIVE.to_string(), 86400.0);
m
};
let mut session = session;
if SessionStateMachine::check_expiry(&mut session, Some(&ttl), None) {
if SessionStateMachine::check_expiry(&mut session, Some(&Self::ttl_policy()), None) {
self.save_session(&session);
return (false, Some("Session expired".into()));
}
if command == CMD_MOVE {
return self.validate_move(&session, payload, sender_hash);
return self.validate_move(&session, payload, identity_id);
}
(true, None)
}
fn validate_outgoing_action(
&self,
session_id: &str,
command: &str,
payload: &HashMap<String, rmpv::Value>,
identity_id: &str,
) -> (bool, Option<String>) {
match command {
CMD_CHALLENGE | CMD_ACCEPT | CMD_DECLINE | CMD_RESIGN | CMD_DRAW_OFFER
| CMD_DRAW_ACCEPT | CMD_DRAW_DECLINE => {
if !payload.is_empty() {
return (false, Some(format!("{command} payload must be empty")));
}
}
CMD_MOVE => {
if !has_exact_keys(payload, &["i"])
|| payload.get("i").and_then(value_as_u64).is_none()
{
return (
false,
Some("move intent must contain exactly integer i".into()),
);
}
}
_ => return (false, Some(format!("Unsupported action: {command}"))),
}
let Some(session) = self.get_session(session_id, identity_id) else {
return if command == CMD_CHALLENGE {
(true, None)
} else {
(false, Some("Session not found".into()))
};
};
if session.status == STATUS_EXPIRED {
return (false, Some("Session expired".into()));
}
if command == CMD_CHALLENGE {
return (false, Some("Session already exists".into()));
}
if command == CMD_DRAW_OFFER && session.has_draw_offer() {
return (false, Some("A draw offer is already outstanding".into()));
}
if matches!(command, CMD_DRAW_ACCEPT | CMD_DRAW_DECLINE) {
let Some(offered_by) = session.draw_offered_by() else {
return (false, Some("No draw offer is outstanding".into()));
};
if offered_by == identity_id {
return (
false,
Some("A participant cannot answer its own draw offer".into()),
);
}
}
if command != CMD_MOVE {
let mut candidate = session;
return match SessionStateMachine::apply_command(&mut candidate, command, false) {
Ok(_) => (true, None),
Err(error) => (false, Some(error.to_string())),
};
}
if session.status != STATUS_ACTIVE {
return (
false,
Some(format!("Session is not active ({})", session.status)),
);
}
if meta_str(&session.metadata, "turn") != identity_id {
return (false, Some("Not your turn".into()));
}
let Some(index) = payload.get("i").and_then(value_as_u64) else {
return (false, Some("Invalid cell index".into()));
};
if index > 8 {
return (false, Some("Invalid cell index".into()));
}
let board = meta_str(&session.metadata, "board");
if board.as_bytes().get(index as usize) != Some(&b'_') {
return (false, Some(format!("Cell {index} is already occupied")));
}
if session.contact_hash.is_empty() {
return (false, Some("Opponent unknown".into()));
}
(true, None)
}
fn get_session_state(&self, session_id: &str, identity_id: &str) -> HashMap<String, JsonValue> {
match self.get_session(session_id, identity_id) {
Some(s) => session_to_json(&s),
@ -991,6 +1242,115 @@ impl GameApp for TicTacToeApp {
}
}
fn get_session_record(&self, session_id: &str, identity_id: &str) -> Option<Session> {
self.get_session(session_id, identity_id)
}
fn upsert_session(&self, session: Session) -> Result<(), LrgpError> {
if session.app_id != self.app_id() || session.app_version != self.version() {
return Err(LrgpError::Validation {
code: ERR_UNSUPPORTED_APP.into(),
message: "session app/version does not match Tic-Tac-Toe".into(),
});
}
if session.identity_id.is_empty() {
return Err(LrgpError::Validation {
code: ERR_PROTOCOL_ERROR.into(),
message: "restored session must include identity_id".into(),
});
}
if !crate::envelope::is_valid_session_id(&session.session_id) {
return Err(LrgpError::InvalidEnvelope(
"restored session id must be exactly 16 lowercase hexadecimal characters".into(),
));
}
let mut session = session;
SessionStateMachine::check_expiry(&mut session, Some(&Self::ttl_policy()), None);
if session.draw_offered_by().is_none() {
// Pre-owner persisted records and stray owner metadata cannot
// safely authorize a response.
session.clear_draw_offer();
} else if let Some(owner) = session.draw_offered_by()
&& owner != session.identity_id
&& owner != session.contact_hash
{
return Err(LrgpError::Validation {
code: ERR_PROTOCOL_ERROR.into(),
message: "draw offer owner is not a bound participant".into(),
});
}
self.save_session(&session);
Ok(())
}
fn remove_session(&self, session_id: &str, identity_id: &str) -> bool {
self.sessions
.lock()
.unwrap()
.remove(&(session_id.to_string(), identity_id.to_string()))
.is_some()
}
fn list_session_records(&self, identity_id: Option<&str>) -> Vec<Session> {
let mut sessions = self.sessions.lock().unwrap();
let ttl = Self::ttl_policy();
sessions
.values_mut()
.filter(|session| identity_id.is_none_or(|id| session.identity_id == id))
.map(|session| {
SessionStateMachine::check_expiry(session, Some(&ttl), None);
session.clone()
})
.collect()
}
fn bind_session_peer(
&self,
session_id: &str,
identity_id: &str,
peer_hash: &str,
) -> Result<(), LrgpError> {
if peer_hash.is_empty() {
return Err(LrgpError::ParticipantRequired);
}
let mut sessions = self.sessions.lock().unwrap();
let session = sessions
.get_mut(&(session_id.to_string(), identity_id.to_string()))
.ok_or_else(|| LrgpError::SessionNotFound(session_id.into()))?;
if !session.contact_hash.is_empty() && session.contact_hash != peer_hash {
return Err(LrgpError::UnauthorizedPeer {
session_id: session_id.into(),
});
}
session.contact_hash = peer_hash.into();
Ok(())
}
fn authorize_incoming(
&self,
session_id: &str,
command: &str,
sender_hash: &str,
identity_id: &str,
) -> Result<(), LrgpError> {
let Some(session) = self.get_session(session_id, identity_id) else {
return if command == CMD_CHALLENGE {
Ok(())
} else {
Err(LrgpError::SessionNotFound(session_id.into()))
};
};
if session.status == STATUS_EXPIRED {
return Err(LrgpError::SessionExpired(session_id.into()));
}
if session.contact_hash.is_empty() || session.contact_hash != sender_hash {
return Err(LrgpError::UnauthorizedPeer {
session_id: session_id.into(),
});
}
Ok(())
}
fn snapshot_session(&self, session_id: &str, identity_id: &str) -> Option<Session> {
self.get_session(session_id, identity_id)
}
@ -1456,6 +1816,77 @@ mod tests {
assert_eq!(sess.metadata["terminal"], "draw");
}
#[test]
fn test_remote_draw_response_uses_offer_owner_and_clears_it() {
let app = TicTacToeApp::new();
app.handle_outgoing("g1", CMD_CHALLENGE, &HashMap::new(), "alice");
app.handle_incoming("g1", CMD_CHALLENGE, &HashMap::new(), "alice", "bob");
let accept = app.handle_outgoing("g1", CMD_ACCEPT, &HashMap::new(), "bob");
app.handle_incoming("g1", CMD_ACCEPT, &accept.payload, "bob", "alice");
app.handle_outgoing("g1", CMD_DRAW_OFFER, &HashMap::new(), "alice");
let offered = app.get_session("g1", "alice").unwrap();
assert_eq!(offered.draw_offered_by(), Some("alice"));
let accepted = app.handle_incoming("g1", CMD_DRAW_ACCEPT, &HashMap::new(), "bob", "alice");
assert!(accepted.error.is_none());
let completed = app.get_session("g1", "alice").unwrap();
assert_eq!(completed.status, STATUS_COMPLETED);
assert_eq!(completed.draw_offered_by(), None);
}
#[test]
fn test_draw_offer_cannot_be_overwritten_or_answered_by_owner() {
let app = TicTacToeApp::new();
app.handle_outgoing("g1", CMD_CHALLENGE, &HashMap::new(), "alice");
app.handle_incoming("g1", CMD_CHALLENGE, &HashMap::new(), "alice", "bob");
let accept = app.handle_outgoing("g1", CMD_ACCEPT, &HashMap::new(), "bob");
app.handle_incoming("g1", CMD_ACCEPT, &accept.payload, "bob", "alice");
let offer = app.handle_incoming("g1", CMD_DRAW_OFFER, &HashMap::new(), "bob", "alice");
assert!(offer.error.is_none());
let duplicate = app.handle_incoming("g1", CMD_DRAW_OFFER, &HashMap::new(), "bob", "alice");
assert!(duplicate.error.is_some());
let self_accept =
app.handle_incoming("g1", CMD_DRAW_ACCEPT, &HashMap::new(), "bob", "alice");
assert!(self_accept.error.is_some());
let session = app.get_session("g1", "alice").unwrap();
assert_eq!(session.status, STATUS_ACTIVE);
assert_eq!(session.draw_offered_by(), Some("bob"));
}
#[test]
fn test_strict_payload_shapes_reject_before_mutation() {
let app = TicTacToeApp::new();
let junk = HashMap::from([("extra".into(), rmpv::Value::Boolean(true))]);
let malformed_challenge = app.handle_incoming("g1", CMD_CHALLENGE, &junk, "alice", "bob");
assert!(malformed_challenge.error.is_some());
assert!(app.get_session("g1", "bob").is_none());
app.handle_outgoing("g1", CMD_CHALLENGE, &HashMap::new(), "alice");
app.handle_incoming("g1", CMD_CHALLENGE, &HashMap::new(), "alice", "bob");
let accept = app.handle_outgoing("g1", CMD_ACCEPT, &HashMap::new(), "bob");
app.handle_incoming("g1", CMD_ACCEPT, &accept.payload, "bob", "alice");
let before = app.get_session("g1", "bob").unwrap();
let intent = HashMap::from([("i".into(), rmpv::Value::from(4))]);
let mut wire_move = app
.handle_outgoing("g1", CMD_MOVE, &intent, "alice")
.payload;
wire_move.insert("extra".into(), rmpv::Value::Nil);
let malformed = app.handle_incoming("g1", CMD_MOVE, &wire_move, "alice", "bob");
assert!(malformed.error.is_some());
let after = app.get_session("g1", "bob").unwrap();
assert_eq!(after.metadata["board"], before.metadata["board"]);
let invalid_intent = HashMap::from([
("i".into(), rmpv::Value::from(4)),
("extra".into(), rmpv::Value::Nil),
]);
let (valid, _) = app.validate_outgoing_action("g1", CMD_MOVE, &invalid_intent, "alice");
assert!(!valid);
}
#[test]
fn test_render_fallback() {
let app = TicTacToeApp::new();

View file

@ -59,14 +59,21 @@ pub const KEY_APP: &str = "a";
pub const KEY_COMMAND: &str = "c";
pub const KEY_SESSION: &str = "s";
pub const KEY_PAYLOAD: &str = "p";
/// Optional 8-byte per-envelope replay-dedup nonce.
/// Required 8-byte per-envelope replay-dedup nonce.
pub const KEY_NONCE: &str = "n";
/// Nonce / replay-dedup sizing.
pub const NONCE_BYTES: usize = 8;
pub const DEDUP_CACHE_PER_SESSION: usize = 512;
/// Maximum number of live per-session nonce caches retained by one router.
pub const DEDUP_CACHE_SESSIONS: usize = 1024;
pub const DEDUP_TTL_SECONDS: u64 = 600;
/// Incoming challenge admission limits. Only pending sessions count; active
/// games are never evicted to make room.
pub const PENDING_SESSIONS_PER_IDENTITY_MAX: usize = 128;
pub const PENDING_SESSIONS_PER_PARTICIPANT_MAX: usize = 16;
/// Error payload keys.
pub const KEY_ERR_CODE: &str = "code";
pub const KEY_ERR_MSG: &str = "msg";

View file

@ -1,28 +1,30 @@
//! Per-session envelope replay-dedup cache.
//!
//! LRGP envelopes carry an optional 8-byte `n` nonce (see [`crate::envelope`]).
//! LRGP envelopes carry a required 8-byte `n` nonce (see [`crate::envelope`]).
//! The receiver keeps a bounded, TTL'd cache of recently-seen
//! `(session_id, nonce)` pairs. If an inbound envelope's nonce is already in
//! the cache for that session, it is treated as a retransmit and dropped;
//! otherwise the nonce is recorded and the envelope is dispatched normally.
//! `(receiving_identity_id, session_id, nonce)` tuples. If an inbound
//! envelope's nonce is already in that namespace, it is treated as a
//! retransmit and dropped; otherwise the nonce is recorded and the envelope is
//! dispatched normally.
//!
//! See `rs/docs/lrgp-nonce-design.md` for the cross-implementation contract.
//! Mirrors the Python `lrgp.dedup.ReplayDedup` class.
//! The normative cross-implementation contract is the repository's `SPEC.md`.
//!
//! Design constraints:
//!
//! * Keyed by session id so cross-session reuse of a nonce value (negligible
//! but free to isolate) cannot cause a false reject.
//! * LRU bound prevents unbounded growth inside a single long-running session.
//! * Scoped by receiving identity and session so cross-identity/session nonce
//! reuse (negligible but free to isolate) cannot cause a false reject.
//! * Inner and outer LRU bounds prevent unbounded growth.
//! * TTL bound makes the cache forget nonces older than any realistic round
//! trip, which limits memory for sessions that never reach terminal state.
//! * Caller is responsible for [`ReplayDedup::drop_session`] on session close;
//! the cache does not inspect session state on its own.
//! * Terminal-session entries deliberately remain through their normal TTL so
//! late transport retransmits cannot produce duplicate UI/state events.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use crate::constants::{DEDUP_CACHE_PER_SESSION, DEDUP_TTL_SECONDS, KEY_NONCE, KEY_SESSION};
use crate::constants::{
DEDUP_CACHE_PER_SESSION, DEDUP_CACHE_SESSIONS, DEDUP_TTL_SECONDS, KEY_NONCE, KEY_SESSION,
};
use crate::envelope::Envelope;
/// Verdict returned by [`ReplayDedup::check`].
@ -34,24 +36,28 @@ pub enum DedupVerdict {
Replay,
}
/// Per-session bounded LRU of `(session_id, nonce)` → last-seen time.
/// Bounded LRU of `(receiving_identity_id, session_id, nonce)` observations.
pub struct ReplayDedup {
max_per_session: usize,
max_sessions: usize,
ttl: Duration,
by_session: HashMap<String, SessionCache>,
by_session: HashMap<(String, String), SessionCache>,
}
struct SessionCache {
// (nonce, last_seen). Kept in most-recent-at-end order so eviction pops
// the front. Lookup is linear in N, which is bounded by max_per_session
// (512 by default) — cheap.
// (nonce, first_seen). Kept in most-recently-used-at-end order so eviction
// pops the front. Replay hits move an entry without changing first_seen,
// preserving the absolute TTL. Lookup is linear in N, which is bounded by
// max_per_session (512 by default) — cheap.
entries: Vec<(Vec<u8>, Instant)>,
last_touched: Instant,
}
impl SessionCache {
fn new() -> Self {
fn new(now: Instant) -> Self {
Self {
entries: Vec::new(),
last_touched: now,
}
}
@ -75,8 +81,14 @@ impl ReplayDedup {
}
pub fn with_bounds(max_per_session: usize, ttl_seconds: u64) -> Self {
Self::with_limits(max_per_session, ttl_seconds, DEDUP_CACHE_SESSIONS)
}
/// Build a cache with explicit per-session, TTL, and session-count bounds.
pub fn with_limits(max_per_session: usize, ttl_seconds: u64, max_sessions: usize) -> Self {
Self {
max_per_session,
max_sessions: max_sessions.max(1),
ttl: Duration::from_secs(ttl_seconds),
by_session: HashMap::new(),
}
@ -88,7 +100,23 @@ impl ReplayDedup {
/// nonce)` pair was already seen (caller should drop it). Otherwise
/// the nonce is recorded and [`DedupVerdict::Fresh`] is returned.
pub fn check(&mut self, envelope: &Envelope) -> DedupVerdict {
self.check_at(envelope, Instant::now())
self.check_scoped_at("", envelope, Instant::now())
}
/// Check a nonce in one receiving identity's namespace.
pub fn check_scoped(&mut self, identity_id: &str, envelope: &Envelope) -> DedupVerdict {
self.check_scoped_at(identity_id, envelope, Instant::now())
}
/// Probe one receiving identity's namespace without recording a fresh
/// nonce or evicting any existing entry.
///
/// Routers use this before participant authorization, then call
/// [`check_scoped`](Self::check_scoped) after authorization succeeds. The
/// second atomic check resolves concurrent duplicate races, while a stream
/// of unauthenticated fresh nonces cannot consume or evict replay state.
pub fn probe_scoped(&mut self, identity_id: &str, envelope: &Envelope) -> DedupVerdict {
self.probe_scoped_at(identity_id, envelope, Instant::now())
}
/// [`check`](Self::check) with an injected clock for deterministic tests.
@ -96,6 +124,16 @@ impl ReplayDedup {
/// Envelope MUST be post-`unpack_envelope` validated; missing/malformed
/// fields here are a protocol violation and are dropped as `Replay`.
pub fn check_at(&mut self, envelope: &Envelope, now: Instant) -> DedupVerdict {
self.check_scoped_at("", envelope, now)
}
/// [`check_scoped`](Self::check_scoped) with an injected clock.
pub fn check_scoped_at(
&mut self,
identity_id: &str,
envelope: &Envelope,
now: Instant,
) -> DedupVerdict {
let nonce = match envelope.get(KEY_NONCE) {
Some(rmpv::Value::Binary(b)) if b.len() == crate::constants::NONCE_BYTES => b.clone(),
_ => return DedupVerdict::Replay,
@ -108,17 +146,21 @@ impl ReplayDedup {
_ => return DedupVerdict::Replay,
};
self.prune_expired_sessions(now);
self.make_room_for_session(identity_id, &session_id);
let cache = self
.by_session
.entry(session_id)
.or_insert_with(SessionCache::new);
.entry((identity_id.to_string(), session_id))
.or_insert_with(|| SessionCache::new(now));
cache.last_touched = now;
cache.prune_expired(now, self.ttl);
if let Some(pos) = cache.position(&nonce) {
// Refresh recency so an active duplicate burst doesn't evict
// the canonical entry mid-stream.
// Refresh LRU ordering, but deliberately retain the first-seen
// timestamp. Replays do not extend the protocol TTL window.
let entry = cache.entries.remove(pos);
cache.entries.push((entry.0, now));
cache.entries.push(entry);
return DedupVerdict::Replay;
}
@ -130,10 +172,111 @@ impl ReplayDedup {
DedupVerdict::Fresh
}
/// Forget every nonce for `session_id`. Called on session terminal states
/// so a long-lived node doesn't accumulate dead session caches forever.
/// [`probe_scoped`](Self::probe_scoped) with an injected clock.
pub fn probe_scoped_at(
&mut self,
identity_id: &str,
envelope: &Envelope,
now: Instant,
) -> DedupVerdict {
let nonce = match envelope.get(KEY_NONCE) {
Some(rmpv::Value::Binary(b)) if b.len() == crate::constants::NONCE_BYTES => b,
_ => return DedupVerdict::Replay,
};
let session_id = match envelope.get(KEY_SESSION) {
Some(rmpv::Value::String(s)) => match s.as_str() {
Some(s) => s,
None => return DedupVerdict::Replay,
},
_ => return DedupVerdict::Replay,
};
self.prune_expired_sessions(now);
let key = (identity_id.to_string(), session_id.to_string());
let Some(cache) = self.by_session.get_mut(&key) else {
return DedupVerdict::Fresh;
};
cache.last_touched = now;
if let Some(position) = cache.position(nonce) {
// Retain the original first-seen timestamp while refreshing only
// bounded LRU order, just like an ordinary replay check.
let entry = cache.entries.remove(position);
cache.entries.push(entry);
DedupVerdict::Replay
} else {
DedupVerdict::Fresh
}
}
/// Forget a session in the legacy unscoped namespace used by [`Self::check`].
///
/// Scoped integrations must use [`drop_scoped_session`](Self::drop_scoped_session)
/// so deleting one identity's session never affects another identity.
/// Do not call either helper merely because a session became terminal;
/// terminal replay entries remain useful through their normal TTL.
pub fn drop_session(&mut self, session_id: &str) {
self.by_session.remove(session_id);
self.drop_scoped_session("", session_id);
}
/// Forget a session cache only for one receiving identity.
pub fn drop_scoped_session(&mut self, identity_id: &str, session_id: &str) {
self.by_session
.remove(&(identity_id.to_string(), session_id.to_string()));
}
/// Remove one previously-recorded nonce in the legacy unscoped namespace.
/// This is a low-level transaction-recovery primitive; router integrations
/// should use the scoped recovery APIs on [`crate::router::LrgpRouter`].
/// Ordinary authorization failures never record a nonce.
pub fn forget_nonce(&mut self, session_id: &str, nonce: &[u8]) {
self.forget_scoped_nonce("", session_id, nonce);
}
pub fn forget_scoped_nonce(&mut self, identity_id: &str, session_id: &str, nonce: &[u8]) {
let key = (identity_id.to_string(), session_id.to_string());
if let Some(cache) = self.by_session.get_mut(&key)
&& let Some(position) = cache.position(nonce)
{
cache.entries.remove(position);
}
if self
.by_session
.get(&key)
.is_some_and(|cache| cache.entries.is_empty())
{
self.by_session.remove(&key);
}
}
fn prune_expired_sessions(&mut self, now: Instant) {
let ttl = self.ttl;
self.by_session.retain(|_, cache| {
cache.prune_expired(now, ttl);
!cache.entries.is_empty()
});
}
fn make_room_for_session(&mut self, identity_id: &str, incoming_session: &str) {
let incoming_key = (identity_id.to_string(), incoming_session.to_string());
if self.max_sessions == 0 || self.by_session.contains_key(&incoming_key) {
return;
}
while self.by_session.len() >= self.max_sessions {
let Some(oldest) = self
.by_session
.iter()
.min_by_key(|(_, cache)| cache.last_touched)
.map(|(session_key, _)| session_key.clone())
else {
break;
};
self.by_session.remove(&oldest);
}
}
#[cfg(test)]
fn session_count(&self) -> usize {
self.by_session.len()
}
}
@ -149,7 +292,12 @@ mod tests {
use crate::envelope::pack_envelope;
fn env(session: &str, nonce: [u8; 8]) -> Envelope {
pack_envelope("ttt", 1, "move", session, None, Some(nonce))
let mut envelope =
pack_envelope("ttt", 1, "move", "0000000000000000", None, Some(nonce)).unwrap();
// ReplayDedup intentionally operates after canonical validation; its
// unit tests use short labels to make cache-scope assertions legible.
envelope.insert(KEY_SESSION.into(), rmpv::Value::String(session.into()));
envelope
}
#[test]
@ -158,6 +306,18 @@ mod tests {
assert_eq!(d.check(&env("s1", [0; 8])), DedupVerdict::Fresh);
}
#[test]
fn fresh_probe_does_not_record_or_evict_existing_nonce() {
let mut d = ReplayDedup::with_limits(1, 600, 4);
let recorded = env("s1", [1; 8]);
let untrusted = env("s1", [2; 8]);
assert_eq!(d.check_scoped("local", &recorded), DedupVerdict::Fresh);
assert_eq!(d.probe_scoped("local", &untrusted), DedupVerdict::Fresh);
assert_eq!(d.probe_scoped("local", &recorded), DedupVerdict::Replay);
assert_eq!(d.check_scoped("local", &recorded), DedupVerdict::Replay);
}
#[test]
fn same_nonce_same_session_is_replay() {
let mut d = ReplayDedup::new();
@ -218,4 +378,47 @@ mod tests {
let mut d = ReplayDedup::new();
d.drop_session("never-seen");
}
#[test]
fn duplicate_does_not_extend_absolute_ttl() {
let start = Instant::now();
let mut d = ReplayDedup::with_bounds(4, 10);
let e = env("0000000000000001", [0x66; 8]);
assert_eq!(d.check_at(&e, start), DedupVerdict::Fresh);
assert_eq!(
d.check_at(&e, start + Duration::from_secs(9)),
DedupVerdict::Replay
);
assert_eq!(
d.check_at(&e, start + Duration::from_secs(11)),
DedupVerdict::Fresh
);
}
#[test]
fn outer_session_map_is_bounded() {
let mut d = ReplayDedup::with_limits(4, 3600, 2);
assert_eq!(
d.check(&env("0000000000000001", [1; 8])),
DedupVerdict::Fresh
);
assert_eq!(
d.check(&env("0000000000000002", [2; 8])),
DedupVerdict::Fresh
);
assert_eq!(
d.check(&env("0000000000000003", [3; 8])),
DedupVerdict::Fresh
);
assert_eq!(d.session_count(), 2);
}
#[test]
fn receiving_identities_have_independent_replay_namespaces() {
let mut d = ReplayDedup::new();
let envelope = env("0000000000000001", [7; 8]);
assert_eq!(d.check_scoped("alice", &envelope), DedupVerdict::Fresh);
assert_eq!(d.check_scoped("alice", &envelope), DedupVerdict::Replay);
assert_eq!(d.check_scoped("bob", &envelope), DedupVerdict::Fresh);
}
}

View file

@ -8,6 +8,21 @@ use crate::errors::LrgpError;
/// An LRGP envelope — the top-level dict stored in LXMF field 0xFD.
pub type Envelope = HashMap<String, rmpv::Value>;
/// Canonically validated envelope fields.
///
/// Construct this only through [`validate_envelope`]. Keeping validation in a
/// single entry point prevents the transport and router from interpreting the
/// same wire map differently.
#[derive(Debug, Clone)]
pub struct ValidatedEnvelope {
pub app_id: String,
pub version: u32,
pub command: String,
pub session_id: String,
pub payload: HashMap<String, rmpv::Value>,
pub nonce: [u8; NONCE_BYTES],
}
/// Convenience re-export of rmpv::Value for payload manipulation.
pub use rmpv::Value;
@ -19,6 +34,14 @@ pub fn generate_nonce() -> [u8; NONCE_BYTES] {
n
}
/// Generate a canonical 16-character lowercase hexadecimal session ID.
pub fn generate_session_id() -> String {
use rand::RngCore;
let mut id = [0u8; 8];
rand::thread_rng().fill_bytes(&mut id);
hex::encode(id)
}
/// Build an LRGP envelope dict. If `nonce` is `None` a fresh CSPRNG nonce is
/// generated; pass `Some(..)` to build deterministic test vectors.
pub fn pack_envelope(
@ -28,6 +51,19 @@ pub fn pack_envelope(
session_id: &str,
payload: Option<HashMap<String, rmpv::Value>>,
nonce: Option<[u8; NONCE_BYTES]>,
) -> Result<Envelope, LrgpError> {
let env = build_envelope(app_id, version, command, session_id, payload, nonce);
validate_envelope(&env)?;
Ok(env)
}
fn build_envelope(
app_id: &str,
version: u32,
command: &str,
session_id: &str,
payload: Option<HashMap<String, rmpv::Value>>,
nonce: Option<[u8; NONCE_BYTES]>,
) -> Envelope {
let mut env = Envelope::new();
env.insert(
@ -51,7 +87,7 @@ pub fn pack_envelope(
/// Validate that the packed envelope fits within ENVELOPE_MAX_PACKED.
/// Returns the packed size in bytes.
pub fn validate_envelope_size(envelope: &Envelope) -> Result<usize, LrgpError> {
let packed = pack_to_bytes(envelope)?;
let packed = encode_envelope_map(envelope)?;
let size = packed.len();
if size > ENVELOPE_MAX_PACKED {
return Err(LrgpError::EnvelopeTooLarge(size, ENVELOPE_MAX_PACKED));
@ -59,13 +95,121 @@ pub fn validate_envelope_size(envelope: &Envelope) -> Result<usize, LrgpError> {
Ok(size)
}
/// Validate every protocol-level envelope invariant shared by all LRGP apps.
///
/// App registration, supported version, and supported action checks belong to
/// the router because they depend on its live app registry.
pub fn validate_envelope(envelope: &Envelope) -> Result<ValidatedEnvelope, LrgpError> {
let required = [KEY_APP, KEY_COMMAND, KEY_SESSION, KEY_PAYLOAD, KEY_NONCE];
if envelope.len() != required.len()
|| !envelope.keys().all(|key| required.contains(&key.as_str()))
{
return Err(LrgpError::InvalidEnvelope(
"envelope must contain exactly the keys a, c, s, p, and n".into(),
));
}
for key in &required {
if !envelope.contains_key(*key) {
return Err(LrgpError::InvalidEnvelope(format!(
"Missing envelope key: {key}"
)));
}
}
validate_envelope_size(envelope)?;
let app_ver = envelope
.get(KEY_APP)
.and_then(value_as_str)
.ok_or_else(|| LrgpError::InvalidEnvelope("KEY_APP must be a string".into()))?;
let (app_id, version) = parse_app_version(app_ver)
.ok_or_else(|| LrgpError::InvalidEnvelope("invalid app.version format".into()))?;
if !is_valid_app_id(app_id) {
return Err(LrgpError::InvalidEnvelope(
"app id must match [a-z][a-z0-9_.-]*".into(),
));
}
let command = envelope
.get(KEY_COMMAND)
.and_then(value_as_str)
.ok_or_else(|| LrgpError::InvalidEnvelope("KEY_COMMAND must be a string".into()))?;
if !is_valid_command(command) {
return Err(LrgpError::InvalidEnvelope(
"command must match [a-z][a-z0-9_]*".into(),
));
}
let session_id = envelope
.get(KEY_SESSION)
.and_then(value_as_str)
.ok_or_else(|| LrgpError::InvalidEnvelope("KEY_SESSION must be a string".into()))?;
if !is_valid_session_id(session_id) {
return Err(LrgpError::InvalidEnvelope(
"session id must be exactly 16 lowercase hexadecimal characters".into(),
));
}
let payload = envelope
.get(KEY_PAYLOAD)
.and_then(map_from_value)
.ok_or_else(|| LrgpError::InvalidEnvelope("KEY_PAYLOAD must be a map".into()))?;
let nonce = match envelope.get(KEY_NONCE) {
Some(rmpv::Value::Binary(bytes)) if bytes.len() == NONCE_BYTES => {
let mut nonce = [0u8; NONCE_BYTES];
nonce.copy_from_slice(bytes);
nonce
}
_ => {
return Err(LrgpError::InvalidEnvelope(format!(
"KEY_NONCE must be {NONCE_BYTES}-byte binary"
)));
}
};
Ok(ValidatedEnvelope {
app_id: app_id.to_string(),
version,
command: command.to_string(),
session_id: session_id.to_string(),
payload,
nonce,
})
}
/// Return true only for the canonical 8-byte lowercase-hex session encoding.
pub fn is_valid_session_id(session_id: &str) -> bool {
session_id.len() == 16
&& session_id
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
/// Return true for the canonical game identifier grammar.
pub fn is_valid_app_id(app_id: &str) -> bool {
let mut bytes = app_id.bytes();
matches!(bytes.next(), Some(b'a'..=b'z'))
&& bytes.all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b'-')
})
}
/// Return true for the canonical command grammar.
pub fn is_valid_command(command: &str) -> bool {
let mut bytes = command.bytes();
matches!(bytes.next(), Some(b'a'..=b'z'))
&& bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
}
/// Return LXMF fields dict ready for inclusion in an LxMessage.
/// Returns `{0xFB: "lrgp.v1", 0xFD: envelope}` as a HashMap<u8, ...>.
pub fn pack_lxmf_fields(envelope: &Envelope) -> HashMap<u8, rmpv::Value> {
pub fn pack_lxmf_fields(envelope: &Envelope) -> Result<HashMap<u8, rmpv::Value>, LrgpError> {
validate_envelope(envelope)?;
let mut fields = HashMap::new();
fields.insert(FIELD_CUSTOM_TYPE, rmpv::Value::String(PROTOCOL_TYPE.into()));
fields.insert(FIELD_CUSTOM_META, value_from_map(envelope.clone()));
fields
Ok(fields)
}
/// Extract and validate an LRGP envelope from LXMF fields.
@ -87,37 +231,7 @@ pub fn unpack_envelope(fields: &HashMap<u8, rmpv::Value>) -> Result<Option<Envel
let envelope = map_from_value(meta)
.ok_or_else(|| LrgpError::InvalidEnvelope("FIELD_CUSTOM_META is not a map".into()))?;
for key in &[KEY_APP, KEY_COMMAND, KEY_SESSION, KEY_PAYLOAD, KEY_NONCE] {
if !envelope.contains_key(*key) {
return Err(LrgpError::InvalidEnvelope(format!(
"Missing envelope key: {key}"
)));
}
}
let app_ver = envelope
.get(KEY_APP)
.and_then(|v| match v {
rmpv::Value::String(s) => s.as_str().map(|s| s.to_string()),
_ => None,
})
.ok_or_else(|| LrgpError::InvalidEnvelope("KEY_APP is not a string".into()))?;
if !app_ver.contains('.') {
return Err(LrgpError::InvalidEnvelope(format!(
"Invalid app.version format: {app_ver:?}"
)));
}
match envelope.get(KEY_NONCE) {
Some(rmpv::Value::Binary(b)) if b.len() == NONCE_BYTES => {}
Some(v) => {
return Err(LrgpError::InvalidEnvelope(format!(
"KEY_NONCE must be {NONCE_BYTES}-byte binary; got {v:?}"
)));
}
None => unreachable!("KEY_NONCE presence enforced above"),
}
validate_envelope(&envelope)?;
Ok(Some(envelope))
}
@ -126,7 +240,14 @@ pub fn unpack_envelope(fields: &HashMap<u8, rmpv::Value>) -> Result<Option<Envel
pub fn parse_app_version(app_ver_string: &str) -> Option<(&str, u32)> {
let dot = app_ver_string.rfind('.')?;
let app_id = &app_ver_string[..dot];
let version: u32 = app_ver_string[dot + 1..].parse().ok()?;
let raw_version = &app_ver_string[dot + 1..];
if raw_version.is_empty() || !raw_version.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let version: u32 = raw_version.parse().ok()?;
if version == 0 || version.to_string() != raw_version {
return None;
}
Some((app_id, version))
}
@ -151,7 +272,12 @@ pub fn map_from_value(value: &rmpv::Value) -> Option<HashMap<String, rmpv::Value
rmpv::Value::String(s) => s.as_str()?.to_string(),
_ => return None,
};
map.insert(key, v.clone());
if map.insert(key, v.clone()).is_some() {
// Duplicate msgpack map keys are ambiguous and would be
// silently collapsed by HashMap/dict decoders. Reject
// them instead of allowing sender/receiver disagreement.
return None;
}
}
Some(map)
}
@ -159,8 +285,18 @@ pub fn map_from_value(value: &rmpv::Value) -> Option<HashMap<String, rmpv::Value
}
}
/// Return true when a payload contains exactly the expected keys.
pub(crate) fn has_exact_keys(payload: &HashMap<String, rmpv::Value>, expected: &[&str]) -> bool {
payload.len() == expected.len() && payload.keys().all(|key| expected.contains(&key.as_str()))
}
/// Serialize an Envelope to msgpack bytes using rmpv.
pub fn pack_to_bytes(envelope: &Envelope) -> Result<Vec<u8>, LrgpError> {
validate_envelope(envelope)?;
encode_envelope_map(envelope)
}
fn encode_envelope_map(envelope: &Envelope) -> Result<Vec<u8>, LrgpError> {
let value = value_from_map(envelope.clone());
let mut buf = Vec::new();
rmpv::encode::write_value(&mut buf, &value)
@ -170,9 +306,20 @@ pub fn pack_to_bytes(envelope: &Envelope) -> Result<Vec<u8>, LrgpError> {
/// Deserialize msgpack bytes into an Envelope.
pub fn unpack_from_bytes(data: &[u8]) -> Result<Envelope, LrgpError> {
let envelope = decode_envelope_map(data)?;
validate_envelope(&envelope)?;
Ok(envelope)
}
fn decode_envelope_map(data: &[u8]) -> Result<Envelope, LrgpError> {
let mut cursor = std::io::Cursor::new(data);
let value = rmpv::decode::read_value(&mut cursor)
.map_err(|e| LrgpError::InvalidEnvelope(format!("msgpack decode error: {e}")))?;
if cursor.position() != data.len() as u64 {
return Err(LrgpError::InvalidEnvelope(
"trailing bytes after msgpack envelope".into(),
));
}
map_from_value(&value)
.ok_or_else(|| LrgpError::InvalidEnvelope("top-level value is not a map".into()))
}
@ -219,7 +366,7 @@ mod tests {
payload.insert("i".to_string(), rmpv::Value::Integer(4.into()));
payload.insert("b".to_string(), rmpv::Value::String("____X____".into()));
let env = pack_envelope("ttt", 1, "move", "a1b2c3d4e5f6g7h8", Some(payload), None);
let env = pack_envelope("ttt", 1, "move", "a1b2c3d4e5f60718", Some(payload), None).unwrap();
let bytes = pack_to_bytes(&env).unwrap();
let recovered = unpack_from_bytes(&bytes).unwrap();
@ -234,13 +381,13 @@ mod tests {
);
assert_eq!(
value_as_str(recovered.get(KEY_SESSION).unwrap()).unwrap(),
"a1b2c3d4e5f6g7h8"
"a1b2c3d4e5f60718"
);
}
#[test]
fn test_validate_envelope_size_ok() {
let env = pack_envelope("ttt", 1, "challenge", "a1b2c3d4e5f6g7h8", None, None);
let env = pack_envelope("ttt", 1, "challenge", "a1b2c3d4e5f60718", None, None).unwrap();
let size = validate_envelope_size(&env).unwrap();
assert!(size <= ENVELOPE_MAX_PACKED);
}
@ -252,9 +399,8 @@ mod tests {
let big_string = "x".repeat(300);
payload.insert("data".to_string(), rmpv::Value::String(big_string.into()));
let env = pack_envelope("ttt", 1, "move", "a1b2c3d4e5f6g7h8", Some(payload), None);
assert!(matches!(
validate_envelope_size(&env),
pack_envelope("ttt", 1, "move", "a1b2c3d4e5f60718", Some(payload), None),
Err(LrgpError::EnvelopeTooLarge(_, _))
));
}
@ -268,6 +414,10 @@ mod tests {
let (app, ver) = parse_app_version("chess.game.2").unwrap();
assert_eq!(app, "chess.game");
assert_eq!(ver, 2);
for invalid in ["ttt.0", "ttt.01", "ttt.+1", "ttt.-1", "ttt."] {
assert!(parse_app_version(invalid).is_none(), "{invalid}");
}
}
#[test]
@ -278,8 +428,8 @@ mod tests {
#[test]
fn test_unpack_envelope_valid() {
let env = pack_envelope("ttt", 1, "challenge", "abc123", None, None);
let lxmf_fields = pack_lxmf_fields(&env);
let env = pack_envelope("ttt", 1, "challenge", "0123456789abcdef", None, None).unwrap();
let lxmf_fields = pack_lxmf_fields(&env).unwrap();
let result = unpack_envelope(&lxmf_fields).unwrap().unwrap();
assert_eq!(
value_as_str(result.get(KEY_COMMAND).unwrap()).unwrap(),
@ -300,6 +450,118 @@ mod tests {
assert!(unpack_envelope(&lxmf).is_err());
}
#[test]
fn canonical_validation_rejects_non_hex_or_uppercase_session_ids() {
for invalid in [
"abc",
"a1b2c3d4e5f6g7h8",
"A1B2C3D4E5F60718",
"a1b2c3d4e5f607189",
] {
let mut envelope =
pack_envelope("ttt", 1, "move", "a1b2c3d4e5f60718", None, None).unwrap();
envelope.insert(KEY_SESSION.into(), rmpv::Value::String(invalid.into()));
assert!(matches!(
validate_envelope(&envelope),
Err(LrgpError::InvalidEnvelope(_))
));
}
}
#[test]
fn canonical_validation_rejects_non_map_payload() {
let mut envelope = pack_envelope("ttt", 1, "move", "a1b2c3d4e5f60718", None, None).unwrap();
envelope.insert(KEY_PAYLOAD.into(), rmpv::Value::Array(Vec::new()));
assert!(matches!(
validate_envelope(&envelope),
Err(LrgpError::InvalidEnvelope(_))
));
}
#[test]
fn canonical_validation_rejects_missing_or_malformed_nonce() {
let mut missing = pack_envelope("ttt", 1, "move", "a1b2c3d4e5f60718", None, None).unwrap();
missing.remove(KEY_NONCE);
assert!(validate_envelope(&missing).is_err());
let mut malformed =
pack_envelope("ttt", 1, "move", "a1b2c3d4e5f60718", None, None).unwrap();
malformed.insert(KEY_NONCE.into(), rmpv::Value::Binary(vec![0; 7]));
assert!(validate_envelope(&malformed).is_err());
}
#[test]
fn canonical_validation_enforces_size_limit() {
let mut payload = HashMap::new();
payload.insert("data".into(), rmpv::Value::String("x".repeat(300).into()));
let envelope = pack_envelope("ttt", 1, "move", "a1b2c3d4e5f60718", Some(payload), None);
assert!(matches!(
envelope,
Err(LrgpError::EnvelopeTooLarge(_, ENVELOPE_MAX_PACKED))
));
}
#[test]
fn canonical_validation_requires_exact_keys_and_lexical_forms() {
let valid = pack_envelope(
"chess.game",
2,
"draw_offer",
"a1b2c3d4e5f60718",
None,
None,
)
.unwrap();
let mut extra = valid.clone();
extra.insert("x".into(), rmpv::Value::Nil);
assert!(validate_envelope(&extra).is_err());
for app_version in ["Ttt.1", "1ttt.1", "ttt!.1", "ttt.01", "ttt.0"] {
let mut envelope = valid.clone();
envelope.insert(KEY_APP.into(), rmpv::Value::String(app_version.into()));
assert!(validate_envelope(&envelope).is_err(), "{app_version}");
}
for command in ["Move", "1move", "draw-offer", ""] {
let mut envelope = valid.clone();
envelope.insert(KEY_COMMAND.into(), rmpv::Value::String(command.into()));
assert!(validate_envelope(&envelope).is_err(), "{command}");
}
}
#[test]
fn canonical_byte_unpack_rejects_trailing_data() {
let envelope = pack_envelope("ttt", 1, CMD_MOVE, "a1b2c3d4e5f60718", None, None).unwrap();
let mut bytes = pack_to_bytes(&envelope).unwrap();
bytes.push(0xc0);
assert!(matches!(
unpack_from_bytes(&bytes),
Err(LrgpError::InvalidEnvelope(message))
if message.contains("trailing bytes")
));
}
#[test]
fn canonical_unpack_rejects_duplicate_map_keys() {
let envelope =
pack_envelope("ttt", 1, CMD_CHALLENGE, "0123456789abcdef", None, None).unwrap();
let mut pairs = match value_from_map(envelope) {
rmpv::Value::Map(pairs) => pairs,
_ => unreachable!(),
};
pairs.push((
rmpv::Value::String(KEY_APP.into()),
rmpv::Value::String("chess.1".into()),
));
let mut bytes = Vec::new();
rmpv::encode::write_value(&mut bytes, &rmpv::Value::Map(pairs)).unwrap();
assert!(matches!(
unpack_from_bytes(&bytes),
Err(LrgpError::InvalidEnvelope(_))
));
}
#[test]
fn test_vector_challenge() {
let data = include_bytes!("../tests/ttt_challenge.bin");
@ -308,7 +570,7 @@ mod tests {
assert_eq!(value_as_str(env.get("c").unwrap()).unwrap(), "challenge");
assert_eq!(
value_as_str(env.get("s").unwrap()).unwrap(),
"a1b2c3d4e5f6g7h8"
"a1b2c3d4e5f60718"
);
}
@ -318,6 +580,7 @@ mod tests {
let env = unpack_from_bytes(data).unwrap();
assert_eq!(value_as_str(env.get("c").unwrap()).unwrap(), "move");
let payload = map_from_value(env.get("p").unwrap()).unwrap();
assert!(has_exact_keys(&payload, &["i", "b", "n", "t", "x"]));
assert_eq!(value_as_u64(payload.get("i").unwrap()).unwrap(), 4);
assert_eq!(
value_as_str(payload.get("b").unwrap()).unwrap(),
@ -332,6 +595,7 @@ mod tests {
let env = unpack_from_bytes(data).unwrap();
assert_eq!(value_as_str(env.get("c").unwrap()).unwrap(), "move");
let payload = map_from_value(env.get("p").unwrap()).unwrap();
assert!(has_exact_keys(&payload, &["i", "b", "n", "t", "x", "w"]));
assert_eq!(value_as_u64(payload.get("i").unwrap()).unwrap(), 2);
assert_eq!(
value_as_str(payload.get("b").unwrap()).unwrap(),

View file

@ -13,6 +13,43 @@ pub enum LrgpError {
#[error("unknown game: {0}")]
UnknownApp(String),
#[error("unsupported {app_id} protocol version {received}; supported version is {supported}")]
UnsupportedVersion {
app_id: String,
received: u32,
supported: u32,
},
#[error("unsupported action '{command}' for game '{app_id}'")]
UnsupportedAction { app_id: String, command: String },
#[error("peer is not authorized for session {session_id}")]
UnauthorizedPeer { session_id: String },
#[error("session expired: {0}")]
SessionExpired(String),
#[error("session not found: {0}")]
SessionNotFound(String),
#[error("session already exists: {0}")]
SessionExists(String),
#[error("a remote peer is required when creating a challenge")]
ParticipantRequired,
#[error("incoming dispatch requires a non-empty transport-authenticated sender")]
AuthenticatedSenderRequired,
#[error("incoming dispatch requires a non-empty receiving local identity")]
ReceivingIdentityRequired,
#[error("outgoing dispatch requires a non-empty local identity")]
OutgoingIdentityRequired,
#[error("incoming challenge admission limit reached ({scope}: {limit})")]
AdmissionLimit { scope: &'static str, limit: usize },
#[error("validation error [{code}]: {message}")]
Validation { code: String, message: String },

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,47 @@ impl Session {
last_action_at: now,
}
}
/// Record the participant who currently owns the outstanding draw offer.
/// The boolean is retained for existing UI/storage consumers, while the
/// owner prevents a participant from accepting or declining its own offer.
pub fn set_draw_offer(&mut self, offered_by: &str) {
self.metadata
.insert("draw_offered".into(), serde_json::Value::Bool(true));
self.metadata.insert(
"draw_offered_by".into(),
serde_json::Value::String(offered_by.into()),
);
}
/// Clear both halves of the draw-offer state atomically.
pub fn clear_draw_offer(&mut self) {
self.metadata
.insert("draw_offered".into(), serde_json::Value::Bool(false));
self.metadata.insert(
"draw_offered_by".into(),
serde_json::Value::String(String::new()),
);
}
/// Return the outstanding offer's owner only when the metadata is
/// complete and internally consistent.
pub fn has_draw_offer(&self) -> bool {
self.metadata
.get("draw_offered")
.and_then(|value| value.as_bool())
== Some(true)
}
pub fn draw_offered_by(&self) -> Option<&str> {
if !self.has_draw_offer() {
return None;
}
self.metadata
.get("draw_offered_by")
.and_then(|value| value.as_str())
.filter(|owner| !owner.is_empty())
}
}
/// Enforces legal game session state transitions.
@ -220,6 +261,21 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn draw_offer_owner_is_set_and_cleared_with_flag() {
let mut session = make_session(STATUS_ACTIVE);
assert!(!session.has_draw_offer());
assert_eq!(session.draw_offered_by(), None);
session.set_draw_offer("alice");
assert!(session.has_draw_offer());
assert_eq!(session.draw_offered_by(), Some("alice"));
session.clear_draw_offer();
assert!(!session.has_draw_offer());
assert_eq!(session.draw_offered_by(), None);
}
#[test]
fn test_check_expiry_pending() {
let mut s = make_session(STATUS_PENDING);

View file

@ -15,8 +15,6 @@ const ALLOWED_COLUMNS: &[&str] = &[
"unread",
"updated_at",
"last_action_at",
"contact_hash",
"initiator",
];
/// A stored game action.
@ -105,7 +103,9 @@ impl LrgpStore {
// ──── Sessions ────
/// Save a new session.
/// Save a new session. An existing `(session_id, identity_id)` is an error;
/// callers must use the allowlisted update path for mutable state. This
/// prevents a retry from silently rebinding the app or remote participant.
#[allow(clippy::too_many_arguments)] // SQL row constructor — every column is load-bearing.
pub fn save_session(
&self,
@ -127,7 +127,7 @@ impl LrgpStore {
.map_err(|e| LrgpError::Store(format!("metadata serialization error: {e}")))?;
conn.execute(
"INSERT OR REPLACE INTO game_sessions
"INSERT INTO game_sessions
(session_id, identity_id, app_id, app_version, contact_hash, initiator,
status, metadata, unread, created_at, updated_at, last_action_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
@ -151,7 +151,8 @@ impl LrgpStore {
Ok(())
}
/// Update specific columns of a session (allowlist-validated).
/// Update mutable columns of a session (allowlist-validated). Participant
/// and initiator bindings are intentionally immutable after insertion.
pub fn update_session(
&self,
session_id: &str,
@ -280,29 +281,38 @@ impl LrgpStore {
/// Delete a session and its actions.
pub fn delete_session(&self, session_id: &str, identity_id: &str) -> Result<(), LrgpError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM game_actions WHERE session_id = ?1 AND identity_id = ?2",
rusqlite::params![session_id, identity_id],
)
.map_err(|e| LrgpError::Store(format!("delete actions error: {e}")))?;
let mut conn = self.conn.lock().unwrap();
let transaction = conn
.transaction()
.map_err(|e| LrgpError::Store(format!("delete transaction error: {e}")))?;
transaction
.execute(
"DELETE FROM game_actions WHERE session_id = ?1 AND identity_id = ?2",
rusqlite::params![session_id, identity_id],
)
.map_err(|e| LrgpError::Store(format!("delete actions error: {e}")))?;
conn.execute(
"DELETE FROM game_sessions WHERE session_id = ?1 AND identity_id = ?2",
rusqlite::params![session_id, identity_id],
)
.map_err(|e| LrgpError::Store(format!("delete session error: {e}")))?;
transaction
.execute(
"DELETE FROM game_sessions WHERE session_id = ?1 AND identity_id = ?2",
rusqlite::params![session_id, identity_id],
)
.map_err(|e| LrgpError::Store(format!("delete session error: {e}")))?;
transaction
.commit()
.map_err(|e| LrgpError::Store(format!("delete commit error: {e}")))?;
Ok(())
}
// ──── Actions ────
/// Save a game action.
/// Save a game action. Duplicate action numbers are rejected so immutable
/// history cannot be silently rewritten.
pub fn save_action(&self, action: &Action) -> Result<(), LrgpError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO game_actions
"INSERT INTO game_actions
(session_id, identity_id, action_num, command, payload_json, sender, timestamp)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
@ -438,6 +448,52 @@ mod tests {
);
}
#[test]
fn duplicate_session_insert_cannot_rebind_or_replace_state() {
let store = test_store();
store
.save_session(
"s1",
"id1",
"ttt",
1,
"trusted-peer",
"id1",
"pending",
&HashMap::new(),
0,
1.0,
1.0,
1.0,
)
.unwrap();
assert!(
store
.save_session(
"s1",
"id1",
"chess",
1,
"attacker",
"attacker",
"completed",
&HashMap::new(),
1,
2.0,
2.0,
2.0,
)
.is_err()
);
let original = store.get_session("s1", "id1").unwrap().unwrap();
assert_eq!(original.app_id, "ttt");
assert_eq!(original.contact_hash, "trusted-peer");
assert_eq!(original.initiator, "id1");
assert_eq!(original.status, "pending");
}
#[test]
fn test_update_session() {
let store = test_store();
@ -491,6 +547,12 @@ mod tests {
updates.insert("evil_column; DROP TABLE--".into(), "hack".into());
let result = store.update_session("s1", "id1", &updates);
assert!(result.is_err());
for immutable in ["contact_hash", "initiator"] {
let mut updates = HashMap::new();
updates.insert(immutable.into(), "attacker".into());
assert!(store.update_session("s1", "id1", &updates).is_err());
}
}
#[test]
@ -570,6 +632,35 @@ mod tests {
assert_eq!(actions[2].action_num, 3);
}
#[test]
fn duplicate_action_number_cannot_rewrite_history() {
let store = test_store();
let original = Action {
session_id: "s1".into(),
identity_id: "id1".into(),
action_num: 1,
command: "move".into(),
payload_json: "{\"i\":1}".into(),
sender: "trusted-peer".into(),
timestamp: 1.0,
};
store.save_action(&original).unwrap();
let replacement = Action {
payload_json: "{\"i\":8}".into(),
sender: "attacker".into(),
timestamp: 2.0,
..original
};
assert!(store.save_action(&replacement).is_err());
let actions = store.list_actions("s1", "id1").unwrap();
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].payload_json, "{\"i\":1}");
assert_eq!(actions[0].sender, "trusted-peer");
assert_eq!(actions[0].timestamp, 1.0);
}
#[test]
fn test_next_action_num() {
let store = test_store();

View file

@ -3,6 +3,15 @@
//! This module handles the raw byte-level conversion needed to embed LRGP
//! game envelopes inside LXMF messages and extract them on receipt.
//! It is pure data transformation — no I/O.
//!
//! # Important: native MessagePack fields
//!
//! The returned byte values each encode **one complete native MessagePack
//! value**. With `lxmf-core::LxMessage`, install them using
//! `set_msgpack_field`, never `set_field`. `set_field` represents its input as
//! a MessagePack binary value, which would put `bin("lrgp.v1")` and
//! `bin(<encoded map>)` on the wire instead of the LRGP string and map. Python
//! LRGP implementations correctly reject/ignore those binary wrappers.
use std::collections::HashMap;
@ -10,17 +19,25 @@ use crate::constants::*;
use crate::envelope::{self, Envelope};
use crate::errors::LrgpError;
fn decode_one(field: &str, data: &[u8]) -> Result<rmpv::Value, LrgpError> {
let mut cursor = std::io::Cursor::new(data);
let value = rmpv::decode::read_value(&mut cursor)
.map_err(|e| LrgpError::InvalidEnvelope(format!("{field} decode error: {e}")))?;
if cursor.position() != data.len() as u64 {
return Err(LrgpError::InvalidEnvelope(format!(
"{field} contains trailing bytes"
)));
}
Ok(value)
}
/// Check whether an LXMF fields dict contains an LRGP game message.
pub fn is_lrgp_message(fields: &HashMap<u8, Vec<u8>>) -> bool {
match fields.get(&FIELD_CUSTOM_TYPE) {
Some(data) => {
if let Ok(val) = rmpv::decode::read_value(&mut &data[..]) {
if let Some(s) = envelope::value_as_str(&val) {
return s == PROTOCOL_TYPE;
}
}
false
}
Some(data) => decode_one("type field", data)
.ok()
.and_then(|value| envelope::value_as_str(&value).map(str::to_owned))
.is_some_and(|marker| marker == PROTOCOL_TYPE),
None => false,
}
}
@ -38,8 +55,7 @@ pub fn extract_envelope(fields: &HashMap<u8, Vec<u8>>) -> Result<Option<Envelope
Some(d) => d,
None => return Ok(None),
};
let type_val = rmpv::decode::read_value(&mut &type_data[..])
.map_err(|e| LrgpError::InvalidEnvelope(format!("type field decode error: {e}")))?;
let type_val = decode_one("type field", type_data)?;
let marker = envelope::value_as_str(&type_val).unwrap_or("");
if marker != PROTOCOL_TYPE {
return Ok(None);
@ -50,59 +66,61 @@ pub fn extract_envelope(fields: &HashMap<u8, Vec<u8>>) -> Result<Option<Envelope
.get(&FIELD_CUSTOM_META)
.ok_or_else(|| LrgpError::InvalidEnvelope("FIELD_CUSTOM_META (0xFD) missing".into()))?;
let meta_val = rmpv::decode::read_value(&mut &meta_data[..])
.map_err(|e| LrgpError::InvalidEnvelope(format!("meta field decode error: {e}")))?;
let meta_val = decode_one("meta field", meta_data)?;
// 3. Convert to HashMap envelope
let env = envelope::map_from_value(&meta_val)
.ok_or_else(|| LrgpError::InvalidEnvelope("meta field is not a map".into()))?;
for key in &[KEY_APP, KEY_COMMAND, KEY_SESSION, KEY_PAYLOAD, KEY_NONCE] {
if !env.contains_key(*key) {
return Err(LrgpError::InvalidEnvelope(format!(
"Missing required key: {key}"
)));
}
}
envelope::validate_envelope(&env)?;
Ok(Some(env))
}
/// Pack an LRGP envelope into raw LXMF field bytes.
/// Pack an LRGP envelope into pre-encoded native LXMF field values.
///
/// Returns `HashMap<u8, Vec<u8>>` ready to pass to lxmf message construction:
/// Returns `HashMap<u8, Vec<u8>>` containing:
/// - `0xFB` → msgpack("lrgp.v1")
/// - `0xFD` → msgpack(envelope dict)
///
/// Each byte vector MUST be installed as a pre-encoded native MessagePack
/// field (for `lxmf-core`, call `LxMessage::set_msgpack_field`). Passing these
/// bytes to `LxMessage::set_field` creates non-interoperable binary wrappers.
///
/// Always uses the current protocol marker (`lrgp.v1`) for outbound messages.
pub fn pack_into_fields(envelope: &Envelope) -> Result<HashMap<u8, Vec<u8>>, LrgpError> {
pub fn pack_into_preencoded_fields(envelope: &Envelope) -> Result<HashMap<u8, Vec<u8>>, LrgpError> {
let mut fields = HashMap::new();
// Type marker → always lrgp.v1
let type_val = rmpv::Value::String(PROTOCOL_TYPE.into());
let mut type_buf = Vec::new();
rmpv::encode::write_value(&mut type_buf, &type_val)
.map_err(|e| LrgpError::InvalidEnvelope(format!("type encode error: {e}")))?;
fields.insert(FIELD_CUSTOM_TYPE, type_buf);
// Envelope dict
let env_val = envelope::value_from_map(envelope.clone());
let mut env_buf = Vec::new();
rmpv::encode::write_value(&mut env_buf, &env_val)
.map_err(|e| LrgpError::InvalidEnvelope(format!("envelope encode error: {e}")))?;
fields.insert(FIELD_CUSTOM_META, env_buf);
for (field_id, value) in envelope::pack_lxmf_fields(envelope)? {
let mut encoded = Vec::new();
rmpv::encode::write_value(&mut encoded, &value).map_err(|error| {
LrgpError::InvalidEnvelope(format!("field {field_id:#x} encode error: {error}"))
})?;
fields.insert(field_id, encoded);
}
Ok(fields)
}
/// Deprecated ambiguous name for [`pack_into_preencoded_fields`].
///
/// The output is not arbitrary field data: each value is already encoded as
/// one native MessagePack object and must be passed to a MessagePack-aware
/// field setter such as `LxMessage::set_msgpack_field`.
#[deprecated(
since = "0.4.0",
note = "use pack_into_preencoded_fields and LxMessage::set_msgpack_field"
)]
pub fn pack_into_fields(envelope: &Envelope) -> Result<HashMap<u8, Vec<u8>>, LrgpError> {
pack_into_preencoded_fields(envelope)
}
/// Convert raw LXMF field bytes into typed rmpv values (for use with envelope::unpack_envelope).
pub fn fields_bytes_to_rmpv(
fields: &HashMap<u8, Vec<u8>>,
) -> Result<HashMap<u8, rmpv::Value>, LrgpError> {
let mut result = HashMap::new();
for (&key, data) in fields {
let val = rmpv::decode::read_value(&mut &data[..])
.map_err(|e| LrgpError::InvalidEnvelope(format!("field {key:#x} decode error: {e}")))?;
let val = decode_one(&format!("field {key:#x}"), data)?;
result.insert(key, val);
}
Ok(result)
@ -114,8 +132,9 @@ mod tests {
#[test]
fn test_pack_and_extract_roundtrip() {
let env = envelope::pack_envelope("ttt", 1, "challenge", "abcdef0123456789", None, None);
let raw_fields = pack_into_fields(&env).unwrap();
let env =
envelope::pack_envelope("ttt", 1, "challenge", "abcdef0123456789", None, None).unwrap();
let raw_fields = pack_into_preencoded_fields(&env).unwrap();
let recovered = extract_envelope(&raw_fields).unwrap().unwrap();
assert_eq!(
@ -130,8 +149,9 @@ mod tests {
#[test]
fn test_is_lrgp_message_true() {
let env = envelope::pack_envelope("ttt", 1, "move", "abc", None, None);
let raw_fields = pack_into_fields(&env).unwrap();
let env =
envelope::pack_envelope("ttt", 1, "move", "abcdef0123456789", None, None).unwrap();
let raw_fields = pack_into_preencoded_fields(&env).unwrap();
assert!(is_lrgp_message(&raw_fields));
}
@ -149,10 +169,77 @@ mod tests {
#[test]
fn test_fields_bytes_to_rmpv() {
let env = envelope::pack_envelope("ttt", 1, "move", "abc", None, None);
let raw = pack_into_fields(&env).unwrap();
let env =
envelope::pack_envelope("ttt", 1, "move", "abcdef0123456789", None, None).unwrap();
let raw = pack_into_preencoded_fields(&env).unwrap();
let rmpv_fields = fields_bytes_to_rmpv(&raw).unwrap();
assert!(rmpv_fields.contains_key(&FIELD_CUSTOM_TYPE));
assert!(rmpv_fields.contains_key(&FIELD_CUSTOM_META));
}
#[test]
fn native_field_reencoding_is_python_interoperable() {
let env = envelope::pack_envelope(
"ttt",
1,
"move",
"abcdef0123456789",
Some(HashMap::from([("i".into(), rmpv::Value::from(4))])),
None,
)
.unwrap();
let typed = envelope::pack_lxmf_fields(&env).unwrap();
let mut native_wire_values = HashMap::new();
for (field_id, value) in typed {
let mut encoded = Vec::new();
rmpv::encode::write_value(&mut encoded, &value).unwrap();
native_wire_values.insert(field_id, encoded);
}
assert_eq!(extract_envelope(&native_wire_values).unwrap(), Some(env));
}
#[test]
fn binary_wrapped_fields_are_not_lrgp() {
let env =
envelope::pack_envelope("ttt", 1, "move", "abcdef0123456789", None, None).unwrap();
let native = pack_into_preencoded_fields(&env).unwrap();
let wrapped = native
.into_iter()
.map(|(field_id, encoded_native)| {
let mut encoded_binary = Vec::new();
rmpv::encode::write_value(
&mut encoded_binary,
&rmpv::Value::Binary(encoded_native),
)
.unwrap();
(field_id, encoded_binary)
})
.collect();
assert!(!is_lrgp_message(&wrapped));
assert!(extract_envelope(&wrapped).unwrap().is_none());
}
#[test]
fn raw_field_decoders_reject_trailing_bytes() {
let env =
envelope::pack_envelope("ttt", 1, "move", "abcdef0123456789", None, None).unwrap();
let mut trailing_type = pack_into_preencoded_fields(&env).unwrap();
trailing_type
.get_mut(&FIELD_CUSTOM_TYPE)
.unwrap()
.push(0xc0);
assert!(!is_lrgp_message(&trailing_type));
assert!(extract_envelope(&trailing_type).is_err());
assert!(fields_bytes_to_rmpv(&trailing_type).is_err());
let mut trailing_meta = pack_into_preencoded_fields(&env).unwrap();
trailing_meta
.get_mut(&FIELD_CUSTOM_META)
.unwrap()
.push(0xc0);
assert!(extract_envelope(&trailing_meta).is_err());
assert!(fields_bytes_to_rmpv(&trailing_meta).is_err());
}
}

View file

@ -1 +1 @@
…¡a§chess.1¡cªdraw_offer¡s°f1e2d3c4b5a69788¡p<EFBFBD>¡r ¡nÄÄåUÞ­¾ï
…¡a§chess.1¡cªdraw_offer¡s°f1e2d3c4b5a69788¡p¡nÄÄåUÞ­¾ï

Binary file not shown.

View file

@ -1 +1 @@
…¡a§chess.1¡c¤move¡s°f1e2d3c4b5a69788¡p…¡m¥e7e8q¡n¡x ¡r ¡w ¡nÄÄåUÞ­¾ï
…¡a§chess.1¡c¤move¡s°f1e2d3c4b5a69788¡pƒ¡m¥e7e8q¡n¡x ¡nÄÄåUÞ­¾ï

View file

@ -12,6 +12,11 @@ fn decode(bytes: &[u8]) -> Envelope {
unpack_from_bytes(bytes).expect("decode succeeds")
}
fn assert_exact_keys(payload: &Envelope, expected: &[&str]) {
assert_eq!(payload.len(), expected.len());
assert!(payload.keys().all(|key| expected.contains(&key.as_str())));
}
#[test]
fn vector_chess_challenge() {
let data = include_bytes!("chess_challenge.bin");
@ -27,7 +32,7 @@ fn vector_chess_accept() {
assert_eq!(value_as_str(env.get("c").unwrap()).unwrap(), "accept");
let payload = map_from_value(env.get("p").unwrap()).unwrap();
// ACCEPT carries the White-player hash under "w".
assert!(payload.contains_key("w"));
assert_exact_keys(&payload, &["w"]);
}
#[test]
@ -36,6 +41,7 @@ fn vector_chess_move() {
let env = decode(data);
assert_eq!(value_as_str(env.get("c").unwrap()).unwrap(), "move");
let payload = map_from_value(env.get("p").unwrap()).unwrap();
assert_exact_keys(&payload, &["m", "n", "x"]);
assert_eq!(value_as_str(payload.get("m").unwrap()).unwrap(), "e2e4");
assert_eq!(value_as_u64(payload.get("n").unwrap()).unwrap(), 0);
}
@ -46,6 +52,7 @@ fn vector_chess_move_promotion() {
let env = decode(data);
assert_eq!(value_as_str(env.get("c").unwrap()).unwrap(), "move");
let payload = map_from_value(env.get("p").unwrap()).unwrap();
assert_exact_keys(&payload, &["m", "n", "x"]);
// UCI promotion notation: e7e8q means pawn-to-e8 promoting to queen.
assert_eq!(value_as_str(payload.get("m").unwrap()).unwrap(), "e7e8q");
}
@ -55,6 +62,7 @@ fn vector_chess_move_checkmate() {
let data = include_bytes!("chess_move_checkmate.bin");
let env = decode(data);
let payload = map_from_value(env.get("p").unwrap()).unwrap();
assert_exact_keys(&payload, &["m", "n", "x", "r", "w"]);
// Scholar's Mate Qxf7# — terminal=win, reason=cm (checkmate).
assert_eq!(value_as_str(payload.get("m").unwrap()).unwrap(), "h5f7");
assert_eq!(value_as_str(payload.get("x").unwrap()).unwrap(), "win");
@ -67,6 +75,8 @@ fn vector_chess_resign() {
let data = include_bytes!("chess_resign.bin");
let env = decode(data);
assert_eq!(value_as_str(env.get("c").unwrap()).unwrap(), "resign");
let payload = map_from_value(env.get("p").unwrap()).unwrap();
assert!(payload.is_empty());
}
#[test]
@ -74,4 +84,6 @@ fn vector_chess_draw_offer() {
let data = include_bytes!("chess_draw_offer.bin");
let env = decode(data);
assert_eq!(value_as_str(env.get("c").unwrap()).unwrap(), "draw_offer");
let payload = map_from_value(env.get("p").unwrap()).unwrap();
assert!(payload.is_empty());
}

View file

@ -1 +1 @@
…¡a¥ttt.1¡c©challenge¡s°a1b2c3d4e5f6g7h8¡p€¡nÄÞ­¾ïÀÿî
…¡a¥ttt.1¡c©challenge¡s°a1b2c3d4e5f60718¡p€¡nÄÞ­¾ïÀÿî

View file

@ -1 +1 @@
…¡a¥ttt.1¡c¤move¡s°a1b2c3d4e5f6g7h8¡p†¡i¡b©____X____¡n¡t°abcdef0123456789¡x ¡w ¡Þ­¾ïÀÿî
…¡a¥ttt.1¡c¤move¡s°a1b2c3d4e5f60718¡p…¡i¡b©____X____¡n¡t°abcdef0123456789¡x ¡nÄÞ­¾ïÀÿî

View file

@ -1 +1 @@
…¡a¥ttt.1¡c¤move¡s°a1b2c3d4e5f6g7h8¡p†¡i¡b©XXX_OO___¡n¡t°abcdef0123456789¡x£win¡w°abcdef0123456789¡nÄÞ­¾ïÀÿî
…¡a¥ttt.1¡c¤move¡s°a1b2c3d4e5f60718¡p†¡i¡b©XXX_OO___¡n¡t°abcdef0123456789¡x£win¡w°abcdef0123456789¡nÄÞ­¾ïÀÿî