1#[cfg(not(target_arch = "wasm32"))]
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4#[cfg(not(target_arch = "wasm32"))]
5use std::sync::Arc;
6#[cfg(not(target_arch = "wasm32"))]
7use std::time::Duration;
8
9#[cfg(not(target_arch = "wasm32"))]
10use crate::error::{HyperError, HyperResult};
11#[cfg(not(target_arch = "wasm32"))]
12use crate::verify::TrustProvider;
13
14#[cfg(not(target_arch = "wasm32"))]
16const DEFAULT_STORAGE_TEMPLATE: &str = "{data_dir}/{actr_type}";
17
18#[cfg(not(target_arch = "wasm32"))]
20#[derive(Clone)]
21pub struct HyperConfig {
22 pub data_dir: PathBuf,
24
25 pub storage_path_template: String,
38
39 pub trust_provider: Arc<dyn TrustProvider>,
44
45 pub credential_expiry_warning: Duration,
50
51 pub mailbox_backpressure_threshold: Option<usize>,
62}
63
64#[cfg(not(target_arch = "wasm32"))]
71pub(crate) const DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD: usize = 1024;
72
73#[cfg(not(target_arch = "wasm32"))]
75pub(crate) const DEFAULT_CREDENTIAL_EXPIRY_WARNING: Duration = Duration::from_secs(5 * 60);
76
77#[cfg(not(target_arch = "wasm32"))]
78impl std::fmt::Debug for HyperConfig {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.debug_struct("HyperConfig")
81 .field("data_dir", &self.data_dir)
82 .field("storage_path_template", &self.storage_path_template)
83 .field("trust_provider", &self.trust_provider)
84 .field("credential_expiry_warning", &self.credential_expiry_warning)
85 .field(
86 "mailbox_backpressure_threshold",
87 &self.mailbox_backpressure_threshold,
88 )
89 .finish()
90 }
91}
92
93#[cfg(not(target_arch = "wasm32"))]
100#[derive(Debug, Clone, Default, serde::Deserialize)]
101pub(crate) struct HyperSection {
102 #[serde(default)]
104 pub data_dir: Option<std::path::PathBuf>,
105
106 #[serde(default)]
108 pub storage_path_template: Option<String>,
109
110 #[serde(default)]
113 pub trust: Option<HyperTrustAnchor>,
114}
115
116#[cfg(not(target_arch = "wasm32"))]
121#[derive(Debug, Clone, serde::Deserialize)]
122#[serde(tag = "kind", rename_all = "snake_case")]
123pub(crate) enum HyperTrustAnchor {
124 DevOnly,
129 Static {
132 #[serde(default)]
133 pubkey_file: Option<std::path::PathBuf>,
134 #[serde(default)]
135 pubkey_b64: Option<String>,
136 },
137 Registry { endpoint: String },
140}
141
142#[cfg(not(target_arch = "wasm32"))]
145#[derive(Debug, Clone, Default, serde::Deserialize)]
146pub(crate) struct HyperSectionWrapper {
147 #[serde(default)]
148 pub hyper: HyperSection,
149}
150
151#[cfg(not(target_arch = "wasm32"))]
162pub(crate) async fn node_from_config_file(
163 path: &Path,
164) -> crate::error::HyperResult<crate::Node<crate::Init>> {
165 node_from_config_file_with_package(path, None).await
166}
167
168#[cfg(not(target_arch = "wasm32"))]
174pub(crate) async fn node_from_config_file_with_package(
175 path: &Path,
176 package_info: Option<actr_config::PackageInfo>,
177) -> crate::error::HyperResult<crate::Node<crate::Init>> {
178 use crate::error::HyperError;
179 use crate::verify::{ChainTrust, RegistryTrust, StaticTrust, TrustProvider};
180
181 let raw_text = std::fs::read_to_string(path).map_err(|e| {
184 HyperError::Config(format!(
185 "failed to read runtime config `{}`: {e}",
186 path.display()
187 ))
188 })?;
189
190 let raw_runtime: actr_config::RuntimeRawConfig = raw_text.parse().map_err(|e| {
191 HyperError::Config(format!(
192 "failed to parse runtime config `{}`: {e}",
193 path.display()
194 ))
195 })?;
196 let package_info = package_info.unwrap_or_else(|| actr_config::PackageInfo {
202 name: "client".to_string(),
203 actr_type: actr_protocol::ActrType {
204 manufacturer: "local".to_string(),
205 name: "Client".to_string(),
206 version: "0.0.0".to_string(),
207 },
208 description: None,
209 authors: vec![],
210 license: None,
211 });
212 let runtime_config = actr_config::ConfigParser::parse_runtime(raw_runtime, path, package_info)
213 .map_err(|e| HyperError::Config(format!("failed to parse runtime config: {e}")))?;
214
215 let hyper_section: HyperSectionWrapper = toml::from_str(&raw_text).map_err(|e| {
217 HyperError::Config(format!(
218 "failed to parse [hyper] section of `{}`: {e}",
219 path.display()
220 ))
221 })?;
222 let hyper_section = hyper_section.hyper;
223
224 let data_dir = if let Some(dir) = hyper_section.data_dir.clone() {
226 dir
227 } else {
228 actr_config::user_config::resolve_hyper_data_dir().map_err(|e| {
229 HyperError::Config(format!(
230 "failed to resolve default hyper data_dir (set `[hyper].data_dir` explicitly): {e}"
231 ))
232 })?
233 };
234
235 let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
237 let trust: Arc<dyn TrustProvider> = if let Some(anchor) = hyper_section.trust.clone() {
238 match anchor {
239 HyperTrustAnchor::DevOnly => {
240 tracing::warn!(
241 "[hyper.trust] kind = \"dev_only\" selected; accepting any package — \
242 NEVER use in production"
243 );
244 Arc::new(StaticTrust::dev_only())
245 }
246 HyperTrustAnchor::Static {
247 pubkey_file,
248 pubkey_b64,
249 } => {
250 let key_bytes = load_static_pubkey_bytes(
251 pubkey_file.as_deref().map(|p| resolve_path(base_dir, p)),
252 pubkey_b64,
253 )?;
254 Arc::new(StaticTrust::new(key_bytes)?)
255 }
256 HyperTrustAnchor::Registry { endpoint } => {
257 let base = endpoint.trim_end_matches("/ais").to_string();
258 Arc::new(RegistryTrust::new(base))
259 }
260 }
261 } else if !runtime_config.trust.is_empty() {
262 let mut providers: Vec<Arc<dyn TrustProvider>> =
264 Vec::with_capacity(runtime_config.trust.len());
265 for anchor in &runtime_config.trust {
266 let provider: Arc<dyn TrustProvider> = match anchor {
267 actr_config::TrustAnchor::Static {
268 pubkey_file,
269 pubkey_b64,
270 } => {
271 let key_bytes =
272 load_static_pubkey_bytes(pubkey_file.clone(), pubkey_b64.clone())?;
273 Arc::new(StaticTrust::new(key_bytes)?)
274 }
275 actr_config::TrustAnchor::Registry { endpoint } => {
276 let base = endpoint.trim_end_matches("/ais").to_string();
277 Arc::new(RegistryTrust::new(base))
278 }
279 };
280 providers.push(provider);
281 }
282 if providers.len() == 1 {
283 providers.into_iter().next().unwrap()
284 } else {
285 Arc::new(ChainTrust::new(providers))
286 }
287 } else {
288 return Err(HyperError::Config(
289 "no `[hyper.trust]` or `[[trust]]` anchor configured. \
290 Every runtime must declare a package-signature trust policy. \
291 For dev / tests set `[hyper.trust] kind = \"dev_only\"`; \
292 for production use `kind = \"static\"` with a `pubkey_file` \
293 or `kind = \"registry\"` with an AIS endpoint."
294 .to_string(),
295 ));
296 };
297
298 let mut hyper_config = HyperConfig::new(&data_dir, trust);
300 if let Some(template) = hyper_section.storage_path_template {
301 hyper_config = hyper_config.with_storage_template(template);
302 }
303
304 let hyper = crate::Hyper::new(hyper_config).await?;
309 let _ = &base_dir;
310 Ok(crate::Node::from_hyper(hyper, runtime_config))
311}
312
313#[cfg(not(target_arch = "wasm32"))]
314fn resolve_path(base_dir: &Path, path: impl AsRef<Path>) -> std::path::PathBuf {
315 let p = path.as_ref();
316 if p.is_absolute() {
317 p.to_path_buf()
318 } else {
319 base_dir.join(p)
320 }
321}
322
323#[cfg(not(target_arch = "wasm32"))]
324fn load_static_pubkey_bytes(
325 pubkey_file: Option<std::path::PathBuf>,
326 pubkey_b64: Option<String>,
327) -> crate::error::HyperResult<Vec<u8>> {
328 use crate::error::HyperError;
329 use base64::Engine;
330
331 if let Some(b64) = pubkey_b64 {
332 let bytes = base64::engine::general_purpose::STANDARD
333 .decode(&b64)
334 .map_err(|e| HyperError::Config(format!("invalid pubkey_b64: {e}")))?;
335 if bytes.len() != 32 {
336 return Err(HyperError::Config(format!(
337 "pubkey_b64 must decode to 32 bytes, got {}",
338 bytes.len()
339 )));
340 }
341 return Ok(bytes);
342 }
343 let path = pubkey_file.ok_or_else(|| {
344 HyperError::Config("static trust anchor requires `pubkey_file` or `pubkey_b64`".to_string())
345 })?;
346 let text = std::fs::read_to_string(&path).map_err(|e| {
347 HyperError::Config(format!(
348 "failed to read pubkey_file `{}`: {e}",
349 path.display()
350 ))
351 })?;
352 let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
353 HyperError::Config(format!(
354 "pubkey_file `{}` is not valid JSON: {e}",
355 path.display()
356 ))
357 })?;
358 let b64 = value
359 .get("public_key")
360 .and_then(|v| v.as_str())
361 .ok_or_else(|| {
362 HyperError::Config(format!(
363 "pubkey_file `{}` is missing the `public_key` field",
364 path.display()
365 ))
366 })?;
367 let bytes = base64::engine::general_purpose::STANDARD
368 .decode(b64)
369 .map_err(|e| {
370 HyperError::Config(format!(
371 "pubkey_file `{}` has invalid base64: {e}",
372 path.display()
373 ))
374 })?;
375 if bytes.len() != 32 {
376 return Err(HyperError::Config(format!(
377 "pubkey_file `{}` must contain a 32-byte key, got {}",
378 path.display(),
379 bytes.len()
380 )));
381 }
382 Ok(bytes)
383}
384
385#[cfg(not(target_arch = "wasm32"))]
386impl HyperConfig {
387 pub fn new(data_dir: impl AsRef<Path>, trust_provider: Arc<dyn TrustProvider>) -> Self {
393 Self {
394 data_dir: data_dir.as_ref().to_path_buf(),
395 storage_path_template: DEFAULT_STORAGE_TEMPLATE.to_string(),
396 trust_provider,
397 credential_expiry_warning: DEFAULT_CREDENTIAL_EXPIRY_WARNING,
398 mailbox_backpressure_threshold: None,
399 }
400 }
401
402 pub fn with_storage_template(mut self, template: impl Into<String>) -> Self {
403 self.storage_path_template = template.into();
404 self
405 }
406
407 pub fn with_trust_provider(mut self, trust_provider: Arc<dyn TrustProvider>) -> Self {
408 self.trust_provider = trust_provider;
409 self
410 }
411
412 pub fn with_credential_expiry_warning(mut self, window: Duration) -> Self {
414 self.credential_expiry_warning = window;
415 self
416 }
417
418 pub fn with_mailbox_backpressure_threshold(mut self, threshold: Option<usize>) -> Self {
422 self.mailbox_backpressure_threshold = threshold;
423 self
424 }
425
426 pub fn resolved_mailbox_backpressure_threshold(&self) -> usize {
429 self.mailbox_backpressure_threshold
430 .unwrap_or(DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD)
431 }
432}
433
434#[cfg(not(target_arch = "wasm32"))]
435pub(crate) struct NamespaceResolver {
440 vars: HashMap<String, String>,
441}
442
443#[cfg(not(target_arch = "wasm32"))]
444impl NamespaceResolver {
445 pub fn new(config: &HyperConfig, instance_id: &str) -> HyperResult<Self> {
446 let mut vars = HashMap::new();
447
448 vars.insert(
449 "data_dir".to_string(),
450 config
451 .data_dir
452 .to_str()
453 .ok_or_else(|| {
454 HyperError::Config("data_dir path contains non-UTF-8 characters".to_string())
455 })?
456 .to_string(),
457 );
458 vars.insert("instance_id".to_string(), instance_id.to_string());
459
460 if let Ok(hostname) = std::env::var("HOSTNAME").or_else(|_| {
461 std::fs::read_to_string("/etc/hostname")
463 .map(|s| s.trim().to_string())
464 .map_err(|_| std::env::VarError::NotPresent)
465 }) {
466 vars.insert("hostname".to_string(), hostname);
467 }
468
469 Ok(Self { vars })
470 }
471
472 pub fn with_actor_type(mut self, manufacturer: &str, actr_name: &str, version: &str) -> Self {
474 self.vars
475 .insert("manufacturer".to_string(), manufacturer.to_string());
476 self.vars
477 .insert("actr_name".to_string(), actr_name.to_string());
478 self.vars.insert("version".to_string(), version.to_string());
479 self.vars.insert(
480 "actr_type".to_string(),
481 format!("{manufacturer}/{actr_name}/{version}"),
482 );
483 self
484 }
485
486 #[allow(dead_code)]
488 pub fn with_realm(mut self, realm_id: u64) -> Self {
489 self.vars
490 .insert("realm_id".to_string(), realm_id.to_string());
491 self
492 }
493
494 pub fn resolve(&self, template: &str) -> HyperResult<PathBuf> {
496 let mut result = template.to_string();
497
498 let env_prefix = "{env.";
500 let mut pos = 0;
501 while let Some(start) = result[pos..].find(env_prefix) {
502 let abs_start = pos + start;
503 if let Some(end) = result[abs_start..].find('}') {
504 let var_name = &result[abs_start + env_prefix.len()..abs_start + end];
505 let value = std::env::var(var_name)
506 .map_err(|_| HyperError::TemplateVariable(format!("env.{var_name}")))?;
507 let placeholder = format!("{{env.{var_name}}}");
508 result = result.replacen(&placeholder, &value, 1);
509 } else {
511 pos = abs_start + 1;
512 }
513 }
514
515 for (key, value) in &self.vars {
517 result = result.replace(&format!("{{{key}}}"), value);
518 }
519
520 if let Some(start) = result.find('{') {
522 if let Some(end) = result[start..].find('}') {
523 let var = &result[start + 1..start + end];
524 return Err(HyperError::TemplateVariable(var.to_string()));
525 }
526 }
527
528 Ok(PathBuf::from(result))
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::verify::StaticTrust;
536
537 fn stub_config(data_dir: &str) -> HyperConfig {
538 HyperConfig::new(data_dir, Arc::new(StaticTrust::dev_only()))
539 }
540
541 #[test]
542 fn resolve_basic_template() {
543 let config = stub_config("/var/lib/actr");
544 let resolver = NamespaceResolver::new(&config, "abc123")
545 .unwrap()
546 .with_actor_type("acme", "Sensor", "1.0.0");
547
548 let path = resolver.resolve("{data_dir}/{actr_type}").unwrap();
549 assert_eq!(path, PathBuf::from("/var/lib/actr/acme/Sensor/1.0.0"));
550 }
551
552 #[test]
553 fn resolve_missing_var_returns_error() {
554 let config = stub_config("/tmp");
555 let resolver = NamespaceResolver::new(&config, "id1").unwrap();
556 let result = resolver.resolve("{data_dir}/{realm_id}");
557 assert!(matches!(result, Err(HyperError::TemplateVariable(_))));
558 }
559
560 #[test]
561 fn resolve_with_realm() {
562 let config = stub_config("/tmp");
563 let resolver = NamespaceResolver::new(&config, "id1")
564 .unwrap()
565 .with_actor_type("acme", "Worker", "2.0")
566 .with_realm(42);
567 let path = resolver
568 .resolve("{data_dir}/{actr_type}/{realm_id}")
569 .unwrap();
570 assert_eq!(path, PathBuf::from("/tmp/acme/Worker/2.0/42"));
571 }
572
573 const BASE_CONFIG_TOML: &str = r#"
578edition = 1
579[signaling]
580url = "ws://localhost:8081/signaling/ws"
581[ais_endpoint]
582url = "http://localhost:8081/ais"
583[deployment]
584realm_id = 1
585"#;
586
587 #[tokio::test]
588 async fn node_from_config_file_dev_only_succeeds() {
589 let dir = tempfile::tempdir().unwrap();
590 let path = dir.path().join("actr.toml");
591 let data_dir = dir.path().display().to_string().replace('\\', "/");
592 std::fs::write(
593 &path,
594 format!(
595 "{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
596 [hyper.trust]\nkind = \"dev_only\"\n"
597 ),
598 )
599 .unwrap();
600 let _node = node_from_config_file(&path)
601 .await
602 .expect("dev_only trust should be accepted");
603 }
604
605 #[tokio::test]
606 async fn node_from_config_file_missing_trust_errors() {
607 let dir = tempfile::tempdir().unwrap();
608 let path = dir.path().join("actr.toml");
609 let data_dir = dir.path().display().to_string().replace('\\', "/");
610 std::fs::write(
611 &path,
612 format!("{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n"),
613 )
614 .unwrap();
615 let result = node_from_config_file(&path).await;
616 let err = match result {
617 Ok(_) => panic!("missing trust must fail"),
618 Err(e) => e,
619 };
620 let msg = err.to_string();
621 assert!(
622 msg.contains("no `[hyper.trust]`") && msg.contains("dev_only"),
623 "error should direct user to the dev_only opt-in, got: {msg}"
624 );
625 }
626
627 #[tokio::test]
628 async fn node_from_config_file_accepts_top_level_registry_anchor() {
629 let dir = tempfile::tempdir().unwrap();
630 let path = dir.path().join("actr.toml");
631 let data_dir = dir.path().display().to_string().replace('\\', "/");
632 std::fs::write(
633 &path,
634 format!(
635 "{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
636 [[trust]]\nkind = \"registry\"\nendpoint = \"http://localhost:8081/ais\"\n"
637 ),
638 )
639 .unwrap();
640 let _node = node_from_config_file(&path)
641 .await
642 .expect("top-level [[trust]] registry anchor should be accepted");
643 }
644
645 #[tokio::test]
646 async fn node_from_config_file_allows_linked_actor_type_override() {
647 let dir = tempfile::tempdir().unwrap();
648 let path = dir.path().join("actr.toml");
649 let data_dir = dir.path().display().to_string().replace('\\', "/");
650 std::fs::write(
651 &path,
652 format!(
653 "{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
654 [hyper.trust]\nkind = \"dev_only\"\n"
655 ),
656 )
657 .unwrap();
658
659 let actor_type = actr_protocol::ActrType {
660 manufacturer: "acme".to_string(),
661 name: "EchoApp".to_string(),
662 version: "0.1.0".to_string(),
663 };
664
665 let node = node_from_config_file(&path)
666 .await
667 .expect("dev_only trust should be accepted")
668 .with_actor_type(actor_type.clone());
669
670 assert_eq!(node.runtime_config().actr_type(), &actor_type);
671 }
672}