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:
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"