mirror of
https://github.com/ratspeak/lrgp-rs
synced 2026-08-12 18:07:21 -04:00
lrgp: harden protocol and align 0.4.0
This commit is contained in:
parent
0b55361ebf
commit
0d2598109c
25 changed files with 3959 additions and 345 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "lrgp"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
rust-version = "1.85"
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -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
236
SPEC.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("?"));
|
||||
|
|
|
|||
108
src/app_base.rs
108
src/app_base.rs
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
259
src/dedup.rs
259
src/dedup.rs
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
352
src/envelope.rs
352
src/envelope.rs
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
||||
|
|
|
|||
1586
src/router.rs
1586
src/router.rs
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
127
src/store.rs
127
src/store.rs
|
|
@ -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();
|
||||
|
|
|
|||
175
src/transport.rs
175
src/transport.rs
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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Þ¾ï
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
…¡a¥ttt.1¡c©challenge¡s°a1b2c3d4e5f6g7h8¡p€¡nÄÞ¾ïÀÿî
|
||||
…¡a¥ttt.1¡c©challenge¡s°a1b2c3d4e5f60718¡p€¡nÄÞ¾ïÀÿî
|
||||
|
|
@ -1 +1 @@
|
|||
…¡a¥ttt.1¡c¤move¡s°a1b2c3d4e5f6g7h8¡p†¡i¡b©____X____¡n¡t°abcdef0123456789¡x ¡w ¡nÄÞ¾ïÀÿî
|
||||
…¡a¥ttt.1¡c¤move¡s°a1b2c3d4e5f60718¡p…¡i¡b©____X____¡n¡t°abcdef0123456789¡x ¡nÄÞ¾ïÀÿî
|
||||
|
|
@ -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ÄÞ¾ïÀÿî
|
||||
Loading…
Add table
Add a link
Reference in a new issue