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 use crate::error::HyperError;
166 use crate::verify::{ChainTrust, RegistryTrust, StaticTrust, TrustProvider};
167
168 let raw_text = std::fs::read_to_string(path).map_err(|e| {
171 HyperError::Config(format!(
172 "failed to read runtime config `{}`: {e}",
173 path.display()
174 ))
175 })?;
176
177 let raw_runtime: actr_config::RuntimeRawConfig = raw_text.parse().map_err(|e| {
181 HyperError::Config(format!(
182 "failed to parse runtime config `{}`: {e}",
183 path.display()
184 ))
185 })?;
186 let package_info = actr_config::PackageInfo {
189 name: "client".to_string(),
190 actr_type: actr_protocol::ActrType {
191 manufacturer: "local".to_string(),
192 name: "Client".to_string(),
193 version: "0.0.0".to_string(),
194 },
195 description: None,
196 authors: vec![],
197 license: None,
198 };
199 let runtime_config = actr_config::ConfigParser::parse_runtime(raw_runtime, path, package_info)
200 .map_err(|e| HyperError::Config(format!("failed to parse runtime config: {e}")))?;
201
202 let hyper_section: HyperSectionWrapper = toml::from_str(&raw_text).map_err(|e| {
204 HyperError::Config(format!(
205 "failed to parse [hyper] section of `{}`: {e}",
206 path.display()
207 ))
208 })?;
209 let hyper_section = hyper_section.hyper;
210
211 let data_dir = if let Some(dir) = hyper_section.data_dir.clone() {
213 dir
214 } else {
215 actr_config::user_config::resolve_hyper_data_dir().map_err(|e| {
216 HyperError::Config(format!(
217 "failed to resolve default hyper data_dir (set `[hyper].data_dir` explicitly): {e}"
218 ))
219 })?
220 };
221
222 let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
224 let trust: Arc<dyn TrustProvider> = if let Some(anchor) = hyper_section.trust.clone() {
225 match anchor {
226 HyperTrustAnchor::DevOnly => {
227 tracing::warn!(
228 "[hyper.trust] kind = \"dev_only\" selected; accepting any package — \
229 NEVER use in production"
230 );
231 Arc::new(StaticTrust::dev_only())
232 }
233 HyperTrustAnchor::Static {
234 pubkey_file,
235 pubkey_b64,
236 } => {
237 let key_bytes = load_static_pubkey_bytes(
238 pubkey_file.as_deref().map(|p| resolve_path(base_dir, p)),
239 pubkey_b64,
240 )?;
241 Arc::new(StaticTrust::new(key_bytes)?)
242 }
243 HyperTrustAnchor::Registry { endpoint } => {
244 let base = endpoint.trim_end_matches("/ais").to_string();
245 Arc::new(RegistryTrust::new(base))
246 }
247 }
248 } else if !runtime_config.trust.is_empty() {
249 let mut providers: Vec<Arc<dyn TrustProvider>> =
251 Vec::with_capacity(runtime_config.trust.len());
252 for anchor in &runtime_config.trust {
253 let provider: Arc<dyn TrustProvider> = match anchor {
254 actr_config::TrustAnchor::Static {
255 pubkey_file,
256 pubkey_b64,
257 } => {
258 let key_bytes =
259 load_static_pubkey_bytes(pubkey_file.clone(), pubkey_b64.clone())?;
260 Arc::new(StaticTrust::new(key_bytes)?)
261 }
262 actr_config::TrustAnchor::Registry { endpoint } => {
263 let base = endpoint.trim_end_matches("/ais").to_string();
264 Arc::new(RegistryTrust::new(base))
265 }
266 };
267 providers.push(provider);
268 }
269 if providers.len() == 1 {
270 providers.into_iter().next().unwrap()
271 } else {
272 Arc::new(ChainTrust::new(providers))
273 }
274 } else {
275 return Err(HyperError::Config(
276 "no `[hyper.trust]` or `[[trust]]` anchor configured. \
277 Every runtime must declare a package-signature trust policy. \
278 For dev / tests set `[hyper.trust] kind = \"dev_only\"`; \
279 for production use `kind = \"static\"` with a `pubkey_file` \
280 or `kind = \"registry\"` with an AIS endpoint."
281 .to_string(),
282 ));
283 };
284
285 let mut hyper_config = HyperConfig::new(&data_dir, trust);
287 if let Some(template) = hyper_section.storage_path_template {
288 hyper_config = hyper_config.with_storage_template(template);
289 }
290
291 let hyper = crate::Hyper::new(hyper_config).await?;
296 let _ = &base_dir;
297 Ok(crate::Node::from_hyper(hyper, runtime_config))
298}
299
300#[cfg(not(target_arch = "wasm32"))]
301fn resolve_path(base_dir: &Path, path: impl AsRef<Path>) -> std::path::PathBuf {
302 let p = path.as_ref();
303 if p.is_absolute() {
304 p.to_path_buf()
305 } else {
306 base_dir.join(p)
307 }
308}
309
310#[cfg(not(target_arch = "wasm32"))]
311fn load_static_pubkey_bytes(
312 pubkey_file: Option<std::path::PathBuf>,
313 pubkey_b64: Option<String>,
314) -> crate::error::HyperResult<Vec<u8>> {
315 use crate::error::HyperError;
316 use base64::Engine;
317
318 if let Some(b64) = pubkey_b64 {
319 let bytes = base64::engine::general_purpose::STANDARD
320 .decode(&b64)
321 .map_err(|e| HyperError::Config(format!("invalid pubkey_b64: {e}")))?;
322 if bytes.len() != 32 {
323 return Err(HyperError::Config(format!(
324 "pubkey_b64 must decode to 32 bytes, got {}",
325 bytes.len()
326 )));
327 }
328 return Ok(bytes);
329 }
330 let path = pubkey_file.ok_or_else(|| {
331 HyperError::Config("static trust anchor requires `pubkey_file` or `pubkey_b64`".to_string())
332 })?;
333 let text = std::fs::read_to_string(&path).map_err(|e| {
334 HyperError::Config(format!(
335 "failed to read pubkey_file `{}`: {e}",
336 path.display()
337 ))
338 })?;
339 let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
340 HyperError::Config(format!(
341 "pubkey_file `{}` is not valid JSON: {e}",
342 path.display()
343 ))
344 })?;
345 let b64 = value
346 .get("public_key")
347 .and_then(|v| v.as_str())
348 .ok_or_else(|| {
349 HyperError::Config(format!(
350 "pubkey_file `{}` is missing the `public_key` field",
351 path.display()
352 ))
353 })?;
354 let bytes = base64::engine::general_purpose::STANDARD
355 .decode(b64)
356 .map_err(|e| {
357 HyperError::Config(format!(
358 "pubkey_file `{}` has invalid base64: {e}",
359 path.display()
360 ))
361 })?;
362 if bytes.len() != 32 {
363 return Err(HyperError::Config(format!(
364 "pubkey_file `{}` must contain a 32-byte key, got {}",
365 path.display(),
366 bytes.len()
367 )));
368 }
369 Ok(bytes)
370}
371
372#[cfg(not(target_arch = "wasm32"))]
373impl HyperConfig {
374 pub fn new(data_dir: impl AsRef<Path>, trust_provider: Arc<dyn TrustProvider>) -> Self {
380 Self {
381 data_dir: data_dir.as_ref().to_path_buf(),
382 storage_path_template: DEFAULT_STORAGE_TEMPLATE.to_string(),
383 trust_provider,
384 credential_expiry_warning: DEFAULT_CREDENTIAL_EXPIRY_WARNING,
385 mailbox_backpressure_threshold: None,
386 }
387 }
388
389 pub fn with_storage_template(mut self, template: impl Into<String>) -> Self {
390 self.storage_path_template = template.into();
391 self
392 }
393
394 pub fn with_trust_provider(mut self, trust_provider: Arc<dyn TrustProvider>) -> Self {
395 self.trust_provider = trust_provider;
396 self
397 }
398
399 pub fn with_credential_expiry_warning(mut self, window: Duration) -> Self {
401 self.credential_expiry_warning = window;
402 self
403 }
404
405 pub fn with_mailbox_backpressure_threshold(mut self, threshold: Option<usize>) -> Self {
409 self.mailbox_backpressure_threshold = threshold;
410 self
411 }
412
413 pub fn resolved_mailbox_backpressure_threshold(&self) -> usize {
416 self.mailbox_backpressure_threshold
417 .unwrap_or(DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD)
418 }
419}
420
421#[cfg(not(target_arch = "wasm32"))]
422pub(crate) struct NamespaceResolver {
427 vars: HashMap<String, String>,
428}
429
430#[cfg(not(target_arch = "wasm32"))]
431impl NamespaceResolver {
432 pub fn new(config: &HyperConfig, instance_id: &str) -> HyperResult<Self> {
433 let mut vars = HashMap::new();
434
435 vars.insert(
436 "data_dir".to_string(),
437 config
438 .data_dir
439 .to_str()
440 .ok_or_else(|| {
441 HyperError::Config("data_dir path contains non-UTF-8 characters".to_string())
442 })?
443 .to_string(),
444 );
445 vars.insert("instance_id".to_string(), instance_id.to_string());
446
447 if let Ok(hostname) = std::env::var("HOSTNAME").or_else(|_| {
448 std::fs::read_to_string("/etc/hostname")
450 .map(|s| s.trim().to_string())
451 .map_err(|_| std::env::VarError::NotPresent)
452 }) {
453 vars.insert("hostname".to_string(), hostname);
454 }
455
456 Ok(Self { vars })
457 }
458
459 pub fn with_actor_type(mut self, manufacturer: &str, actr_name: &str, version: &str) -> Self {
461 self.vars
462 .insert("manufacturer".to_string(), manufacturer.to_string());
463 self.vars
464 .insert("actr_name".to_string(), actr_name.to_string());
465 self.vars.insert("version".to_string(), version.to_string());
466 self.vars.insert(
467 "actr_type".to_string(),
468 format!("{manufacturer}/{actr_name}/{version}"),
469 );
470 self
471 }
472
473 #[allow(dead_code)]
475 pub fn with_realm(mut self, realm_id: u64) -> Self {
476 self.vars
477 .insert("realm_id".to_string(), realm_id.to_string());
478 self
479 }
480
481 pub fn resolve(&self, template: &str) -> HyperResult<PathBuf> {
483 let mut result = template.to_string();
484
485 let env_prefix = "{env.";
487 let mut pos = 0;
488 while let Some(start) = result[pos..].find(env_prefix) {
489 let abs_start = pos + start;
490 if let Some(end) = result[abs_start..].find('}') {
491 let var_name = &result[abs_start + env_prefix.len()..abs_start + end];
492 let value = std::env::var(var_name)
493 .map_err(|_| HyperError::TemplateVariable(format!("env.{var_name}")))?;
494 let placeholder = format!("{{env.{var_name}}}");
495 result = result.replacen(&placeholder, &value, 1);
496 } else {
498 pos = abs_start + 1;
499 }
500 }
501
502 for (key, value) in &self.vars {
504 result = result.replace(&format!("{{{key}}}"), value);
505 }
506
507 if let Some(start) = result.find('{') {
509 if let Some(end) = result[start..].find('}') {
510 let var = &result[start + 1..start + end];
511 return Err(HyperError::TemplateVariable(var.to_string()));
512 }
513 }
514
515 Ok(PathBuf::from(result))
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use crate::verify::StaticTrust;
523
524 fn stub_config(data_dir: &str) -> HyperConfig {
525 HyperConfig::new(data_dir, Arc::new(StaticTrust::dev_only()))
526 }
527
528 #[test]
529 fn resolve_basic_template() {
530 let config = stub_config("/var/lib/actr");
531 let resolver = NamespaceResolver::new(&config, "abc123")
532 .unwrap()
533 .with_actor_type("acme", "Sensor", "1.0.0");
534
535 let path = resolver.resolve("{data_dir}/{actr_type}").unwrap();
536 assert_eq!(path, PathBuf::from("/var/lib/actr/acme/Sensor/1.0.0"));
537 }
538
539 #[test]
540 fn resolve_missing_var_returns_error() {
541 let config = stub_config("/tmp");
542 let resolver = NamespaceResolver::new(&config, "id1").unwrap();
543 let result = resolver.resolve("{data_dir}/{realm_id}");
544 assert!(matches!(result, Err(HyperError::TemplateVariable(_))));
545 }
546
547 #[test]
548 fn resolve_with_realm() {
549 let config = stub_config("/tmp");
550 let resolver = NamespaceResolver::new(&config, "id1")
551 .unwrap()
552 .with_actor_type("acme", "Worker", "2.0")
553 .with_realm(42);
554 let path = resolver
555 .resolve("{data_dir}/{actr_type}/{realm_id}")
556 .unwrap();
557 assert_eq!(path, PathBuf::from("/tmp/acme/Worker/2.0/42"));
558 }
559
560 const BASE_CONFIG_TOML: &str = r#"
565edition = 1
566[signaling]
567url = "ws://localhost:8081/signaling/ws"
568[ais_endpoint]
569url = "http://localhost:8081/ais"
570[deployment]
571realm_id = 1
572"#;
573
574 #[tokio::test]
575 async fn node_from_config_file_dev_only_succeeds() {
576 let dir = tempfile::tempdir().unwrap();
577 let path = dir.path().join("actr.toml");
578 let data_dir = dir.path().display().to_string().replace('\\', "/");
579 std::fs::write(
580 &path,
581 format!(
582 "{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
583 [hyper.trust]\nkind = \"dev_only\"\n"
584 ),
585 )
586 .unwrap();
587 let _node = node_from_config_file(&path)
588 .await
589 .expect("dev_only trust should be accepted");
590 }
591
592 #[tokio::test]
593 async fn node_from_config_file_missing_trust_errors() {
594 let dir = tempfile::tempdir().unwrap();
595 let path = dir.path().join("actr.toml");
596 let data_dir = dir.path().display().to_string().replace('\\', "/");
597 std::fs::write(
598 &path,
599 format!("{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n"),
600 )
601 .unwrap();
602 let result = node_from_config_file(&path).await;
603 let err = match result {
604 Ok(_) => panic!("missing trust must fail"),
605 Err(e) => e,
606 };
607 let msg = err.to_string();
608 assert!(
609 msg.contains("no `[hyper.trust]`") && msg.contains("dev_only"),
610 "error should direct user to the dev_only opt-in, got: {msg}"
611 );
612 }
613
614 #[tokio::test]
615 async fn node_from_config_file_accepts_top_level_registry_anchor() {
616 let dir = tempfile::tempdir().unwrap();
617 let path = dir.path().join("actr.toml");
618 let data_dir = dir.path().display().to_string().replace('\\', "/");
619 std::fs::write(
620 &path,
621 format!(
622 "{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
623 [[trust]]\nkind = \"registry\"\nendpoint = \"http://localhost:8081/ais\"\n"
624 ),
625 )
626 .unwrap();
627 let _node = node_from_config_file(&path)
628 .await
629 .expect("top-level [[trust]] registry anchor should be accepted");
630 }
631
632 #[tokio::test]
633 async fn node_from_config_file_allows_linked_actor_type_override() {
634 let dir = tempfile::tempdir().unwrap();
635 let path = dir.path().join("actr.toml");
636 let data_dir = dir.path().display().to_string().replace('\\', "/");
637 std::fs::write(
638 &path,
639 format!(
640 "{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
641 [hyper.trust]\nkind = \"dev_only\"\n"
642 ),
643 )
644 .unwrap();
645
646 let actor_type = actr_protocol::ActrType {
647 manufacturer: "acme".to_string(),
648 name: "EchoApp".to_string(),
649 version: "0.1.0".to_string(),
650 };
651
652 let node = node_from_config_file(&path)
653 .await
654 .expect("dev_only trust should be accepted")
655 .with_actor_type(actor_type.clone());
656
657 assert_eq!(node.runtime_config().actr_type(), &actor_type);
658 }
659}