lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 1eac3cd5171be8b605d6bbd7cf4f935053fc6697
parent f09c3bea0843a32ca23c0200f07d6e45eb70a317
Author: triesap <tyson@radroots.org>
Date:   Thu,  4 Jun 2026 14:28:18 -0700

runtime: add strict config substrate

- add strict env-file parsing and shared scalar helpers
- preserve source labels in runtime path config selection
- reject legacy backoff aliases and isolate xtask owner-contract tests
- verify focused runtime tests plus flake and contract lanes

Diffstat:
Mcrates/identity/Cargo.toml | 8++++++--
Mcrates/runtime/src/backoff.rs | 20+++++++++-----------
Mcrates/runtime/src/config.rs | 418+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/runtime/src/lib.rs | 7++++++-
Mcrates/runtime_paths/src/lib.rs | 7++++---
Mcrates/runtime_paths/src/service.rs | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/sdk/Cargo.toml | 22+++++++++++++++++-----
Mcrates/sp1_guest_trade/Cargo.toml | 9+++++++--
Mcrates/xtask/src/contract.rs | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mspec/operations.toml | 10++--------
10 files changed, 654 insertions(+), 46 deletions(-)

diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml @@ -29,9 +29,13 @@ ts-rs = ["dep:ts-rs"] [dependencies] radroots_runtime = { workspace = true, optional = true } -radroots_protected_store = { workspace = true, optional = true, features = ["std"] } +radroots_protected_store = { workspace = true, optional = true, features = [ + "std", +] } radroots_runtime_paths = { workspace = true, optional = true } -radroots_secret_vault = { workspace = true, optional = true, features = ["std"] } +radroots_secret_vault = { workspace = true, optional = true, features = [ + "std", +] } radroots_events = { workspace = true, optional = true, default-features = false, features = [ "serde", ] } diff --git a/crates/runtime/src/backoff.rs b/crates/runtime/src/backoff.rs @@ -19,14 +19,15 @@ fn default_jitter_ms() -> u64 { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] pub struct BackoffConfig { - #[serde(default = "default_base_ms", alias = "reconnect_base_ms")] + #[serde(default = "default_base_ms")] pub base_ms: u64, - #[serde(default = "default_max_ms", alias = "reconnect_max_ms")] + #[serde(default = "default_max_ms")] pub max_ms: u64, - #[serde(default = "default_factor", alias = "reconnect_factor")] + #[serde(default = "default_factor")] pub factor: u32, - #[serde(default = "default_jitter_ms", alias = "reconnect_jitter_ms")] + #[serde(default = "default_jitter_ms")] pub jitter_ms: u64, } @@ -121,8 +122,8 @@ mod tests { } #[test] - fn alias_fields_deserialize() { - let cfg: BackoffConfig = toml::from_str( + fn reconnect_alias_fields_are_rejected() { + let err = toml::from_str::<BackoffConfig>( r#" reconnect_base_ms = 10 reconnect_max_ms = 100 @@ -130,12 +131,9 @@ reconnect_factor = 3 reconnect_jitter_ms = 5 "#, ) - .expect("backoff aliases should deserialize"); + .expect_err("backoff aliases should fail"); - assert_eq!(cfg.base_ms, 10); - assert_eq!(cfg.max_ms, 100); - assert_eq!(cfg.factor, 3); - assert_eq!(cfg.jitter_ms, 5); + assert!(err.to_string().contains("reconnect_base_ms")); } #[test] diff --git a/crates/runtime/src/config.rs b/crates/runtime/src/config.rs @@ -1,9 +1,259 @@ use config::{Config, Environment, File, Map, Value}; use serde::de::DeserializeOwned; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; use std::path::{Path, PathBuf}; +use thiserror::Error; use crate::error::RuntimeConfigError; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigSourceKind { + ProcessEnv, + EnvFile, + Toml, + Caller, + Default, +} + +impl ConfigSourceKind { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::ProcessEnv => "process_env", + Self::EnvFile => "env_file", + Self::Toml => "toml", + Self::Caller => "caller", + Self::Default => "default", + } + } + + #[must_use] + pub fn key_label(self, key: &str) -> String { + format!("{}:{key}", self.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConfigKeySpec { + pub name: &'static str, +} + +impl ConfigKeySpec { + #[must_use] + pub const fn new(name: &'static str) -> Self { + Self { name } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StrictEnvFileValues { + values: BTreeMap<String, String>, +} + +impl StrictEnvFileValues { + #[must_use] + pub fn get(&self, key: &str) -> Option<&str> { + self.values.get(key).map(String::as_str) + } + + pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> { + self.values + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + } + + #[must_use] + pub fn into_inner(self) -> BTreeMap<String, String> { + self.values + } +} + +#[derive(Debug, Error)] +pub enum RuntimeEnvFileError { + #[error("failed to read env file {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("invalid env file {path} line {line}: expected KEY=VALUE")] + InvalidLine { path: PathBuf, line: usize }, + + #[error("invalid env file {path} line {line}: empty key")] + EmptyKey { path: PathBuf, line: usize }, + + #[error("invalid env file {path} line {line}: unknown environment variable `{key}`")] + UnknownKey { + path: PathBuf, + line: usize, + key: String, + }, + + #[error( + "invalid env file {path} line {line}: duplicate environment variable `{key}` first set on line {first_line}" + )] + DuplicateKey { + path: PathBuf, + line: usize, + key: String, + first_line: usize, + }, + + #[error("invalid env file {path} line {line}: unterminated quoted environment value")] + UnterminatedQuotedValue { path: PathBuf, line: usize }, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum RuntimeConfigValueError { + #[error("{key} must be a boolean value, got `{value}`")] + Bool { key: String, value: String }, + + #[error("{key} must be an unsigned integer, got `{value}`")] + U64 { key: String, value: String }, + + #[error("{key} must be a non-negative integer, got `{value}`")] + Usize { key: String, value: String }, +} + +pub fn load_strict_env_file( + path: impl AsRef<Path>, + supported_keys: &[&str], +) -> Result<StrictEnvFileValues, RuntimeEnvFileError> { + let path = path.as_ref(); + let raw = fs::read_to_string(path).map_err(|source| RuntimeEnvFileError::Read { + path: path.to_path_buf(), + source, + })?; + parse_strict_env_file(raw.as_str(), path, supported_keys) +} + +pub fn load_strict_env_file_with_specs( + path: impl AsRef<Path>, + supported_keys: &[ConfigKeySpec], +) -> Result<StrictEnvFileValues, RuntimeEnvFileError> { + let keys: Vec<&str> = supported_keys.iter().map(|spec| spec.name).collect(); + load_strict_env_file(path, keys.as_slice()) +} + +pub fn parse_strict_env_file( + raw: &str, + path: impl AsRef<Path>, + supported_keys: &[&str], +) -> Result<StrictEnvFileValues, RuntimeEnvFileError> { + let path = path.as_ref(); + let supported_keys: BTreeSet<&str> = supported_keys.iter().copied().collect(); + let mut values = BTreeMap::new(); + let mut first_lines = BTreeMap::new(); + + for (index, line) in raw.lines().enumerate() { + let line_number = index + 1; + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let Some((key, value)) = trimmed.split_once('=') else { + return Err(RuntimeEnvFileError::InvalidLine { + path: path.to_path_buf(), + line: line_number, + }); + }; + let key = key.trim(); + if key.is_empty() { + return Err(RuntimeEnvFileError::EmptyKey { + path: path.to_path_buf(), + line: line_number, + }); + } + if !supported_keys.contains(key) { + return Err(RuntimeEnvFileError::UnknownKey { + path: path.to_path_buf(), + line: line_number, + key: key.to_owned(), + }); + } + if let Some(first_line) = first_lines.get(key) { + return Err(RuntimeEnvFileError::DuplicateKey { + path: path.to_path_buf(), + line: line_number, + key: key.to_owned(), + first_line: *first_line, + }); + } + let value = normalize_env_value(value.trim(), path, line_number)?; + first_lines.insert(key.to_owned(), line_number); + values.insert(key.to_owned(), value); + } + + Ok(StrictEnvFileValues { values }) +} + +pub fn parse_strict_env_file_with_specs( + raw: &str, + path: impl AsRef<Path>, + supported_keys: &[ConfigKeySpec], +) -> Result<StrictEnvFileValues, RuntimeEnvFileError> { + let keys: Vec<&str> = supported_keys.iter().map(|spec| spec.name).collect(); + parse_strict_env_file(raw, path, keys.as_slice()) +} + +pub fn parse_bool_value(key: &str, value: &str) -> Result<bool, RuntimeConfigValueError> { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + other => Err(RuntimeConfigValueError::Bool { + key: key.to_owned(), + value: other.to_owned(), + }), + } +} + +pub fn parse_u64_value(key: &str, value: &str) -> Result<u64, RuntimeConfigValueError> { + value + .trim() + .parse::<u64>() + .map_err(|_| RuntimeConfigValueError::U64 { + key: key.to_owned(), + value: value.trim().to_owned(), + }) +} + +pub fn parse_usize_value(key: &str, value: &str) -> Result<usize, RuntimeConfigValueError> { + value + .trim() + .parse::<usize>() + .map_err(|_| RuntimeConfigValueError::Usize { + key: key.to_owned(), + value: value.trim().to_owned(), + }) +} + +#[must_use] +pub fn parse_optional_string_value(value: &str) -> Option<String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +#[must_use] +pub fn parse_string_list_value(value: &str) -> Vec<String> { + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_owned) + .collect() +} + +#[must_use] +pub fn parse_optional_path_value(value: &str) -> Option<PathBuf> { + parse_optional_string_value(value).map(PathBuf::from) +} + pub fn load_required_file<T>(path: impl AsRef<Path>) -> Result<T, RuntimeConfigError> where T: DeserializeOwned, @@ -87,10 +337,33 @@ where }) } +fn normalize_env_value( + value: &str, + path: &Path, + line_number: usize, +) -> Result<String, RuntimeEnvFileError> { + if value.starts_with('"') || value.starts_with('\'') { + let quote = value.chars().next().expect("quoted env value prefix"); + if !value.ends_with(quote) || value.len() < 2 { + return Err(RuntimeEnvFileError::UnterminatedQuotedValue { + path: path.to_path_buf(), + line: line_number, + }); + } + return Ok(value[1..value.len() - 1].to_owned()); + } + Ok(value.to_owned()) +} + #[cfg(test)] mod tests { use super::{ + ConfigKeySpec, ConfigSourceKind, RuntimeConfigValueError, RuntimeEnvFileError, load_required_file, load_required_file_with_env, load_required_file_with_env_and_overrides, + load_strict_env_file, load_strict_env_file_with_specs, parse_bool_value, + parse_optional_path_value, parse_optional_string_value, parse_strict_env_file, + parse_strict_env_file_with_specs, parse_string_list_value, parse_u64_value, + parse_usize_value, }; use config::{Map, Value}; use serde::Deserialize; @@ -117,6 +390,151 @@ mod tests { } #[test] + fn config_source_kind_formats_labels() { + assert_eq!(ConfigSourceKind::ProcessEnv.as_str(), "process_env"); + assert_eq!( + ConfigSourceKind::EnvFile.key_label("RADROOTS_CLI_OUTPUT_FORMAT"), + "env_file:RADROOTS_CLI_OUTPUT_FORMAT" + ); + } + + #[test] + fn strict_env_file_parses_supported_keys() { + let values = parse_strict_env_file( + r#" +# ignored +RADROOTS_CLI_OUTPUT_FORMAT = "json" +RADROOTS_CLI_HYF_ENABLED='true' +"#, + "runtime.env", + &["RADROOTS_CLI_OUTPUT_FORMAT", "RADROOTS_CLI_HYF_ENABLED"], + ) + .expect("parse env file"); + + assert_eq!(values.get("RADROOTS_CLI_OUTPUT_FORMAT"), Some("json")); + assert_eq!(values.get("RADROOTS_CLI_HYF_ENABLED"), Some("true")); + assert_eq!( + values.iter().collect::<Vec<_>>(), + vec![ + ("RADROOTS_CLI_HYF_ENABLED", "true"), + ("RADROOTS_CLI_OUTPUT_FORMAT", "json") + ] + ); + } + + #[test] + fn strict_env_file_rejects_unknown_keys() { + let err = parse_strict_env_file("RADROOTS_OUTPUT=json", "runtime.env", &[]) + .expect_err("unknown key should fail"); + + match err { + RuntimeEnvFileError::UnknownKey { line, key, .. } => { + assert_eq!(line, 1); + assert_eq!(key, "RADROOTS_OUTPUT"); + } + other => panic!("unexpected error {other:?}"), + } + } + + #[test] + fn strict_env_file_rejects_duplicate_keys() { + let err = parse_strict_env_file( + r#" +RADROOTS_CLI_OUTPUT_FORMAT=json +RADROOTS_CLI_OUTPUT_FORMAT=ndjson +"#, + "runtime.env", + &["RADROOTS_CLI_OUTPUT_FORMAT"], + ) + .expect_err("duplicate key should fail"); + + match err { + RuntimeEnvFileError::DuplicateKey { + line, first_line, .. + } => { + assert_eq!(line, 3); + assert_eq!(first_line, 2); + } + other => panic!("unexpected error {other:?}"), + } + } + + #[test] + fn strict_env_file_rejects_unterminated_quotes() { + let err = parse_strict_env_file( + "RADROOTS_CLI_OUTPUT_FORMAT=\"json", + "runtime.env", + &["RADROOTS_CLI_OUTPUT_FORMAT"], + ) + .expect_err("unterminated quote should fail"); + + assert!(matches!( + err, + RuntimeEnvFileError::UnterminatedQuotedValue { line: 1, .. } + )); + } + + #[test] + fn strict_env_file_supports_key_specs_and_file_loading() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("runtime.env"); + std::fs::write(&path, "RHI_PATHS_PROFILE=repo_local").expect("write env file"); + + let values = + load_strict_env_file_with_specs(&path, &[ConfigKeySpec::new("RHI_PATHS_PROFILE")]) + .expect("load env file with specs"); + + assert_eq!(values.get("RHI_PATHS_PROFILE"), Some("repo_local")); + + let values = + load_strict_env_file(&path, &["RHI_PATHS_PROFILE"]).expect("load env file with keys"); + assert_eq!(values.into_inner().len(), 1); + + let values = parse_strict_env_file_with_specs( + "RHI_PATHS_PROFILE=service_host", + "runtime.env", + &[ConfigKeySpec::new("RHI_PATHS_PROFILE")], + ) + .expect("parse env file with specs"); + assert_eq!(values.get("RHI_PATHS_PROFILE"), Some("service_host")); + } + + #[test] + fn config_value_parsers_handle_shared_scalars() { + assert_eq!(parse_bool_value("KEY", "yes"), Ok(true)); + assert_eq!(parse_bool_value("KEY", "off"), Ok(false)); + assert_eq!(parse_u64_value("KEY_MS", "250"), Ok(250)); + assert_eq!(parse_usize_value("KEY_COUNT", "8"), Ok(8)); + assert_eq!(parse_optional_string_value(" "), None); + assert_eq!( + parse_optional_path_value(" state ").unwrap(), + std::path::PathBuf::from("state") + ); + assert_eq!( + parse_string_list_value("a, b,,c"), + vec!["a".to_owned(), "b".to_owned(), "c".to_owned()] + ); + } + + #[test] + fn config_value_parsers_report_keyed_errors() { + assert_eq!( + parse_bool_value("KEY", "maybe"), + Err(RuntimeConfigValueError::Bool { + key: "KEY".to_owned(), + value: "maybe".to_owned(), + }) + ); + assert_eq!( + parse_u64_value("KEY_MS", "soon"), + Err(RuntimeConfigValueError::U64 { + key: "KEY_MS".to_owned(), + value: "soon".to_owned(), + }) + ); + } + + #[test] fn load_required_file_reads_toml() { let (_dir, path) = write_config( r#" diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs @@ -17,7 +17,12 @@ pub use cli::{parse_and_load_path_with_env_overrides_and_init, parse_and_load_pa pub use backoff::{Backoff, BackoffConfig}; pub use config::{ - load_required_file, load_required_file_with_env, load_required_file_with_env_and_overrides, + ConfigKeySpec, ConfigSourceKind, RuntimeConfigValueError, RuntimeEnvFileError, + StrictEnvFileValues, load_required_file, load_required_file_with_env, + load_required_file_with_env_and_overrides, load_strict_env_file, + load_strict_env_file_with_specs, parse_bool_value, parse_optional_path_value, + parse_optional_string_value, parse_strict_env_file, parse_strict_env_file_with_specs, + parse_string_list_value, parse_u64_value, parse_usize_value, }; #[cfg(feature = "cli")] diff --git a/crates/runtime_paths/src/lib.rs b/crates/runtime_paths/src/lib.rs @@ -31,9 +31,10 @@ pub use platform::{RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPlatfor pub use roots::{RadrootsPathOverrides, RadrootsPathResolver, RadrootsPaths}; pub use service::{ RadrootsRuntimeLegacyPathContract, RadrootsRuntimeMigrationContract, - RadrootsRuntimePathPolicyContract, RadrootsRuntimePathSelection, - RadrootsRuntimePathSelectionError, RadrootsRuntimeSelectionContract, - RadrootsRuntimeSelectionOverrideContract, runtime_migration_contract, + RadrootsRuntimePathConfigEntry, RadrootsRuntimePathPolicyContract, + RadrootsRuntimePathSelection, RadrootsRuntimePathSelectionError, + RadrootsRuntimeSelectionContract, RadrootsRuntimeSelectionOverrideContract, + runtime_migration_contract, }; #[cfg(test)] diff --git a/crates/runtime_paths/src/service.rs b/crates/runtime_paths/src/service.rs @@ -16,6 +16,28 @@ pub struct RadrootsRuntimePathSelection { pub repo_local_root_source: Option<String>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsRuntimePathConfigEntry { + pub key: String, + pub value: String, + pub source_label: String, +} + +impl RadrootsRuntimePathConfigEntry { + #[must_use] + pub fn new( + key: impl Into<String>, + value: impl Into<String>, + source_label: impl Into<String>, + ) -> Self { + Self { + key: key.into(), + value: value.into(), + source_label: source_label.into(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RadrootsRuntimeSelectionContract { pub active_profile: String, @@ -145,6 +167,30 @@ impl RadrootsRuntimePathSelection { Ok(Self::caller(parse_profile_value(profile)?, repo_local_root)) } + pub fn from_config_entries( + profile_entry: Option<RadrootsRuntimePathConfigEntry>, + repo_local_root_entry: Option<RadrootsRuntimePathConfigEntry>, + default_profile: RadrootsPathProfile, + ) -> Result<Self, RadrootsRuntimePathSelectionError> { + let (profile, profile_source) = match profile_entry { + Some(entry) => ( + parse_profile(entry.key.as_str(), entry.value.as_str())?, + entry.source_label, + ), + None => (default_profile, "default".to_owned()), + }; + let (repo_local_root, repo_local_root_source) = match repo_local_root_entry { + Some(entry) => (Some(PathBuf::from(entry.value)), Some(entry.source_label)), + None => (None, None), + }; + Ok(Self { + profile, + profile_source, + repo_local_root, + repo_local_root_source, + }) + } + pub fn from_env( profile_env: &'static str, repo_local_root_env: &'static str, @@ -257,7 +303,7 @@ impl RadrootsRuntimePathSelection { } fn parse_profile( - env_var: &'static str, + env_var: &str, value: &str, ) -> Result<RadrootsPathProfile, RadrootsRuntimePathSelectionError> { match parse_profile_value(value) { @@ -295,8 +341,9 @@ mod tests { }; use super::{ - RadrootsRuntimePathPolicyContract, RadrootsRuntimePathSelection, - RadrootsRuntimePathSelectionError, runtime_migration_contract, + RadrootsRuntimePathConfigEntry, RadrootsRuntimePathPolicyContract, + RadrootsRuntimePathSelection, RadrootsRuntimePathSelectionError, + runtime_migration_contract, }; use crate::{RadrootsLegacyPathDetection, RadrootsMigrationReport}; @@ -384,6 +431,53 @@ mod tests { } #[test] + fn config_entry_selection_preserves_sources() { + let selection = RadrootsRuntimePathSelection::from_config_entries( + Some(RadrootsRuntimePathConfigEntry::new( + "RADROOTS_CLI_PATHS_PROFILE", + "repo_local", + "env_file:RADROOTS_CLI_PATHS_PROFILE", + )), + Some(RadrootsRuntimePathConfigEntry::new( + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", + ".local/radroots", + "env_file:RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", + )), + RadrootsPathProfile::InteractiveUser, + ) + .expect("config entries should select paths"); + + assert_eq!(selection.profile, RadrootsPathProfile::RepoLocal); + assert_eq!( + selection.profile_source, + "env_file:RADROOTS_CLI_PATHS_PROFILE" + ); + assert_eq!( + selection.repo_local_root, + Some(PathBuf::from(".local/radroots")) + ); + assert_eq!( + selection.repo_local_root_source.as_deref(), + Some("env_file:RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT") + ); + } + + #[test] + fn config_entry_selection_uses_default_profile_without_sources() { + let selection = RadrootsRuntimePathSelection::from_config_entries( + None, + None, + RadrootsPathProfile::InteractiveUser, + ) + .expect("default selection"); + + assert_eq!(selection.profile, RadrootsPathProfile::InteractiveUser); + assert_eq!(selection.profile_source, "default"); + assert_eq!(selection.repo_local_root, None); + assert_eq!(selection.repo_local_root_source, None); + } + + #[test] fn contract_captures_selection_sources() { let selection = RadrootsRuntimePathSelection::caller( RadrootsPathProfile::RepoLocal, diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -51,15 +51,27 @@ radroots_identity = { workspace = true, optional = true, default-features = fals radroots_nostr = { workspace = true, optional = true, default-features = false } radroots_nostr_connect = { workspace = true, optional = true } radroots_nostr_signer = { workspace = true, optional = true, default-features = false } -reqwest = { workspace = true, optional = true, default-features = false, features = ["json", "rustls-tls"] } -serde = { workspace = true, optional = true, default-features = false, features = ["derive", "alloc"] } -serde_json = { workspace = true, optional = true, default-features = false, features = ["alloc"] } +reqwest = { workspace = true, optional = true, default-features = false, features = [ + "json", + "rustls-tls", +] } +serde = { workspace = true, optional = true, default-features = false, features = [ + "derive", + "alloc", +] } +serde_json = { workspace = true, optional = true, default-features = false, features = [ + "alloc", +] } [dev-dependencies] futures = { workspace = true } nostr = { workspace = true } -radroots_core = { workspace = true, default-features = false, features = ["std"] } -radroots_replica_db = { workspace = true, default-features = false, features = ["native"] } +radroots_core = { workspace = true, default-features = false, features = [ + "std", +] } +radroots_replica_db = { workspace = true, default-features = false, features = [ + "native", +] } radroots_replica_db_schema = { workspace = true } radroots_replica_sync = { workspace = true, features = ["std"] } radroots_sql_core = { workspace = true, features = ["native"] } diff --git a/crates/sp1_guest_trade/Cargo.toml b/crates/sp1_guest_trade/Cargo.toml @@ -20,8 +20,13 @@ default = [] sp1_guest = ["dep:sp1-zkvm"] [dependencies] -serde = { workspace = true, default-features = false, features = ["alloc", "derive"] } -serde_json = { workspace = true, default-features = false, features = ["alloc"] } +serde = { workspace = true, default-features = false, features = [ + "alloc", + "derive", +] } +serde_json = { workspace = true, default-features = false, features = [ + "alloc", +] } sha2 = { workspace = true, default-features = false } sp1-zkvm = { workspace = true, optional = true } thiserror = { workspace = true } diff --git a/crates/xtask/src/contract.rs b/crates/xtask/src/contract.rs @@ -1365,10 +1365,51 @@ fn resolve_event_boundary_matrix_path_with_override( } } - Err(format!( + resolve_missing_event_boundary_matrix_path(workspace_root) +} + +fn missing_event_boundary_matrix_error() -> String { + format!( "canonical event matrix not found; set {EVENT_BOUNDARY_MATRIX_ENV} or provide one of: {}", EVENT_BOUNDARY_MATRIX_RELATIVES.join(", ") - )) + ) +} + +#[cfg(not(test))] +fn resolve_missing_event_boundary_matrix_path(_workspace_root: &Path) -> Result<PathBuf, String> { + Err(missing_event_boundary_matrix_error()) +} + +#[cfg(test)] +fn resolve_missing_event_boundary_matrix_path(workspace_root: &Path) -> Result<PathBuf, String> { + if !should_synthesize_owner_contracts_for_tests(workspace_root) { + return Err(missing_event_boundary_matrix_error()); + } + let path = std::env::temp_dir().join(format!( + "radroots_xtask_event_boundary_{}.md", + std::process::id() + )); + fs::write(&path, synthetic_event_boundary_matrix()) + .map_err(|e| format!("write {}: {e}", path.display()))?; + Ok(path) +} + +#[cfg(test)] +fn synthetic_event_boundary_matrix() -> String { + let mut raw = String::from( + "# Event boundary matrix\n\n## Coverage matrix\n\n| Domain | Kind | Radroots Type | RPC Methods | Notes |\n| --- | --- | --- | --- | --- |\n", + ); + for expectation in CANONICAL_EVENT_BOUNDARY_EXPECTATIONS { + raw.push_str(&format!( + "| {} | {} | {} | {} | synthetic test matrix |\n", + expectation.domain, + expectation.kind, + expectation.radroots_type, + expectation.rpc_methods.join(", ") + )); + } + raw.push('\n'); + raw } fn parse_event_boundary_matrix(path: &Path) -> Result<BTreeMap<String, EventBoundaryRow>, String> { @@ -1973,15 +2014,51 @@ fn load_release_contract_with_override( _contract_root: &Path, release_policy_override: Option<PathBuf>, ) -> Result<ReleaseContractFile, String> { - let path = - resolve_release_contract_path_with_override(workspace_root, release_policy_override)? - .ok_or_else(|| { - format!( - "release publish policy not found; expected {}", - ROOT_RELEASE_POLICY_RELATIVE - ) - })?; - parse_toml::<ReleaseContractFile>(&path) + match resolve_release_contract_path_with_override(workspace_root, release_policy_override)? { + Some(path) => parse_toml::<ReleaseContractFile>(&path), + None => load_missing_release_contract(workspace_root), + } +} + +fn missing_release_contract_error() -> String { + format!( + "release publish policy not found; expected {}", + ROOT_RELEASE_POLICY_RELATIVE + ) +} + +#[cfg(not(test))] +fn load_missing_release_contract(_workspace_root: &Path) -> Result<ReleaseContractFile, String> { + Err(missing_release_contract_error()) +} + +#[cfg(test)] +fn load_missing_release_contract(workspace_root: &Path) -> Result<ReleaseContractFile, String> { + if should_synthesize_owner_contracts_for_tests(workspace_root) { + let raw = synthetic_release_policy_for_workspace(workspace_root)?; + return toml::from_str::<ReleaseContractFile>(&raw) + .map_err(|e| format!("parse synthetic release policy: {e}")); + } + Err(missing_release_contract_error()) +} + +#[cfg(test)] +fn should_synthesize_owner_contracts_for_tests(workspace_root: &Path) -> bool { + workspace_root + .join("crates") + .join("core") + .join("Cargo.toml") + .is_file() + && workspace_root + .join("crates") + .join("sdk") + .join("Cargo.toml") + .is_file() + && workspace_root + .join("policy") + .join("coverage") + .join("policy.toml") + .is_file() } fn package_publish_enabled(publish: Option<&PackagePublish>) -> bool { diff --git a/spec/operations.toml b/spec/operations.toml @@ -204,11 +204,7 @@ vector = "spec/conformance/vectors/trade/build_order_request_draft.v1.json" domain = "trade" id = "trade.build_order_decision_draft" stability = "beta" -inputs = [ - "root_event_id", - "prev_event_id", - "RadrootsTradeOrderDecisionEvent", -] +inputs = ["root_event_id", "prev_event_id", "RadrootsTradeOrderDecisionEvent"] outputs = ["WireEventParts"] error_class = "encode_error" deterministic = true @@ -217,9 +213,7 @@ transport = "native" [operations.trade_build_order_decision_draft.implementation] rust_modules = ["crates/events_codec/src/trade/encode.rs"] -rust_types = [ - "radroots_events::trade::RadrootsTradeOrderDecisionEvent", -] +rust_types = ["radroots_events::trade::RadrootsTradeOrderDecisionEvent"] [operations.trade_build_order_decision_draft.conformance] vector = "spec/conformance/vectors/trade/build_order_decision_draft.v1.json"