Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Trait>` 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"

Expand Down
10 changes: 7 additions & 3 deletions crates/nexum-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
48 changes: 37 additions & 11 deletions crates/nexum-engine/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
}

// 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.
Expand Down Expand Up @@ -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 <path-to-component.wasm> [<nexum.toml>]")
})?;
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());
Expand Down
108 changes: 105 additions & 3 deletions crates/nexum-engine/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String> = 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));
}
}
19 changes: 19 additions & 0 deletions docs/07-rpc-namespace-design.md
Original file line number Diff line number Diff line change
@@ -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(...)`
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion modules/example/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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