#[cfg(not(target_arch = "wasm32"))]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::time::Duration;
#[cfg(not(target_arch = "wasm32"))]
use crate::error::{HyperError, HyperResult};
#[cfg(not(target_arch = "wasm32"))]
use crate::verify::TrustProvider;
#[cfg(not(target_arch = "wasm32"))]
const DEFAULT_STORAGE_TEMPLATE: &str = "{data_dir}/{actr_type}";
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone)]
pub struct HyperConfig {
pub data_dir: PathBuf,
pub storage_path_template: String,
pub trust_provider: Arc<dyn TrustProvider>,
pub credential_expiry_warning: Duration,
pub mailbox_backpressure_threshold: Option<usize>,
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD: usize = 1024;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const DEFAULT_CREDENTIAL_EXPIRY_WARNING: Duration = Duration::from_secs(5 * 60);
#[cfg(not(target_arch = "wasm32"))]
impl std::fmt::Debug for HyperConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HyperConfig")
.field("data_dir", &self.data_dir)
.field("storage_path_template", &self.storage_path_template)
.field("trust_provider", &self.trust_provider)
.field("credential_expiry_warning", &self.credential_expiry_warning)
.field(
"mailbox_backpressure_threshold",
&self.mailbox_backpressure_threshold,
)
.finish()
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub(crate) struct HyperSection {
#[serde(default)]
pub data_dir: Option<std::path::PathBuf>,
#[serde(default)]
pub storage_path_template: Option<String>,
#[serde(default)]
pub trust: Option<HyperTrustAnchor>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(crate) enum HyperTrustAnchor {
DevOnly,
Static {
#[serde(default)]
pubkey_file: Option<std::path::PathBuf>,
#[serde(default)]
pubkey_b64: Option<String>,
},
Registry { endpoint: String },
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub(crate) struct HyperSectionWrapper {
#[serde(default)]
pub hyper: HyperSection,
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn node_from_config_file(
path: &Path,
) -> crate::error::HyperResult<crate::Node<crate::Init>> {
use crate::error::HyperError;
use crate::verify::{ChainTrust, RegistryTrust, StaticTrust, TrustProvider};
let raw_text = std::fs::read_to_string(path).map_err(|e| {
HyperError::Config(format!(
"failed to read runtime config `{}`: {e}",
path.display()
))
})?;
let raw_runtime: actr_config::RuntimeRawConfig = raw_text.parse().map_err(|e| {
HyperError::Config(format!(
"failed to parse runtime config `{}`: {e}",
path.display()
))
})?;
let package_info = actr_config::PackageInfo {
name: "client".to_string(),
actr_type: actr_protocol::ActrType {
manufacturer: "local".to_string(),
name: "Client".to_string(),
version: "0.0.0".to_string(),
},
description: None,
authors: vec![],
license: None,
};
let runtime_config = actr_config::ConfigParser::parse_runtime(raw_runtime, path, package_info)
.map_err(|e| HyperError::Config(format!("failed to parse runtime config: {e}")))?;
let hyper_section: HyperSectionWrapper = toml::from_str(&raw_text).map_err(|e| {
HyperError::Config(format!(
"failed to parse [hyper] section of `{}`: {e}",
path.display()
))
})?;
let hyper_section = hyper_section.hyper;
let data_dir = if let Some(dir) = hyper_section.data_dir.clone() {
dir
} else {
actr_config::user_config::resolve_hyper_data_dir().map_err(|e| {
HyperError::Config(format!(
"failed to resolve default hyper data_dir (set `[hyper].data_dir` explicitly): {e}"
))
})?
};
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let trust: Arc<dyn TrustProvider> = if let Some(anchor) = hyper_section.trust.clone() {
match anchor {
HyperTrustAnchor::DevOnly => {
tracing::warn!(
"[hyper.trust] kind = \"dev_only\" selected; accepting any package — \
NEVER use in production"
);
Arc::new(StaticTrust::dev_only())
}
HyperTrustAnchor::Static {
pubkey_file,
pubkey_b64,
} => {
let key_bytes = load_static_pubkey_bytes(
pubkey_file.as_deref().map(|p| resolve_path(base_dir, p)),
pubkey_b64,
)?;
Arc::new(StaticTrust::new(key_bytes)?)
}
HyperTrustAnchor::Registry { endpoint } => {
let base = endpoint.trim_end_matches("/ais").to_string();
Arc::new(RegistryTrust::new(base))
}
}
} else if !runtime_config.trust.is_empty() {
let mut providers: Vec<Arc<dyn TrustProvider>> =
Vec::with_capacity(runtime_config.trust.len());
for anchor in &runtime_config.trust {
let provider: Arc<dyn TrustProvider> = match anchor {
actr_config::TrustAnchor::Static {
pubkey_file,
pubkey_b64,
} => {
let key_bytes =
load_static_pubkey_bytes(pubkey_file.clone(), pubkey_b64.clone())?;
Arc::new(StaticTrust::new(key_bytes)?)
}
actr_config::TrustAnchor::Registry { endpoint } => {
let base = endpoint.trim_end_matches("/ais").to_string();
Arc::new(RegistryTrust::new(base))
}
};
providers.push(provider);
}
if providers.len() == 1 {
providers.into_iter().next().unwrap()
} else {
Arc::new(ChainTrust::new(providers))
}
} else {
return Err(HyperError::Config(
"no `[hyper.trust]` or `[[trust]]` anchor configured. \
Every runtime must declare a package-signature trust policy. \
For dev / tests set `[hyper.trust] kind = \"dev_only\"`; \
for production use `kind = \"static\"` with a `pubkey_file` \
or `kind = \"registry\"` with an AIS endpoint."
.to_string(),
));
};
let mut hyper_config = HyperConfig::new(&data_dir, trust);
if let Some(template) = hyper_section.storage_path_template {
hyper_config = hyper_config.with_storage_template(template);
}
let hyper = crate::Hyper::new(hyper_config).await?;
let _ = &base_dir;
Ok(crate::Node::from_hyper(hyper, runtime_config))
}
#[cfg(not(target_arch = "wasm32"))]
fn resolve_path(base_dir: &Path, path: impl AsRef<Path>) -> std::path::PathBuf {
let p = path.as_ref();
if p.is_absolute() {
p.to_path_buf()
} else {
base_dir.join(p)
}
}
#[cfg(not(target_arch = "wasm32"))]
fn load_static_pubkey_bytes(
pubkey_file: Option<std::path::PathBuf>,
pubkey_b64: Option<String>,
) -> crate::error::HyperResult<Vec<u8>> {
use crate::error::HyperError;
use base64::Engine;
if let Some(b64) = pubkey_b64 {
let bytes = base64::engine::general_purpose::STANDARD
.decode(&b64)
.map_err(|e| HyperError::Config(format!("invalid pubkey_b64: {e}")))?;
if bytes.len() != 32 {
return Err(HyperError::Config(format!(
"pubkey_b64 must decode to 32 bytes, got {}",
bytes.len()
)));
}
return Ok(bytes);
}
let path = pubkey_file.ok_or_else(|| {
HyperError::Config("static trust anchor requires `pubkey_file` or `pubkey_b64`".to_string())
})?;
let text = std::fs::read_to_string(&path).map_err(|e| {
HyperError::Config(format!(
"failed to read pubkey_file `{}`: {e}",
path.display()
))
})?;
let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
HyperError::Config(format!(
"pubkey_file `{}` is not valid JSON: {e}",
path.display()
))
})?;
let b64 = value
.get("public_key")
.and_then(|v| v.as_str())
.ok_or_else(|| {
HyperError::Config(format!(
"pubkey_file `{}` is missing the `public_key` field",
path.display()
))
})?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| {
HyperError::Config(format!(
"pubkey_file `{}` has invalid base64: {e}",
path.display()
))
})?;
if bytes.len() != 32 {
return Err(HyperError::Config(format!(
"pubkey_file `{}` must contain a 32-byte key, got {}",
path.display(),
bytes.len()
)));
}
Ok(bytes)
}
#[cfg(not(target_arch = "wasm32"))]
impl HyperConfig {
pub fn new(data_dir: impl AsRef<Path>, trust_provider: Arc<dyn TrustProvider>) -> Self {
Self {
data_dir: data_dir.as_ref().to_path_buf(),
storage_path_template: DEFAULT_STORAGE_TEMPLATE.to_string(),
trust_provider,
credential_expiry_warning: DEFAULT_CREDENTIAL_EXPIRY_WARNING,
mailbox_backpressure_threshold: None,
}
}
pub fn with_storage_template(mut self, template: impl Into<String>) -> Self {
self.storage_path_template = template.into();
self
}
pub fn with_trust_provider(mut self, trust_provider: Arc<dyn TrustProvider>) -> Self {
self.trust_provider = trust_provider;
self
}
pub fn with_credential_expiry_warning(mut self, window: Duration) -> Self {
self.credential_expiry_warning = window;
self
}
pub fn with_mailbox_backpressure_threshold(mut self, threshold: Option<usize>) -> Self {
self.mailbox_backpressure_threshold = threshold;
self
}
pub fn resolved_mailbox_backpressure_threshold(&self) -> usize {
self.mailbox_backpressure_threshold
.unwrap_or(DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD)
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) struct NamespaceResolver {
vars: HashMap<String, String>,
}
#[cfg(not(target_arch = "wasm32"))]
impl NamespaceResolver {
pub fn new(config: &HyperConfig, instance_id: &str) -> HyperResult<Self> {
let mut vars = HashMap::new();
vars.insert(
"data_dir".to_string(),
config
.data_dir
.to_str()
.ok_or_else(|| {
HyperError::Config("data_dir path contains non-UTF-8 characters".to_string())
})?
.to_string(),
);
vars.insert("instance_id".to_string(), instance_id.to_string());
if let Ok(hostname) = std::env::var("HOSTNAME").or_else(|_| {
std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.map_err(|_| std::env::VarError::NotPresent)
}) {
vars.insert("hostname".to_string(), hostname);
}
Ok(Self { vars })
}
pub fn with_actor_type(mut self, manufacturer: &str, actr_name: &str, version: &str) -> Self {
self.vars
.insert("manufacturer".to_string(), manufacturer.to_string());
self.vars
.insert("actr_name".to_string(), actr_name.to_string());
self.vars.insert("version".to_string(), version.to_string());
self.vars.insert(
"actr_type".to_string(),
format!("{manufacturer}/{actr_name}/{version}"),
);
self
}
#[allow(dead_code)]
pub fn with_realm(mut self, realm_id: u64) -> Self {
self.vars
.insert("realm_id".to_string(), realm_id.to_string());
self
}
pub fn resolve(&self, template: &str) -> HyperResult<PathBuf> {
let mut result = template.to_string();
let env_prefix = "{env.";
let mut pos = 0;
while let Some(start) = result[pos..].find(env_prefix) {
let abs_start = pos + start;
if let Some(end) = result[abs_start..].find('}') {
let var_name = &result[abs_start + env_prefix.len()..abs_start + end];
let value = std::env::var(var_name)
.map_err(|_| HyperError::TemplateVariable(format!("env.{var_name}")))?;
let placeholder = format!("{{env.{var_name}}}");
result = result.replacen(&placeholder, &value, 1);
} else {
pos = abs_start + 1;
}
}
for (key, value) in &self.vars {
result = result.replace(&format!("{{{key}}}"), value);
}
if let Some(start) = result.find('{') {
if let Some(end) = result[start..].find('}') {
let var = &result[start + 1..start + end];
return Err(HyperError::TemplateVariable(var.to_string()));
}
}
Ok(PathBuf::from(result))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::verify::StaticTrust;
fn stub_config(data_dir: &str) -> HyperConfig {
HyperConfig::new(data_dir, Arc::new(StaticTrust::dev_only()))
}
#[test]
fn resolve_basic_template() {
let config = stub_config("/var/lib/actr");
let resolver = NamespaceResolver::new(&config, "abc123")
.unwrap()
.with_actor_type("acme", "Sensor", "1.0.0");
let path = resolver.resolve("{data_dir}/{actr_type}").unwrap();
assert_eq!(path, PathBuf::from("/var/lib/actr/acme/Sensor/1.0.0"));
}
#[test]
fn resolve_missing_var_returns_error() {
let config = stub_config("/tmp");
let resolver = NamespaceResolver::new(&config, "id1").unwrap();
let result = resolver.resolve("{data_dir}/{realm_id}");
assert!(matches!(result, Err(HyperError::TemplateVariable(_))));
}
#[test]
fn resolve_with_realm() {
let config = stub_config("/tmp");
let resolver = NamespaceResolver::new(&config, "id1")
.unwrap()
.with_actor_type("acme", "Worker", "2.0")
.with_realm(42);
let path = resolver
.resolve("{data_dir}/{actr_type}/{realm_id}")
.unwrap();
assert_eq!(path, PathBuf::from("/tmp/acme/Worker/2.0/42"));
}
const BASE_CONFIG_TOML: &str = r#"
edition = 1
[signaling]
url = "ws://localhost:8081/signaling/ws"
[ais_endpoint]
url = "http://localhost:8081/ais"
[deployment]
realm_id = 1
"#;
#[tokio::test]
async fn node_from_config_file_dev_only_succeeds() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("actr.toml");
let data_dir = dir.path().display().to_string().replace('\\', "/");
std::fs::write(
&path,
format!(
"{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
[hyper.trust]\nkind = \"dev_only\"\n"
),
)
.unwrap();
let _node = node_from_config_file(&path)
.await
.expect("dev_only trust should be accepted");
}
#[tokio::test]
async fn node_from_config_file_missing_trust_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("actr.toml");
let data_dir = dir.path().display().to_string().replace('\\', "/");
std::fs::write(
&path,
format!("{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n"),
)
.unwrap();
let result = node_from_config_file(&path).await;
let err = match result {
Ok(_) => panic!("missing trust must fail"),
Err(e) => e,
};
let msg = err.to_string();
assert!(
msg.contains("no `[hyper.trust]`") && msg.contains("dev_only"),
"error should direct user to the dev_only opt-in, got: {msg}"
);
}
#[tokio::test]
async fn node_from_config_file_accepts_top_level_registry_anchor() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("actr.toml");
let data_dir = dir.path().display().to_string().replace('\\', "/");
std::fs::write(
&path,
format!(
"{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
[[trust]]\nkind = \"registry\"\nendpoint = \"http://localhost:8081/ais\"\n"
),
)
.unwrap();
let _node = node_from_config_file(&path)
.await
.expect("top-level [[trust]] registry anchor should be accepted");
}
#[tokio::test]
async fn node_from_config_file_allows_linked_actor_type_override() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("actr.toml");
let data_dir = dir.path().display().to_string().replace('\\', "/");
std::fs::write(
&path,
format!(
"{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
[hyper.trust]\nkind = \"dev_only\"\n"
),
)
.unwrap();
let actor_type = actr_protocol::ActrType {
manufacturer: "acme".to_string(),
name: "EchoApp".to_string(),
version: "0.1.0".to_string(),
};
let node = node_from_config_file(&path)
.await
.expect("dev_only trust should be accepted")
.with_actor_type(actor_type.clone());
assert_eq!(node.runtime_config().actr_type(), &actor_type);
}
}