API Misuse Analysis & Type-System Defenses
This document systematically analyzes every public API boundary in Iron Curtain, identifies how each can be misused (by callers, mods, network peers, or contributors making honest mistakes), and maps each misuse vector to a concrete type-system defense. It complements type-safety.md, which defines the policies — this document applies them to every API surface.
Methodology: For each API boundary, we identify: (1) what the API exposes, (2) every way a caller can misuse it, (3) which Rust type-system mechanisms prevent or detect the misuse, and (4) what automated tests verify the defense. Misuse vectors are tagged by origin: caller (honest code error), mod (sandboxed external code), network (untrusted peer), adversary (deliberate attack).
1. Simulation API (ic-sim::Simulation)
Surface: Simulation::new(), apply_tick(), snapshot(), restore(), delta_snapshot(), apply_delta(), state_hash(), apply_correction(), query_state().
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| S1 | Call apply_tick() with orders from a future tick (tick N+2 when sim is at N) | caller | TickOrders carries a tick: SimTick field. apply_tick() returns Result<(), SimError> — debug_assert! panics in debug builds; returns Err(SimError::TickMismatch { expected, got }) in release. SimTick newtype prevents confusion with raw u64. See 02-ARCHITECTURE.md § Simulation API for the canonical signature. | T1: unit test — future-tick orders panic in debug, return Err in release |
| S2 | Call apply_tick() with duplicate orders (same order replayed twice in one tick) | caller/network | TickOrders::chronological() does not deduplicate — the relay is responsible for dedup. In-sim, order validation rejects duplicate actions (e.g., two build commands on the same cell). ValidatedOrder wrapper (post-validation) is consumed once. | T2: replay with duplicate injected orders → second copy rejected |
| S3 | Call restore() with a snapshot from a different game (wrong seed, wrong map) | caller | SimCoreSnapshot includes game_seed: u64 and map_hash: StateHash. restore() returns Result<(), SimError> — checks both match the current Simulation config. Mismatch returns Err(SimError::ConfigMismatch). See 02-ARCHITECTURE.md § Simulation API. | T2: cross-game snapshot restore → Err, sim state_hash() unchanged |
| S4 | Load a truncated or corrupted save/snapshot file | network/adversary | SaveHeader.payload_hash stores a SHA-256 (StateHash) over the compressed payload bytes (Fossilize pattern). The file-loading layer (in ic-game, not Simulation::restore()) reads the header first, SHA-256-hashes the compressed payload bytes, and verifies the hash matches before decompression or deserialization. Simulation::restore() itself is a pure sim-core operation that accepts an already-verified &SimCoreSnapshot (see 02-ARCHITECTURE.md § Simulation API). The full save-load path goes through GameRunner::restore_full() (see 02-ARCHITECTURE.md § ic-game Integration), which orchestrates file verification → decompression → deserialization → sim restore → campaign/script rehydration. Verified<SimSnapshot> is required by the reconnection codepath. The hash lives in the outer file envelope (header), not inside the serialized struct — so verification requires no parsing of untrusted data. | T3: fuzz snapshot bytes → parser never panics; corrupted hash → rejection |
| S5 | Call apply_correction() outside the reconciler context | caller | apply_correction() takes &ReconcilerToken — an unforgeable capability token with _private: (). Only the SimReconciler system can construct it. Calling without a token is a compile error. | T1: compile-time — no test needed; verify in code review |
| S6 | Pass f32/f64 values into sim state via snapshot deserialization | adversary | Three layers of defense: (1) clippy::disallowed_types bans f32/f64 in ic-sim — no float field can exist in any snapshot struct (compile-time). (2) The save payload hash (SaveHeader.payload_hash, see S4) prevents any bit-level tampering before deserialization begins. (3) Post-deserialization, FixedPoint values are range-validated against domain-specific bounds (e.g., map coordinate limits, stat ranges). Note: the snapshot format is binary (bincode) — there is no type-level parse-time distinction between f32 bits and i32 bits in a binary codec. The compile-time float ban is the primary defense; the payload hash prevents injection; range validation catches semantically invalid values. | T3: fuzz snapshot bytes → never panics; verify clippy::disallowed_types lint active in CI; post-deser range validation rejects out-of-bounds FixedPoint values |
| S7 | Inject orders for a player ID that doesn’t exist in the game | network | validate_order() checks PlayerId exists in sim.players. Unknown PlayerId triggers immediate rejection under OrderRejectionCategory::Ownership (D012). PlayerId newtype prevents confusion with other IDs. | T1: exhaustive rejection matrix includes unknown-player case |
| S8 | Call snapshot() and apply_tick() concurrently from different threads | caller | Simulation contains a Bevy World, which internally uses UnsafeCell and is !Sync. Since Simulation contains a !Sync field, Rust’s auto-trait rules make Simulation itself !Sync — a &Simulation cannot be shared across threads. All mutation methods additionally require &mut self, so concurrent mutable access is also prevented by the borrow checker. | Compile-time enforcement — no runtime test needed |
| S9 | Construct WorldPos with out-of-range coordinates | caller/mod | WorldPos fields are SimCoord (newtype over i32). Map bounds are checked at order validation time, not at coordinate construction — WorldPos is a value type. OrderValidator::validate() checks position against MapBounds. | T1: order with coordinates outside map bounds → OrderRejectionCategory::Placement (D012) |
| S10 | Call delta_snapshot() with a baseline from a different game branch (wrong tick sequence) | caller | SimCoreDelta includes baseline_tick: SimTick and baseline_hash: StateHash (full SHA-256, not SyncHash). apply_delta() returns Result<(), SimError> — verifies both match the current sim state before applying, returning Err(SimError::BaselineMismatch) on failure. Using StateHash (not SyncHash) ensures the replay seeking and autosave paths meet the same integrity bar as Verified<SimSnapshot> (see N3). See 02-ARCHITECTURE.md § Simulation API. | T2: delta from divergent branch → Err, sim state unchanged |
Cross-cutting defense: Simulation holds all state in a Bevy World. Systems access components via Query<> which enforces borrow rules at the ECS level — a system cannot write to a component another system is reading in the same phase.
2. Order Pipeline (ic-protocol)
Surface: PlayerOrder enum, TimestampedOrder, TickOrders, OrderCodec trait, OrderBudget.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| O1 | Send order referencing a UnitTag the player doesn’t own | network/adversary | validate_order() checks ownership. UnitTag includes generation — stale references to recycled slots are caught by generation mismatch. | T1: exhaustive rejection matrix — ownership column |
| O2 | Flood the relay with orders exceeding the budget | adversary | OrderBudget is a validated-construction type (private fields, _private: ()). try_spend() returns Err(BudgetExhausted) when tokens are depleted. Budget cannot be constructed in a broken state (refill=0 or cap=0 rejected). The relay calls try_spend() per order. | T2: 1000 orders/tick → excess dropped; budget recovers next tick |
| O3 | Craft a TimestampedOrder with sub_tick_time in the far future to gain unfair ordering advantage | adversary | Relay clamps sub_tick_time to the feasible envelope based on RTT measurement. SubTickTimestamp newtype prevents confusion with SimTick. Clamping is a relay-side operation, not trusting client values. | T2: timestamp envelope clamping test; anti-abuse telemetry fires |
| O4 | Deserialize a PlayerOrder variant with an unknown discriminant (protocol version mismatch) | network | serde deserialization into PlayerOrder enum returns Err for unknown variants — no silent truncation. Wire format includes protocol version header; version mismatch terminates handshake before orders flow. | T3: fuzz random bytes as PlayerOrder → never panics; unknown variants → clean error |
| O5 | Construct TickOrders with orders from multiple different ticks | caller | TickOrders has a single tick: SimTick field. The chronological() method sorts by sub-tick time only. If orders from a wrong tick are included, that’s a relay bug — the relay constructs TickOrders per tick and only includes orders assigned to that tick. Debug builds can assert tick consistency at the relay level. | T2: orders injected into wrong TickOrders batch → relay-level assertion in debug; sim processes whatever the relay emits |
| O6 | Bypass OrderBudget by constructing it via struct literal with inflated burst_cap | caller/adversary | OrderBudget has _private: () field — construction outside the defining module is a compile error. Only OrderBudget::new() can create instances, which validates refill_per_tick > 0 && burst_cap > 0. | Compile-time enforcement — verify in code review |
| O7 | Create Verified<PlayerOrder> without actually validating the order | caller | Verified::new_verified() is pub(crate) — only code within the same crate (the order validation module) can wrap a PlayerOrder in Verified. External code receives ValidatedOrder (type alias) from the validation function. | Compile-time enforcement + code review checklist item |
| O8 | Vec<UnitTag> in Move order with 65,535 entries (oversized selection) | adversary | Wire protocol enforces maximum payload size per message. Inside sim, validate_order() checks selection size against MAX_SELECTION_SIZE (configurable per game module, RA1 default: 40). BoundedVec<UnitTag, MAX_SELECTION_SIZE> enforces at the type level. | T1: oversized selection → OrderRejectionCategory::Custom (D012, game-module-defined cap) |
3. Network Protocol & Relay (ic-net)
Surface: Relay handshake, session creation, NetworkModel trait, message lanes, reconnection, transport encryption.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| N1 | Send a FromServer<T> message from a client | adversary | FromClient<T> and FromServer<T> are distinct types. Relay handler functions accept only FromClient<T>. A FromServer<T> message arriving on a client connection is a type mismatch that the handler signature rejects. | T2: protocol test — client packet with server message type → connection terminated |
| N2 | Replay a captured handshake challenge response | adversary | SHA-256 challenge-response with server-generated nonce. Challenge includes session_id and monotonic nonce_counter. Replayed responses fail because the nonce has been consumed. Verified<HandshakeResponse> ensures the verification step cannot be skipped. | T2: replay captured handshake → rejection |
| N3 | Reconnect and receive a snapshot that’s been tampered with by a malicious relay | adversary | Reconnection snapshot is Verified<SimSnapshot> — the hash is checked against the local trusted hash chain. Requires StateHash match, not just SyncHash. The client verifies before restoring. | T4: corrupted reconnection snapshot → client rejects; stale tick → client rejects |
| N4 | Exploit NetworkModel trait to inject orders that bypass validation | caller | NetworkModel::poll_tick() returns TickOrders — which still pass through Simulation::apply_tick() → validate_order(). The trait has no way to inject pre-validated orders. Even LocalNetwork (test impl) goes through the full validation path. | T2: LocalNetwork integration test — invalid orders still rejected |
| N5 | Cause a desync by sending different orders to different clients (relay compromise) | adversary | All clients compute SyncHash per tick. Relay distributes canonical TickOrders with hashes. Divergence detected within the sync frame window. Merkle tree localization identifies the divergent archetype. | T2: desync injection → detection within N ticks |
| N6 | Denial-of-service via connection flood (half-open connections) | adversary | Connection state machine uses typestate: Connection<Disconnected> → Connection<Handshaking> → Connection<Authenticated> → Connection<InGame> → Connection<PostGame>. Half-open timeout is enforced — Connection<Handshaking> that doesn’t advance within timeout is dropped. BoundedVec for pending connections with configurable cap. | T3: 10,000 half-open connections → all timeout; relay remains responsive |
| N7 | SyncHash and StateHash confused in desync comparison logic | caller | Distinct newtypes with no implicit conversion. Functions that compare sync data accept SyncHash; functions that verify snapshots accept StateHash. Using the wrong type is a compile error. | Compile-time enforcement |
| N8 | Message lane priority manipulation (sending low-priority data on high-priority lane) | adversary | Message lanes are an enum MessageLane { Orders, Control, Chat, Voice, Bulk } (see wire-format.md). Lane selection is relay-side — clients submit to the relay’s ingress, and the relay routes to appropriate lanes. Client cannot select output lane. | T2: client message with wrong lane header → relay re-routes or rejects |
4. WASM Sandbox API (ic-script)
Surface: Host functions exposed to WASM mods, WasmSandbox<S> lifecycle, WasmInstanceId, capability tokens.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| W1 | Call execute() on a WasmTerminated instance | mod/caller | Typestate pattern: WasmSandbox<WasmTerminated> has no execute() method. Only WasmSandbox<WasmReady> does. Attempting to call it is a compile error. | Compile-time enforcement |
| W2 | Module A reads Module B’s ECS data via crafted query | mod | Host API functions take WasmInstanceId (which encodes the owning mod). The host filters ECS queries to only return data the mod has permission to see. WasmInstanceId is a newtype — modules cannot forge another module’s ID (it’s assigned by the host, never exposed as raw integer to the module). | T3: cross-module data probe → permission error |
| W3 | Module attempts memory.grow(65536) (4GB) to exhaust host memory | mod/adversary | WASM runtime configured with memory cap (max_pages). memory.grow beyond the limit traps the module. The trap is caught by the host, which terminates the module cleanly. BoundedCvar<u32> controls the max pages setting — cannot be set to unreasonable values. | T3: adversarial memory.grow → denied at limit; host stable |
| W4 | Module writes f32 value to sim state via host callback | mod | Host API functions that write to sim components accept FixedPoint, not f32. The WASM→host ABI conversion layer rejects f32 arguments for sim-writing functions. No implicit float→fixed conversion. | T3: float write attempt → type rejection error |
| W5 | Module enters infinite loop (CPU exhaustion) | mod | Fuel metering: WASM runtime configured with instruction fuel budget per tick. When fuel exhausted, execution traps. Host catches trap, terminates module, continues game. The fuel budget is a BoundedCvar<u64> — runtime configurable within bounds. | T3: infinite loop module → terminated at fuel limit; game continues |
| W6 | Module calls FsReadCapability with ../../etc/passwd path | mod/adversary | FsReadCapability restricts to a StrictPath<PathBoundary>. Path traversal via ../ is caught by StrictPath::join(), which rejects any path that escapes the boundary. The capability’s allowed_path cannot be modified by the module (private field + _private: ()). | T3: path traversal attempt → SandboxError::PathEscapeAttempt |
| W7 | Module constructs a FsReadCapability from scratch | mod | FsReadCapability has _private: () — unconstructible outside the host module. WASM modules receive capabilities as opaque handles passed by the host. The handle-to-capability mapping is host-internal. | Compile-time enforcement |
| W8 | Module calls host functions after being terminated (use-after-terminate) | caller | Typestate: WasmSandbox<WasmTerminated> exposes no host callback methods. Drop implementation cleans up the WASM instance. Host function table is deregistered on termination. | Compile-time enforcement + T3: access after terminate → no-op or clean error |
| W9 | Two WASM modules loaded with the same WasmInstanceId | caller | WasmInstanceId is allocated by a global monotonic counter in the host. Allocation is pub(crate) — external code cannot construct WasmInstanceId. Counter never resets within a process. | T2: load 100 modules → all IDs unique; ID pool exhaustion → clean error |
5. Lua Sandbox API (ic-script)
Surface: Lua globals (D024: 16+ OpenRA-compatible + IC extensions), resource limits, deterministic execution.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| L1 | string.rep("a", 2^30) memory bomb | mod | Lua runtime configured with memory limit. Allocation hook denies requests beyond budget. Script receives out of memory error. Memory budget is BoundedCvar<usize>. | T3: string.rep bomb → allocation denied; host stable |
| L2 | Infinite loop via while true do end | mod | Instruction count hook fires after fuel budget expires. Script terminated with timeout error. Host recovers execution. | T3: infinite loop → terminated at instruction limit |
| L3 | os.execute(), io.open(), loadfile() — system access | mod | These standard library functions are not registered in the sandbox. The Lua environment is constructed with a whitelist of allowed globals. Attempts to call them produce nil function error. | T1: attempt to call each banned function → nil/error |
| L4 | Script modifies global state shared between scripts | mod | Each Lua script runs in an isolated environment (_ENV). Global tables are read-only from the script’s perspective (metatable __newindex error). Scripts communicate only through host-mediated APIs. | T3: script A sets global → script B cannot see it |
| L5 | Script crafts a UnitTag directly to manipulate units it shouldn’t access | mod | Lua receives UnitTag values from host API calls. Lua’s number type represents them as opaque integers. Every host API that accepts a UnitTag validates ownership against the calling script’s PlayerId. Lua cannot construct a valid UnitTag — it must receive one from the host. | T3: script forges UnitTag for enemy unit → host rejects |
| L6 | Script calls Trigger.AfterDelay() with delay=0 to create recursive timer bomb | mod | Timer system enforces minimum delay (MIN_TRIGGER_DELAY, e.g., 1 tick). BoundedCvar ensures the minimum cannot be configured to 0. Timer depth is bounded — recursive timer chains exceeding MAX_TRIGGER_DEPTH are terminated. | T3: delay=0 timer → rejected or clamped; recursive chain → terminated |
| L7 | Non-deterministic Lua behavior (e.g., table iteration order, math.random()) | mod | math.random() is redirected to the sim’s DeterministicRng (not removed — OpenRA compat). Table creation uses deterministic key ordering by construction. os.time(), os.clock() are removed from the environment. | T2: Lua script producing random numbers → identical sequence across platforms |
6. Replay System
Surface: Recording, playback, signing, ForeignReplayPlayback, format parsing.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| R1 | Tampered replay file submitted as evidence for dispute resolution | adversary | Replay file includes Ed25519 signature chain. Verified<ReplaySignature> is required before a replay is accepted as tamper-evident evidence (tournament review, dispute resolution). The ranking system itself accepts only relay-signed CertifiedMatchResult (D055) — replays are evidence artifacts, not the primary ranked authority. | T4: tampered frame → signature verification fails |
| R2 | Replay from a different game version played back on current version | caller | Replay header contains version: u16 (serialization format version); metadata JSON contains base_build, data_version, and game_module (see formats/save-replay-formats.md § Metadata). Playback refuses mismatches with specific error. SnapshotCodec version dispatch (D054) handles format evolution. | T2: wrong-version replay → version mismatch error |
| R3 | Crafted replay that causes excessive memory allocation during parsing | adversary | Replay parser uses bounded readers. total_ticks declared in the header is validated against file size. Section offset/length pairs are bounds-checked before reading. Individual frame sizes are bounded. Fuzz testing covers decompression-bomb patterns. | T3: fuzz replay bytes → parser never allocates unboundedly; no panics |
| R4 | Replay playback diverges from recording (non-determinism bug) | caller | Replay round-trip test: record → playback → hash comparison. If hashes diverge, the replay flags the exact tick and archetype where divergence occurred using Merkle tree. | T2: replay round-trip → hash match; T3: cross-platform replay → hash match |
| R5 | Foreign replay (.orarep) with crafted orders exploiting IC’s order validation differences | adversary | ForeignReplayPlayback NetworkModel applies IC’s full order validation to foreign orders. Invalid orders are rejected (not silently applied). Divergences between foreign engine and IC behavior are logged as ForeignDivergence events. | T3: OpenRA replay with IC-invalid orders → orders rejected; divergence logged |
7. Workshop Package Lifecycle (ic-net / Workshop)
Surface: PackageInstall<S> typestate, dependency resolution, manifest verification, publishing.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| P1 | Install a package without verifying its hash (skip verification step) | caller | Typestate: PackageInstall<PkgDownloading> → PackageInstall<PkgVerifying> → PackageInstall<PkgExtracted>. Cannot call extract() on PkgDownloading — must pass through PkgVerifying first. Each transition consumes self. | Compile-time enforcement |
| P2 | Publish a package with a previously-used version number (version mutability) | adversary | Registry enforces version immutability. PackageVersion is compared using semantic versioning exact match. Once published, (PublisherId, PackageName, PackageVersion) is permanently occupied. Re-publish returns PublishError::VersionExists. | T2: re-publish same version → rejection; existing package unchanged |
| P3 | Dependency confusion — malicious package with same name as popular package in different registry | adversary | PackageName is scoped to PublisherId. Full identifier is publisher/package_name. Without the publisher prefix, resolution fails. PackageName is a validated newtype (3-64 chars, [a-z0-9-] only). | T1: unprefixed package name → validation error; name collision across publishers → both install correctly |
| P4 | Circular dependency chain: A→B→C→A | adversary/caller | DependencyGraph uses validated construction — DependencyGraph::new() runs cycle detection (topological sort). Cycles produce DependencyGraphError::Cycle { path }. The _private: () field prevents construction without validation. | T1: circular deps → cycle error with full path |
| P5 | Diamond dependency with incompatible version constraints | caller | PubGrub resolver detects version conflicts and reports both constraint chains. Error includes required_by_a, required_by_b, and the conflicting version ranges. | T1: diamond conflict → error with both chains |
| P6 | Package manifest declares small size but contains decompression bomb | adversary | Extraction uses bounded decompressor. Declared size in manifest is compared against actual decompressed size — if actual exceeds declared × safety margin (e.g., 2×), extraction aborts. CompressionAlgorithm enum controls decompressor selection (no arbitrary decompressor). | T3: decompression bomb → extraction aborted; host stable |
| P7 | Forge a Verified<ManifestHash> to bypass integrity check | caller/adversary | Verified::new_verified() is pub(crate) — only the verification function in the manifest-checking module can create it. Code outside that crate receives Verified<ManifestHash> from the verification API. | Compile-time enforcement + code review |
Sub-Pages
| Section | Topic | File |
|---|---|---|
| Analysis 8-14 + Patterns | Campaign Graph, Console, Chat, Double-Buffered State, Entity Identity, Config Loading, Asset Pipeline + cross-cutting type-system defense patterns + defense gap analysis + summary matrix | api-misuse-patterns.md |