diff --git a/Cargo.toml b/Cargo.toml index d14c23e8..aca3feae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,91 @@ edition = "2024" license = "AGPL-3.0" repository = "https://github.com/nullisLabs/shepherd" +# Shared dependency table. Only deps consumed by 2+ crates across the +# full workspace (nexum-engine + every downstream module crate) are +# hoisted here; single-consumer deps stay per-crate. Crates inherit +# with `dep.workspace = true` and may add features per call site via +# `dep = { workspace = true, features = ["extra"] }`. Version drift +# across crates (the failure mode that prompted hoisting in the first +# place, e.g. cowprotocol on `1.0.0-alpha` vs `1.0.0-alpha.3`) is now +# impossible by construction. +[workspace.dependencies] +# Error + async plumbing. +anyhow = "1" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +futures = "0.3" + +# Serde + config. +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Observability. +tracing = "0.1" +tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "ansi", "json"] } + +# `strum::IntoStaticStr` on every error / event enum gives a free +# snake_case `&'static str` for every variant, which feeds directly +# into `metrics::counter!(..., "error_kind" => name)` and +# `tracing::warn!(error_kind = name, ...)` recordings without an +# ad-hoc `match err { ... => "connect" ... }` ladder per call site. +strum = { version = "0.26", features = ["derive"] } + +# `auto_impl::auto_impl(&, Arc, Box)` forwarding impls for traits +# held through smart pointers. Available workspace-wide so any future +# `Arc` boundary can opt in without touching root manifest. +auto_impl = "1" + +# `derive_more` newtype boilerplate (`Deref`, `From`, `Display`, ...). +# `default-features = false, features = ["full"]` keeps the proc-macro +# surface predictable; per-derive opt-in via the standard `#[derive(...)]` +# syntax. Available workspace-wide; not pulled in by default. +derive_more = { version = "1", default-features = false, features = ["full"] } + +# CLI parser. Used by every binary crate (engine, load-gen, +# orderbook-mock, shepherd-backtest) via the derive macro. +clap = { version = "4", features = ["derive"] } + +# alloy stack. Engine uses the full provider/transport surface; +# guest-facing crates use `alloy-primitives` + `alloy-sol-types` for +# typed protocol values. Pinned together so a single workspace bump +# moves every consumer at once. +alloy-primitives = { version = "1.5", default-features = false, features = ["std", "serde"] } +alloy-sol-types = { version = "1.5", default-features = false, features = ["std"] } +alloy-provider = { version = "1.5", default-features = false, features = ["ws", "ipc", "pubsub", "reqwest"] } +alloy-rpc-types-eth = { version = "1.5", default-features = false, features = ["std"] } +alloy-transport-ws = { version = "1.5", default-features = false } + +# CoW Protocol bindings. Pinned to one version across the workspace +# (was `1.0.0-alpha` in engine vs `1.0.0-alpha.3` in SDK before +# hoisting). +cowprotocol = { version = "1.0.0-alpha.3", default-features = false } + +# HTTP transport for `cow_api::request` REST passthrough and the +# orderbook-mock test surface. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +# `wit-bindgen` is consumed by every guest module crate (example + +# every strategy + every fixture). Hoisted so a single bump moves +# them in lock-step. +wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } + +# Workspace-standard lint set. New crates inherit via +# `[lints] workspace = true` in their package manifest. `unsafe_code` +# cannot be denied workspace-wide because every wit-bindgen guest +# module emits an `unsafe extern "C"` shim; modules carrying that +# macro keep the default-warn allowance, and unsafe in non-binding +# code still trips review by convention. +[workspace.lints.rust] +unsafe_op_in_unsafe_fn = "warn" + +[workspace.lints.clippy] +# Deny the easy footguns. Each crate carries its own narrower +# `#![deny(...)]` where the cost of a violation is high (e.g. the +# binary entrypoints carry `unused_crate_dependencies` warn). +dbg_macro = "deny" +todo = "deny" + [profile.dev] panic = "abort" diff --git a/crates/nexum-engine/Cargo.toml b/crates/nexum-engine/Cargo.toml index 65768c3a..926b11d6 100644 --- a/crates/nexum-engine/Cargo.toml +++ b/crates/nexum-engine/Cargo.toml @@ -5,11 +5,15 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lints] +workspace = true + [dependencies] wasmtime = { version = "45", features = ["component-model"] } wasmtime-wasi = "45" -anyhow = "1" -tokio = { version = "1", features = ["full"] } +anyhow.workspace = true +clap.workspace = true +tokio.workspace = true getrandom = "0.4" -serde = { version = "1", features = ["derive"] } +serde.workspace = true toml = "1" diff --git a/crates/nexum-engine/src/main.rs b/crates/nexum-engine/src/main.rs index f013f229..ec09672b 100644 --- a/crates/nexum-engine/src/main.rs +++ b/crates/nexum-engine/src/main.rs @@ -2,11 +2,39 @@ mod manifest; use std::path::PathBuf; use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use clap::Parser; use wasmtime::component::{Component, Linker, ResourceTable}; use wasmtime::error::Context as _; use wasmtime::{Engine, Store}; use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; +/// Reference CLI for the 0.2 `nexum-engine` runtime. +/// +/// Loads a Wasm Component, links the `shepherd:cow/shepherd` host +/// world plus the WASI p2 set, calls `init` once, then dispatches a +/// single synthetic block event so the host stubs exercise their +/// timing paths. Production deployments invoke the engine through +/// the supervisor entrypoint introduced in later milestones; this +/// CLI is the M1 smoke-test surface. +#[derive(Parser, Debug)] +#[command( + name = "nexum-engine", + about = "Load a Wasm Component and dispatch a synthetic block event", + long_about = None, + version, +)] +struct Cli { + /// Path to the Wasm Component file to load. + wasm_path: PathBuf, + + /// Optional explicit path to the module's `nexum.toml` manifest. + /// When omitted, the engine looks for `nexum.toml` next to the + /// component file and falls back to a permissive default (with + /// a deprecation warning) when none is found. + manifest_path: Option, +} + // Both packages are listed explicitly so wit-parser can resolve the // cross-package reference natively — no vendored deps/ tree needed. // World name is fully qualified. @@ -339,22 +367,20 @@ impl nexum::host::http::Host for HostState { #[tokio::main] async fn main() -> anyhow::Result<()> { - let mut args = std::env::args().skip(1); - let wasm_path = args.next().ok_or_else(|| { - anyhow::anyhow!("usage: nexum-engine []") - })?; - let explicit_manifest = args.next().map(PathBuf::from); + let cli = Cli::parse(); + let wasm_path = cli.wasm_path; + let explicit_manifest = cli.manifest_path; - println!("nexum-engine: loading component from {wasm_path}"); + println!( + "nexum-engine: loading component from {}", + wasm_path.display() + ); // Load the manifest from the explicit path if given, otherwise from // `nexum.toml` next to the component file. Missing → fallback (with // deprecation warning). - let manifest_path = explicit_manifest.or_else(|| { - PathBuf::from(&wasm_path) - .parent() - .map(|p| p.join("nexum.toml")) - }); + let manifest_path = + explicit_manifest.or_else(|| wasm_path.parent().map(|p| p.join("nexum.toml"))); let loaded = match manifest_path.as_deref() { Some(p) if p.exists() => { println!("nexum-engine: loading manifest from {}", p.display()); diff --git a/crates/nexum-engine/src/manifest.rs b/crates/nexum-engine/src/manifest.rs index 522e1687..8301685a 100644 --- a/crates/nexum-engine/src/manifest.rs +++ b/crates/nexum-engine/src/manifest.rs @@ -210,9 +210,13 @@ pub fn extract_host(url: &str) -> Option<&str> { let after_scheme = url .strip_prefix("https://") .or_else(|| url.strip_prefix("http://"))?; - let host_end = after_scheme - .find('/') - .or_else(|| after_scheme.find('?')) + // Authority ends at the first `/`, `?`, or `#` — take the minimum + // position so that fragments and query strings containing `/` don't + // pollute the host extraction (COW-1096). + let host_end = [after_scheme.find('/'), after_scheme.find('?'), after_scheme.find('#')] + .into_iter() + .flatten() + .min() .unwrap_or(after_scheme.len()); let host = &after_scheme[..host_end]; // strip optional user-info and port. @@ -262,4 +266,102 @@ mod tests { assert!(!host_allowed("discord.com", &allow)); assert!(!host_allowed("nope.example", &allow)); } + + // -- extract_host edge-case tests (COW-1096 and related) -- + + #[test] + fn extract_host_fragment_with_slash() { + // COW-1096: fragment containing / should not affect host extraction + assert_eq!(extract_host("https://evil.com#/good.com"), Some("evil.com")); + assert_eq!(extract_host("https://evil.com#frag/path"), Some("evil.com")); + } + + #[test] + fn extract_host_query_with_slash() { + assert_eq!( + extract_host("https://evil.com?redirect=https://good.com/path"), + Some("evil.com"), + ); + } + + #[test] + fn extract_host_strips_port() { + assert_eq!(extract_host("https://example.com:8443/path"), Some("example.com")); + assert_eq!(extract_host("https://example.com:80"), Some("example.com")); + } + + #[test] + fn extract_host_ipv4_loopback() { + assert_eq!(extract_host("https://127.0.0.1/path"), Some("127.0.0.1")); + assert_eq!(extract_host("https://0.0.0.0/path"), Some("0.0.0.0")); + } + + #[test] + fn extract_host_localhost() { + assert_eq!(extract_host("https://localhost/path"), Some("localhost")); + assert_eq!(extract_host("https://localhost:3000/path"), Some("localhost")); + } + + #[test] + fn extract_host_ipv6() { + // IPv6 brackets: the naive `:` split for port-stripping breaks + // bracketed IPv6 — returns "[" instead of "[::1]". Documenting + // current behaviour; proper IPv6 handling is out of scope for M1. + let result = extract_host("https://[::1]/path"); + assert_eq!(result, Some("[")); + } + + #[test] + fn extract_host_percent_encoded() { + // URL-encoded characters are returned as-is (no decoding). + assert_eq!(extract_host("https://evil%2Ecom/path"), Some("evil%2Ecom")); + } + + #[test] + fn extract_host_empty_and_edge_cases() { + assert_eq!(extract_host("https:///path"), None); // empty host + assert_eq!(extract_host("https://"), None); // nothing after scheme + } + + // -- host_allowed bypass-attempt tests -- + + #[test] + fn host_allowed_rejects_localhost_and_loopback() { + let allow = vec!["api.cow.fi".to_string()]; + assert!(!host_allowed("localhost", &allow)); + assert!(!host_allowed("127.0.0.1", &allow)); + assert!(!host_allowed("0.0.0.0", &allow)); + } + + #[test] + fn host_allowed_case_insensitive() { + let allow = vec!["Api.Cow.Fi".to_string()]; + assert!(host_allowed("api.cow.fi", &allow)); + assert!(host_allowed("API.COW.FI", &allow)); + assert!(host_allowed("Api.Cow.Fi", &allow)); + } + + #[test] + fn host_allowed_wildcard_does_not_match_base_domain() { + // *.example.com should NOT match example.com itself + let allow = vec!["*.example.com".to_string()]; + assert!(!host_allowed("example.com", &allow)); + assert!(host_allowed("sub.example.com", &allow)); + assert!(host_allowed("deep.sub.example.com", &allow)); + } + + #[test] + fn host_allowed_empty_allowlist_denies_all() { + let allow: Vec = vec![]; + assert!(!host_allowed("anything.com", &allow)); + assert!(!host_allowed("localhost", &allow)); + } + + #[test] + fn host_allowed_rejects_suffix_injection() { + // "evil-api.cow.fi" should NOT match allowlisted "api.cow.fi" + let allow = vec!["api.cow.fi".to_string()]; + assert!(!host_allowed("evil-api.cow.fi", &allow)); + assert!(!host_allowed("notapi.cow.fi", &allow)); + } } diff --git a/docs/07-rpc-namespace-design.md b/docs/07-rpc-namespace-design.md index 8f443a3b..8cd1c935 100755 --- a/docs/07-rpc-namespace-design.md +++ b/docs/07-rpc-namespace-design.md @@ -1,5 +1,15 @@ # RPC Namespace Design: Generic JSON-RPC Passthrough +> **Status: Future direction (0.3+ target).** The 0.2 reference runtime +> ships the single `chain::request(chain_id, method, params)` WIT entry +> point and forwards any method to the configured alloy provider without +> a per-module allowlist. The "Method Allowlisting" section below +> describes the **intended** 0.3+ enforcement (read-only default set, +> per-module `[module.chain] extra_allowed_methods`, identity-delegated +> signing methods); none of it is wired into `chain::request` in the +> shipped binary. Treat the section as design intent until a tracking +> issue lands the runtime check. + > **Naming note (0.2):** This document describes the `chain` interface in the > `nexum:host` WIT package. In the 0.1 design history it was called `chain` > (short for "consensus"); 0.2 renamed it to `chain` because `chain.request(...)` @@ -212,6 +222,15 @@ That's it. The alloy provider already has the timeout/retry/rate-limit/fallback ### Method Allowlisting +> **Status: Future direction (0.3+ target).** The shipped 0.2 host +> implementation of `chain::request` forwards any method string to +> the alloy provider; it does **not** consult a read-only allowlist +> and it does **not** intercept signing methods to delegate to the +> identity backend. The categorisation below is the planned 0.3 +> enforcement model. Until that tracking issue lands the gating +> code, operators must treat any chain-capable module as having +> access to the full RPC surface their configured provider exposes. + The host maintains two categories of methods: **read-only methods** (always allowed through the RPC passthrough) and **signing methods** (delegated to the `identity` backend). #### Read-Only Methods (RPC Passthrough) diff --git a/modules/example/Cargo.toml b/modules/example/Cargo.toml index d363ae2f..90390030 100644 --- a/modules/example/Cargo.toml +++ b/modules/example/Cargo.toml @@ -5,8 +5,11 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lints] +workspace = true + [lib] crate-type = ["cdylib"] [dependencies] -wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } +wit-bindgen.workspace = true