8. Campaign Graph (ic-sim / D021)
Surface: CampaignGraph, MissionExecution<S>, roster carryover, story flags.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| C1 | Complete a mission that is still loading (MissionLoading → MissionCompleted) | caller | Typestate: MissionExecution<MissionLoading> has no complete() method. Only MissionExecution<MissionActive> does. Transition consumes self. | Compile-time enforcement |
| C2 | Construct a CampaignGraph with cycles (mission A → B → A) | caller/mod | Validated construction: CampaignGraph::new() validates the DAG property. _private: () prevents bypass via struct literal. | T1: cyclic graph → CampaignGraphError::Cycle |
| C3 | Transition to a MissionId that doesn’t exist in the graph | caller/mod | MissionExecution<MissionCompleted>::transition() takes an OutcomeName and looks up the next mission in the validated CampaignGraph. Invalid outcomes return CampaignTransitionError::NoSuchOutcome. | T1: dangling outcome → error |
| C4 | Roster carryover with fabricated unit stats (inflated health/veterancy) | mod/adversary | Roster is extracted from the sim snapshot at mission end — not user-supplied. roster_extract() reads from Verified<SimSnapshot>. Stats come from the actual sim state, not from save data the user can edit. | T2: roster extraction from snapshot → matches actual unit state |
| C5 | Story flag name collision between different campaign mods | mod | Story flags are namespaced by CampaignId. Flag access API requires (CampaignId, FlagName). FlagName is a validated newtype. Cross-campaign flag access is impossible without the correct CampaignId. | T2: flag set in campaign A → not visible in campaign B |
9. Console Commands (ic-ui / D058)
Surface: Command tree, cvar system, BoundedCvar<T>, permission tiers, DevModeFlag.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| X1 | Non-admin player executes admin-only command | network/adversary | Each command node is tagged with a PermissionTier enum (Player, Admin, Dev). Command execution checks caller.permission >= command.required_tier. The comparison is on the enum, not a raw integer — no off-by-one privilege escalation. | T1: non-admin → admin command → permission error |
| X2 | Set a BoundedCvar to out-of-range value via console | caller | BoundedCvar::set() clamps to [min, max]. The cvar cannot represent an out-of-range value — it’s structurally impossible after construction. _private: () prevents direct field assignment. | T1: set cvar to extreme value → clamped to nearest bound |
| X3 | Command injection via chat-crafted / prefix in multiplayer | adversary | Chat messages beginning with / are parsed through the command tree. Unknown commands return CommandError::NotFound — they are not echoed to chat. The Brigadier-style parse tree has no string interpolation or eval. | T2: malformed command string → clean error; no eval |
| X4 | Execute dev command during ranked game without flagging | caller | Any command tagged DEV_ONLY sets DevModeFlag on the sim state. DevModeFlag is checked by the ranking system — flagged games are excluded from ranked standings. The flag is write-once-per-session (cannot be unset). | T2: dev command in ranked → replay flagged; no leaderboard entry |
| X5 | Flood commands to overwhelm the command parser | adversary | Command processing is rate-limited via the same OrderBudget mechanism as orders. Excess commands are dropped with CommandRejection::RateLimitExceeded. | T2: 1000 commands/tick → excess dropped; rate recovers |
10. Chat & Communication System (D059)
Surface: ChatMessage<S> scope-branded messages (domain/UI layer — lowered to unbranded ChatMessage { channel, text } at the wire boundary; see type-safety.md § Layering note), ping system, chat wheel, minimap drawing.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| M1 | Team chat message accidentally broadcast to all players | caller | ChatMessage<TeamScope> and ChatMessage<AllScope> are distinct types. The team chat handler accepts only ChatMessage<TeamScope>. The routing function for team chat is physically separate from all-chat routing. No implicit From<TeamScope> → AllScope conversion. | T1: team message → only team members receive; T2: integration test |
| M2 | RTL/BiDi override characters injected in chat to disguise message content | adversary | SanitizedString replaces String for all user-facing text. Construction requires passing through sanitize_bidi(), which strips/neutralizes BiDi override codepoints per UAX #9. The inner String is private. | T3: BiDi QA corpus regression suite |
| M3 | Display name impersonation via Unicode confusables (Cyrillic ‘а’ vs Latin ‘a’) | adversary | Display names are validated via UTS #39 confusable check at registration. DisplayName is a validated newtype — construction runs confusable detection. Existing names are checked for collision with the new name’s skeleton. | T3: confusable corpus → all impersonation attempts blocked |
| M4 | Ping spam (hundreds of pings per second to obscure minimap) | adversary | Ping system uses OrderBudget-style rate limiting. Excess pings are silently dropped. Ping rate limit is lower than order rate limit (configurable, default: 5/second). | T2: 100 pings/second → excess dropped; rate recovers |
| M5 | Minimap drawing exceeds size limit (draw thousands of points) | adversary | Drawing commands are bounded (BoundedVec<DrawPoint, MAX_DRAW_POINTS>). Excess points are truncated. Drawing state is cleared each tick. | T2: oversized draw command → truncated to limit |
11. Double-Buffered State
Surface: DoubleBuffered<T> — used for fog visibility, influence maps, weather terrain effects, global condition modifiers.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| B1 | Reading from the write buffer during a tick (seeing partial state) | caller | DoubleBuffered::read() returns &T of the read buffer. DoubleBuffered::write() returns &mut T of the write buffer. Borrow checker prevents aliasing. Additionally, only the designated writer system calls write() — enforced by Bevy system parameter constraints (the writer system takes ResMut<DoubleBuffered<T>>, readers take Res<DoubleBuffered<T>>). | T2: determinism test — two sims with same input produce identical fog state |
| B2 | Forgetting to call swap() at tick boundary (readers see stale data) | caller | swap() is called in Simulation::apply_tick() at a fixed point (before system pipeline). This is a single call site, not distributed across systems. Integration tests verify fog state advances per tick. | T2: fog update → next tick readers see updated visibility |
| B3 | Calling swap() multiple times per tick (can corrupt state) | caller | swap() is called exactly once in apply_tick(). Debug assertion tracks swap count per tick. Multiple swaps in a single tick triggers debug_assert!(self.swap_count_this_tick == 0). | T1: debug assertion catches double-swap in test |
| B4 | Writer system mutates the read buffer directly (bypassing double-buffered write) | caller | The read buffer’s type is accessed via read() → &T (immutable). Without unsafe, the writer cannot obtain &mut to the read buffer. Rust’s borrow system enforces this structurally. | Compile-time enforcement |
12. UnitTag & Entity Identity
Surface: UnitTag { index: u16, generation: u16 }, UnitPool resource, UnitTag ↔ Entity mapping.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| U1 | Stale UnitTag used after unit death (use-after-free analog) | caller/mod/network | UnitPool::resolve(tag) checks generation. If the slot has been recycled (new unit with higher generation), returns None. Callers handle None explicitly (order validation rejects stale targets). | T1: stale UnitTag → resolution returns None; T2: attack order on dead unit → OrderRejection |
| U2 | UnitTag Index overflow — more than 65,535 units | caller | UnitPool size is bounded by game module config (RA1: 2048). Allocation beyond capacity returns UnitPoolError::PoolExhausted. The pool size is checked at game start — misconfig is caught early. | T2: pool exhaustion → clean error; T3: fuzz with adversarial spawn rates |
| U3 | Generation wraparound — generation u16 overflows after 65,535 recycles of same slot | caller | At 30 tps with frequent unit deaths, a single slot recycling every 2 seconds wraps in ~36 hours. In practice, generation overflow is unlikely but handled: UnitPool detects wraparound and retires the slot permanently (marks it unusable). | T4: synthetic stress test — force 65,535 recycles on one slot → slot retired |
| U4 | Lua/WASM script constructs a UnitTag from raw integers | mod | Host API returns UnitTag values as opaque handles. Lua represents them as userdata (not plain numbers). WASM receives them as handles through the ABI. Neither can construct arbitrary UnitTag values — they can only use values the host gave them. | T3: script forges tag value → host API rejects with ownership error |
| U5 | Serialized UnitTag in replay or network message doesn’t match any live entity | network | UnitPool::resolve() returns None for unknown tags. Order execution handles None gracefully (order is silently dropped or rejected depending on context). No panic path. | T2: replay with stale tags → orders rejected gracefully |
13. Configuration Loading (TOML/YAML)
Surface: config.toml, server_config.toml, settings.toml, mod YAML, serde deserialization.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| F1 | YAML rule with negative health or zero-cost unit creating degenerate gameplay | mod | Schema validation runs at load time. Health.max is validated as > 0. Buildable.cost is validated as >= 0. Validation errors include the YAML file path, line number, and field name. | T1: negative health YAML → schema error with location |
| F2 | YAML inheritance creates infinite loop (A inherits B inherits A) | mod | Inheritance resolver detects cycles via visited-set tracking. RuleLoadError::CircularInheritance { chain } includes the human-readable cycle path. | T1: circular inheritance → error with chain; T3: fuzz random inheritance trees |
| F3 | TOML config with unknown keys (typos that silently do nothing) | caller | serde’s #[serde(deny_unknown_fields)] on config structs. Unknown keys produce a deserialization error naming the unknown field and listing valid fields. ic server validate-config CLI runs the same check. | T1: unknown key in config → error with suggestion |
| F4 | Config value within valid range but cross-parameter inconsistency (e.g., min > max) | caller | Config struct validate() method checks cross-parameter invariants after deserialization. Example: OrderBudget::new() rejects refill > burst_cap. Validated construction applies to all config types with dependent constraints. | T1: cross-parameter inconsistency → specific error |
| F5 | Server config hot-reload introduces inconsistent state mid-game | caller | Hot-reload applies changes at tick boundary only (between ticks). Changes are validated against the full config before application — invalid partial updates are rejected atomically. The reload handler takes &mut Config, not individual fields. | T2: hot-reload with invalid change → rejected; old config preserved |
14. Asset Pipeline & Format Parsing (ic-cnc-content)
Surface: .mix, .shp, .pal, .aud, .oramap parsers, StrictPath, virtual filesystem.
| # | Misuse Vector | Origin | Type-System Defense | Test Requirement |
|---|---|---|---|---|
| A1 | Zip Slip in .oramap archive (entry with ../../ path) | adversary | StrictPath<PathBoundary> rejects any path that escapes its boundary directory. The .oramap extractor resolves entries through StrictPath::join(), which normalizes and boundary-checks. | T3: fuzz .oramap with path traversal entries → all rejected |
| A2 | .mix archive with header declaring more files than the archive contains | adversary | Parser compares declared file count against available data. Returns MixParseError::FileCountMismatch { declared, actual } with full diagnostic. | T1: truncated .mix → specific error; T3: fuzz random .mix bytes |
| A3 | .shp sprite file with frame dimensions causing integer overflow in buffer allocation | adversary | Frame dimensions are validated against MAX_SPRITE_DIMENSION (configurable, default: 4096×4096). Buffer allocation uses checked_mul() — overflow returns error, not wrap. | T3: fuzz .shp with extreme dimensions → clean error |
| A4 | .aud audio file with decompression ratio indicating a bomb | adversary | Audio decoder uses bounded output buffer. If decompressed size exceeds MAX_AUDIO_OUTPUT_BYTES, decoding aborts. The declared sample count is cross-checked against the compressed data size. | T3: fuzz .aud with decompression bomb → clean abort |
| A5 | Virtual filesystem path collision (two mods providing same asset path) | mod | Mod profile system (D062) uses explicit priority ordering. When two mods conflict, the higher-priority mod wins. Conflict is detected and reported at profile activation (not silently). Fingerprint of the resolved namespace ensures all clients agree. | T2: conflicting mods → conflict reported; resolution deterministic |
Cross-Cutting Type-System Defense Patterns
These patterns apply across multiple API surfaces:
Pattern 1: Newtype Fortress
Every domain identifier (PlayerId, SimTick, UnitTag, MissionId, PackageName, WasmInstanceId, etc.) is a newtype with a private inner field. Construction is restricted to pub(crate) or validated factory methods. This eliminates the most common bug class in game engines: parameter swap errors.
Measured impact: In a hypothetical codebase using raw integers, apply_order(player: u32, tick: u32, unit: u32) permits 6 argument orderings. With newtypes, only 1 compiles. This is a 6× reduction in the callable-but-wrong surface.
Pattern 2: Typestate Lifecycle
State machines with restricted transitions use typestate (connection, WASM sandbox, Workshop install, campaign mission, balance patch). Invalid transitions are compile errors, not runtime checks. Every consume-self-return-new-state method documents the transition in its type signature.
Coverage: 5 typestate machines currently defined. Each eliminates 2–4 invalid transition paths that would otherwise require runtime checks and tests.
Pattern 3: Capability Tokens
APIs that grant access (filesystem, network, ECS queries from mods) require unforgeable capability tokens. Tokens use _private: () to prevent external construction. This pattern applies to: FsReadCapability, ReconcilerToken, AdminCapability, and WASM host function access.
Pattern 4: Verified Wrappers
Security-critical data passes through verification once, then carries proof of verification in the type system. Verified<T> prevents the “forgot to verify” bug class. Applies to: SCR signatures, manifest hashes, replay signatures, validated orders.
StructurallyChecked<T> is the relay-side counterpart — a weaker wrapper indicating structural validation (decode + field bounds) has passed, but NOT full sim validation (D012). The relay cannot produce Verified<T> because it does not run ic-sim. Applies to: StructurallyChecked<TimestampedOrder> in the ForeignOrderPipeline (see cross-engine/relay-security.md). Same _private: () construction guard as Verified<T> — only the structural validation path can wrap.
Pattern 5: Bounded Collections
Every collection that grows based on external input has a type-enforced bound. BoundedVec<T, N> returns Err(CapacityExceeded) on overflow. This applies to: order queues, chat buffers, ping lists, draw commands, waypoints, build queues, group assignments.
Pattern 6: Direction/Scope Branding
Messages carry their origin/destination in the type: FromClient<T> vs FromServer<T>, ChatMessage<TeamScope> vs ChatMessage<AllScope>. Routing errors become type errors. No implicit conversion between branded types.
Defense Gap Analysis
Areas where type-system defenses are defined but should be validated through priority testing:
| Gap | Current State | Recommended Action | Priority |
|---|---|---|---|
DeltaSnapshot baseline validation | Covered: S10 runtime test in testing-properties-misuse-integration.md (divergent-baseline delta → Err(BaselineMismatch), sim state unchanged). Delta baselines matter for replay apply_full_delta() and autosave I/O-thread reconstruction — reconnection uses full SimSnapshot, not deltas. | M2 | |
| Composite restore/apply integration | Property tests cover GameRunner::restore_full() / apply_full_delta() round-trips and autosave fidelity (testing-properties-misuse-integration.md) — this validates the testing contract (correct types, correct assertions, correct metric). Remaining open gap: integration tests exercising campaign graph mutation + Lua VM rehydration + script on_deserialize() correctness across save-load and replay seeking paths. The property tests prove the sim-core round-trip; the integration gap is the campaign/script layer behavior under real orchestration. | Add integration test scenarios: (1) save-load with active campaign branching + Lua globals, (2) replay seek across a campaign branch point, (3) autosave round-trip with WASM persistent memory | M4 |
| Lua timer recursion depth bound | Designed (L6) but not in any fuzz target | Add lua_timer_chain fuzz target | M7 |
UnitTag generation wraparound | Handled by slot retirement (U3) but rare to trigger | Add T4 stress test for wraparound | M9 |
| Config hot-reload atomicity | Designed (F5) but not in subsystem test specs | Add to Console Command or Server Config test table | M5 |
| Foreign replay order rejection (R5) | ForeignDivergence logging defined but no labeled corpus | Add foreign replay test corpus (10 replays minimum) | M10 |
| Cross-module WASM communication | Blocked by type system; basic cross-module probe tests spec’d at M6 (V50 — testing-coverage-release.md). Remaining gap: inter-module data leaks via shared host resources (e.g., global ECS) need expanded integration testing beyond M6’s probe coverage. | Add WASM shared-host-resource integration scenarios (beyond M6 probes) | M7 |
| Ping/draw rate recovery timing | Budget recovery is tested but exact tick-level recovery rate is not asserted | Add specific tick-count assertion for rate recovery | M6 |
Summary Matrix
| API Surface | Misuse Vectors | Compile-Time Defenses | Runtime Defenses | Tests Required |
|---|---|---|---|---|
| Simulation | 10 | 3 (borrow checker, !Sync via Bevy World, ReconcilerToken) | 8 (hash check, validation, bounds, dedup) | 8 runtime tests |
| Order Pipeline | 8 | 3 (Verified, _private, BoundedVec) | 5 (validation, budget, clamping) | 6 runtime tests |
| Network/Relay | 8 | 2 (FromClient/FromServer, SyncHash≠StateHash) | 6 (challenge, hash, typestate) | 6 runtime tests |
| WASM Sandbox | 9 | 4 (typestate, _private, capability tokens, type rejection) | 5 (fuel, memory cap, timeout) | 7 runtime tests |
| Lua Sandbox | 7 | 0 (Lua is dynamically typed — defenses are host-side) | 7 (memory limit, fuel, whitelist, isolation) | 7 runtime tests |
| Replay | 5 | 1 (Verified<ReplaySignature>) | 4 (signature, version, bounds, hash) | 5 runtime tests |
| Workshop | 7 | 3 (typestate, _private, validated construction) | 4 (immutability, cycle detection, bomb guard) | 5 runtime tests |
| Campaign | 5 | 2 (typestate, validated construction) | 3 (flag namespace, roster extraction, reference check) | 4 runtime tests |
| Console | 5 | 1 (BoundedCvar) | 4 (permission, rate limit, flag, clamping) | 5 runtime tests |
| Chat | 5 | 2 (scope branding, SanitizedString) | 3 (rate limit, BiDi strip, confusable check) | 5 runtime tests |
| Double-Buffer | 4 | 3 (borrow checker, &T read, &mut T write) | 1 (debug swap count) | 2 runtime tests |
| UnitTag | 5 | 1 (opaque handle for mods) | 4 (generation check, pool bound, ownership validation) | 5 runtime tests |
| Config | 5 | 1 (deny_unknown_fields) | 4 (validation, cycle detect, cross-param, atomicity) | 5 runtime tests |
| Asset Pipeline | 5 | 1 (StrictPath) | 4 (bounds check, bomb guard, hash-check, priority) | 4 runtime tests |
| Totals | 88 | 27 compile-time | 61 runtime | 76 tests |
The type system eliminates 27 misuse vectors at compile time (31%). The remaining 61 require runtime checks. Most runtime defenses have corresponding tests spec’d in testing-properties-misuse-integration.md; the gap table above tracks the remaining coverage work (composite integration, Lua timer fuzz, config atomicity, foreign replay corpus, WASM inter-module isolation, ping rate recovery).