Skip to content
Open
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
61 changes: 42 additions & 19 deletions crates/nexum-runtime/src/manifest/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

use std::collections::HashSet;

use strum::VariantNames;

use super::error::{CapabilityError, CapabilityViolation};
use super::types::{CORE_CAPABILITIES, LoadedManifest};

Expand Down Expand Up @@ -54,35 +56,56 @@ const HTTP_CAPABILITY: &str = nexum_world::Cap::Http.as_str();

/// Gated WASI capability names; declaring one grants the matching `wasi:`
/// interface group. See [`classify_wasi`].
const WASI_CAPABILITIES: &[&str] = &["wasi-sockets", "wasi-filesystem"];
const WASI_CAPABILITIES: &[&str] = WasiCap::VARIANTS;

/// A gated WASI capability; the single source of the `wasi-*` name set.
#[derive(Clone, Copy, strum::IntoStaticStr, strum::VariantNames, strum::VariantArray)]
enum WasiCap {
#[strum(serialize = "wasi-sockets")]
Sockets,
#[strum(serialize = "wasi-filesystem")]
Filesystem,
}

impl WasiCap {
const ALL: &'static [Self] = <Self as strum::VariantArray>::VARIANTS;

fn as_str(self) -> &'static str {
self.into()
}

/// The `wasi:` interface prefix this capability gates.
const fn gated_prefix(self) -> &'static str {
match self {
Self::Sockets => "wasi:sockets/",
Self::Filesystem => "wasi:filesystem/",
}
}
}

/// Always-linked `wasi:` prefixes: io, clocks, random, stdio/exit/terminal.
const AMBIENT_WASI_PREFIXES: &[&str] = &["wasi:io/", "wasi:clocks/", "wasi:random/", "wasi:cli/"];

/// A `wasi:` import (other than `wasi:http`) classified against the gate.
enum WasiGate {
/// Always linked, never declared: io, clocks, random, stdio/exit/terminal.
/// Always linked, never declared.
Ambient,
/// Usable only when the named capability is declared.
Gated(&'static str),
/// Usable only when the capability is declared.
Gated(WasiCap),
/// Unrecognised `wasi:` interface: refused fail-closed.
Unknown,
}

/// Classify a non-http `wasi:` interface id, ignoring any `@version` suffix.
fn classify_wasi(import_name: &str) -> WasiGate {
let iface = import_name.split('@').next().unwrap_or(import_name);
if iface.starts_with("wasi:io/")
|| iface.starts_with("wasi:clocks/")
|| iface.starts_with("wasi:random/")
{
WasiGate::Ambient
} else if iface.starts_with("wasi:filesystem/") {
WasiGate::Gated("wasi-filesystem")
} else if iface.starts_with("wasi:sockets/") {
WasiGate::Gated("wasi-sockets")
} else if iface.starts_with("wasi:cli/") {
WasiGate::Ambient
} else {
WasiGate::Unknown
if AMBIENT_WASI_PREFIXES.iter().any(|p| iface.starts_with(p)) {
return WasiGate::Ambient;
}
WasiCap::ALL
.iter()
.find(|cap| iface.starts_with(cap.gated_prefix()))
.map_or(WasiGate::Unknown, |&cap| WasiGate::Gated(cap))
}

/// Capability namespaces recognised by enforcement: the core namespace plus
Expand Down Expand Up @@ -181,10 +204,10 @@ pub fn enforce_capabilities<'a>(
if without_version.starts_with("wasi:") && !without_version.starts_with(WASI_HTTP_PREFIX) {
match classify_wasi(import_name) {
WasiGate::Ambient => {}
WasiGate::Gated(cap) if declared.contains(cap) => {}
WasiGate::Gated(cap) if declared.contains(cap.as_str()) => {}
WasiGate::Gated(cap) => {
return Err(CapabilityViolation {
capability: cap.to_owned(),
capability: cap.as_str().to_owned(),
wit_import: import_name.to_owned(),
}
.into());
Expand Down
51 changes: 51 additions & 0 deletions crates/nexum-runtime/src/manifest/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,57 @@ event_signature = "0x00000000000000000000000000000000000000000000000000000000dea
}
}

/// Malformed chain-log hex refuses the manifest at parse, not at first
/// dispatch, with the operator wording pinned verbatim.
#[test]
fn load_refuses_malformed_chain_log_hex_at_parse() {
for (field, detail) in [
(
"address = \"0xabc\"",
"invalid chain-log address \"0xabc\"",
),
(
"event_signature = \"not-a-topic\"",
"invalid topic \"not-a-topic\"",
),
] {
let toml = format!(
"[module]\nname = \"bad\"\n\n[[subscription]]\nkind = \"chain-log\"\n\
chain_id = 1\n{field}\n"
);
let err = toml::from_str::<Manifest>(&toml).expect_err("malformed hex");
assert!(err.to_string().contains(detail), "{err}");
}
}

/// Typing the field must neither widen nor narrow the accepted spelling:
/// `0x`-prefixed or bare, any case, no checksum requirement.
#[test]
fn load_accepts_every_hex_spelling_of_a_chain_log_address() {
let expected: alloy_primitives::Address = "0xc92e8bdf79f0507f65a392b0ab4667716bfe0110"
.parse()
.expect("canonical address");
for spelling in [
"0xC92E8bdf79f0507f65a392b0ab4667716BFE0110",
"0xc92e8bdf79f0507f65a392b0ab4667716bfe0110",
"0xC92E8BDF79F0507F65A392B0AB4667716BFE0110",
"c92e8bdf79f0507f65a392b0ab4667716bfe0110",
] {
let toml = format!(
"[module]\nname = \"ok\"\n\n[[subscription]]\nkind = \"chain-log\"\n\
chain_id = 1\naddress = \"{spelling}\"\n"
);
let manifest: Manifest = toml::from_str(&toml).expect(spelling);
assert!(
matches!(
&manifest.subscriptions[0],
Subscription::ChainLog { address: Some(a), .. } if *a == expected
),
"{spelling} must parse to the canonical address",
);
}
}

#[test]
fn load_parses_the_retired_log_kind_as_an_extension_kind() {
// The chain-event kind is `chain-log`; a stale `kind = "log"`
Expand Down
42 changes: 33 additions & 9 deletions crates/nexum-runtime/src/manifest/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use std::collections::BTreeMap;

use alloy_primitives::{Address, B256};
use serde::Deserialize;
use serde::de::Error as _;

Expand Down Expand Up @@ -51,11 +52,11 @@ pub enum Subscription {
ChainLog {
/// EVM chain id.
chain_id: u64,
/// Contract address as `0x`-prefixed 20-byte hex. Optional.
address: Option<String>,
/// Topic-0 filter as `0x`-prefixed 32-byte hex; absent matches
/// every event from the address(es).
event_signature: Option<String>,
/// Contract address filter, declared as 20-byte hex.
address: Option<Address>,
/// Topic-0 filter, declared as 32-byte hex; absent matches every
/// event from the address(es).
event_signature: Option<B256>,
/// Persist a durable cursor; a restart re-opens AT the cursor block
/// and replays it.
resume: bool,
Expand Down Expand Up @@ -91,10 +92,10 @@ enum CoreSubscription {
#[serde(rename = "chain-log")]
ChainLog {
chain_id: u64,
#[serde(default)]
address: Option<String>,
#[serde(default)]
event_signature: Option<String>,
#[serde(default, deserialize_with = "chain_log_address")]
address: Option<Address>,
#[serde(default, deserialize_with = "chain_log_topic")]
event_signature: Option<B256>,
#[serde(default)]
resume: bool,
#[serde(default)]
Expand All @@ -105,6 +106,29 @@ enum CoreSubscription {
},
}

fn chain_log_address<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Address>, D::Error> {
// Pinned operator wording.
hex_field(d, "invalid chain-log address")
}

fn chain_log_topic<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<B256>, D::Error> {
// Pinned operator wording.
hex_field(d, "invalid topic")
}

/// Refusal lands at manifest load; `label` carries the pinned wording.
fn hex_field<'de, D, T>(d: D, label: &str) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
let raw = String::deserialize(d)?;
raw.parse()
.map(Some)
.map_err(|e| D::Error::custom(format!("{label} {raw:?}: {e}")))
}

impl From<CoreSubscription> for Subscription {
fn from(sub: CoreSubscription) -> Self {
match sub {
Expand Down
21 changes: 11 additions & 10 deletions crates/nexum-runtime/src/supervisor/cursors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use std::collections::BTreeMap;

use alloy_chains::Chain;
use alloy_primitives::{Address, B256, keccak256};
use tracing::warn;

use crate::host::component::{StateHandle, StateStore};
Expand Down Expand Up @@ -126,21 +127,21 @@ pub(super) fn persist_progress_marker<S: StateStore>(
}
}

/// Derived from normalized manifest inputs, not the alloy `Filter` (whose
/// hash is process-randomized), so it is stable across restarts.
/// Keyed on `0x`-prefixed lowercase hex, not the alloy `Filter` (whose hash
/// is process-randomized), so it is stable across a restart and across the
/// typing of the manifest values it was formerly derived from.
pub(super) fn chainlog_cursor_key(
chain: Chain,
address: Option<&str>,
event_signature: Option<&str>,
address: Option<Address>,
event_signature: Option<B256>,
) -> String {
let normalized = format!(
"{}|{}|{}",
chain.id(),
address.unwrap_or("").to_ascii_lowercase(),
event_signature.unwrap_or("").to_ascii_lowercase(),
address.map(|a| format!("{a:#x}")).unwrap_or_default(),
event_signature
.map(|t| format!("{t:#x}"))
.unwrap_or_default(),
);
format!(
"chainlog_cursor:{:x}",
alloy_primitives::keccak256(normalized.as_bytes())
)
format!("chainlog_cursor:{:x}", keccak256(normalized.as_bytes()))
}
20 changes: 2 additions & 18 deletions crates/nexum-runtime/src/supervisor/prepass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use anyhow::{Context, Error, Result, anyhow};
use tracing::{info, warn};

use super::role::Role;
use super::subscriptions::build_alloy_filter;
use crate::engine_config::EngineConfig;
use crate::manifest::{self, CapabilityRegistry, LoadedManifest, Subscription};

Expand Down Expand Up @@ -113,8 +112,8 @@ impl ConfiguredChains {
}
}

/// Refuse any subscription naming a chain absent from `[chains]` or carrying
/// an unparseable chain-log filter, before any guest code runs.
/// Refuse any subscription naming a chain absent from `[chains]`, before any
/// guest code runs.
pub(super) fn enforce_subscriptions(
role: Role,
name: &str,
Expand All @@ -129,21 +128,6 @@ pub(super) fn enforce_subscriptions(
if !chains.contains(*chain_id) {
return Err(unconfigured_chain(role, name, *chain_id, chains));
}
if let Subscription::ChainLog {
address,
event_signature,
..
} = sub
{
build_alloy_filter(address.as_deref(), event_signature.as_deref()).with_context(
|| {
format!(
"{} {name} declares an invalid chain-log filter on chain {chain_id}",
role.claim_role(),
)
},
)?;
}
}
Ok(())
}
Expand Down
51 changes: 9 additions & 42 deletions crates/nexum-runtime/src/supervisor/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,12 @@ impl<T: RuntimeTypes> Supervisor<T> {
resume,
max_lookback,
} => {
let filter =
build_alloy_filter(address.as_deref(), event_signature.as_deref())
.expect("chain-log filters are validated at load");
let filter = build_alloy_filter(*address, *event_signature);
let chain = Chain::from_id(*chain_id);
// A `resume` subscription reads its durable cursor
// once here at boot; others start at head.
let (cursor_key, initial_cursor) = if *resume {
let key = chainlog_cursor_key(
chain,
address.as_deref(),
event_signature.as_deref(),
);
let key = chainlog_cursor_key(chain, *address, *event_signature);
let seed = read_chain_log_cursor(
&self.shared.components.store,
module.name.as_str(),
Expand Down Expand Up @@ -157,44 +151,17 @@ impl From<&alloy_rpc_types_eth::Log> for nexum::host::types::ChainLog {
}
}

#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub(super) enum FilterError {
/// `[[subscriptions]].address` did not parse as an EVM address.
#[error("invalid chain-log address {address:?}: {source}")]
Address {
address: String,
#[source]
source: alloy_primitives::hex::FromHexError,
},
/// `[[subscriptions]].event_signature` did not parse as a 32-byte topic.
#[error("invalid topic {topic:?}: {source}")]
Topic {
topic: String,
#[source]
source: alloy_primitives::hex::FromHexError,
},
}

/// Infallible: the manifest carries typed filter values.
pub(super) fn build_alloy_filter(
address: Option<&str>,
event_signature: Option<&str>,
) -> std::result::Result<alloy_rpc_types_eth::Filter, FilterError> {
use alloy_primitives::{Address, B256};
address: Option<alloy_primitives::Address>,
event_signature: Option<alloy_primitives::B256>,
) -> alloy_rpc_types_eth::Filter {
let mut filter = alloy_rpc_types_eth::Filter::new();
if let Some(addr_hex) = address {
let addr: Address = addr_hex.parse().map_err(|source| FilterError::Address {
address: addr_hex.to_owned(),
source,
})?;
if let Some(addr) = address {
filter = filter.address(addr);
}
if let Some(topic_hex) = event_signature {
let topic: B256 = topic_hex.parse().map_err(|source| FilterError::Topic {
topic: topic_hex.to_owned(),
source,
})?;
if let Some(topic) = event_signature {
filter = filter.event_signature(topic);
}
Ok(filter)
filter
}
Loading
Loading