Skip to main content

actr_hyper/
config.rs

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/// Default storage path template: `{data_dir}/{actr_type}`.
15#[cfg(not(target_arch = "wasm32"))]
16const DEFAULT_STORAGE_TEMPLATE: &str = "{data_dir}/{actr_type}";
17
18/// Hyper initialization configuration.
19#[cfg(not(target_arch = "wasm32"))]
20#[derive(Clone)]
21pub struct HyperConfig {
22    /// Root data directory, corresponds to the namespace template variable `{data_dir}`
23    pub data_dir: PathBuf,
24
25    /// Storage namespace path template, defaults to `{data_dir}/{actr_type}`
26    ///
27    /// Available variables:
28    /// - `{data_dir}`      — root data directory
29    /// - `{instance_id}`   — locally unique ID generated and persisted at Hyper startup
30    /// - `{hostname}`      — OS hostname
31    /// - `{manufacturer}`  — Actor manufacturer name
32    /// - `{actr_name}`     — Actor name
33    /// - `{version}`       — Actor version
34    /// - `{actr_type}`     — full three-part type (`{manufacturer}/{actr_name}/{version}`)
35    /// - `{realm_id}`      — Actor's realm (available at runtime)
36    /// - `{env.VAR}`       — any environment variable
37    pub storage_path_template: String,
38
39    /// Pluggable package-signature verifier. Replaces the old `TrustMode` enum.
40    ///
41    /// Construct via [`crate::verify::StaticTrust`], [`crate::verify::RegistryTrust`],
42    /// or [`crate::verify::ChainTrust`] (or bring your own).
43    pub trust_provider: Arc<dyn TrustProvider>,
44
45    /// How far in advance of expiry the framework should fire the
46    /// `on_credential_expiring` hook.
47    ///
48    /// Default: 5 minutes.
49    pub credential_expiry_warning: Duration,
50
51    /// Queue-length trip point for the `on_mailbox_backpressure` hook.
52    ///
53    /// When `Some(threshold)`, the hook fires once per incident as soon as
54    /// the mailbox queued-message count crosses `threshold`, and re-arms
55    /// once the queue falls back below. When `None`, a built-in default of
56    /// [`DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD`] messages is used. The
57    /// mailbox trait currently exposes `status()` which reports
58    /// queued_messages, so the polling-based implementation in
59    /// `lifecycle::node` works against any mailbox backend that supports
60    /// the base trait.
61    pub mailbox_backpressure_threshold: Option<usize>,
62}
63
64/// Default mailbox backpressure threshold (queued-message count).
65///
66/// Chosen conservatively — most actor-rtc workloads are below this in
67/// steady state, so a warning at this level means queue growth needs
68/// attention. Tune per-actor via
69/// [`HyperConfig::mailbox_backpressure_threshold`].
70#[cfg(not(target_arch = "wasm32"))]
71pub(crate) const DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD: usize = 1024;
72
73/// Default credential-expiry warning lead time.
74#[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/// Raw TOML shape for the optional `[hyper]` section of `actr.toml`.
94///
95/// All fields optional; each controls one knob of [`HyperConfig`]. The
96/// trust anchor lives inside `[hyper.trust]` (singular) and uses the
97/// same tag-dispatched schema as the top-level `[[trust]]` array so users
98/// can pick whichever style they prefer.
99#[cfg(not(target_arch = "wasm32"))]
100#[derive(Debug, Clone, Default, serde::Deserialize)]
101pub(crate) struct HyperSection {
102    /// Root data directory (`{data_dir}` template variable).
103    #[serde(default)]
104    pub data_dir: Option<std::path::PathBuf>,
105
106    /// Override the default `{data_dir}/{actr_type}` storage template.
107    #[serde(default)]
108    pub storage_path_template: Option<String>,
109
110    /// Single trust anchor; for chain composition use the top-level
111    /// `[[trust]]` array instead.
112    #[serde(default)]
113    pub trust: Option<HyperTrustAnchor>,
114}
115
116/// Trust anchor config for `[hyper.trust]`. Superset of
117/// `actr_config::TrustAnchor` with an extra `dev_only` kind that lets
118/// tests and examples opt in to [`crate::verify::StaticTrust::dev_only`]
119/// explicitly.
120#[cfg(not(target_arch = "wasm32"))]
121#[derive(Debug, Clone, serde::Deserialize)]
122#[serde(tag = "kind", rename_all = "snake_case")]
123pub(crate) enum HyperTrustAnchor {
124    /// Accept any package — for tests and local development **only**.
125    ///
126    /// When selected by `Node::from_config_file`, emits a prominent
127    /// `tracing::warn!` at load time.
128    DevOnly,
129    /// Pre-shared Ed25519 public key. See
130    /// [`actr_config::TrustAnchor::Static`] for field semantics.
131    Static {
132        #[serde(default)]
133        pubkey_file: Option<std::path::PathBuf>,
134        #[serde(default)]
135        pubkey_b64: Option<String>,
136    },
137    /// AIS HTTP registry endpoint. See
138    /// [`actr_config::TrustAnchor::Registry`].
139    Registry { endpoint: String },
140}
141
142/// Top-level wrapper used when parsing `actr.toml` for the `[hyper]`
143/// section in isolation. Ignores every other field.
144#[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/// Load an `actr.toml`-style file and return a [`crate::Node`] in the
152/// `Init` state. This function handles the full pipeline:
153///
154/// 1. Parse the runtime section via [`actr_config::ConfigParser`].
155/// 2. Parse the optional `[hyper]` section for data-dir / template /
156///    trust overrides.
157/// 3. Resolve trust anchors (preferring `[hyper.trust]`, falling back to
158///    top-level `[[trust]]`, finally producing an `Err` unless the
159///    effective config explicitly opts into dev-only trust).
160/// 4. Build a [`crate::Hyper`] and wrap it in `Node<Init>`.
161#[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    // Load both the RuntimeConfig and the raw TOML so we can pick up
169    // [hyper.*] overrides that `ConfigParser` doesn't surface.
170    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    // Derive a minimal PackageInfo from the raw toml when `[package]` is
178    // absent. Otherwise, let ConfigParser handle it from its embedded
179    // `[package]` section.
180    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    // `RuntimeConfig` requires a PackageInfo; synthesise a placeholder
187    // one so `from_config_file` can work without a sibling manifest.
188    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    // Parse the optional [hyper] section.
203    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    // Resolve the data_dir: [hyper].data_dir > CLI user-config default.
212    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    // Resolve trust: prefer [hyper.trust], fall back to top-level [[trust]].
223    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        // Chain fallback from the top-level [[trust]] anchors.
250        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    // Build HyperConfig with the resolved values.
286    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    // Build Hyper and return Node<Init>. Observability bring-up is left
292    // to the caller — bindings and the CLI both want control over when
293    // the tracing subscriber gets installed (they may want to layer in
294    // their own filters first).
295    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    /// Build a new HyperConfig with the given `data_dir` and package trust provider.
375    ///
376    /// There is no default provider — you must explicitly decide how packages
377    /// are authenticated (see [`crate::verify::StaticTrust`] /
378    /// [`crate::verify::RegistryTrust`] / [`crate::verify::ChainTrust`]).
379    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    /// Override the credential-expiry warning lead time.
400    pub fn with_credential_expiry_warning(mut self, window: Duration) -> Self {
401        self.credential_expiry_warning = window;
402        self
403    }
404
405    /// Set the mailbox backpressure threshold.
406    ///
407    /// See [`HyperConfig::mailbox_backpressure_threshold`] for semantics.
408    pub fn with_mailbox_backpressure_threshold(mut self, threshold: Option<usize>) -> Self {
409        self.mailbox_backpressure_threshold = threshold;
410        self
411    }
412
413    /// Resolve the active mailbox backpressure threshold — explicit
414    /// override or the built-in [`DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD`].
415    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"))]
422/// Namespace template resolver
423///
424/// Holds runtime-known variables and resolves path templates on demand.
425/// Templates are resolved once during Hyper initialization and remain fixed afterwards.
426pub(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            // fallback: read system hostname
449            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    /// Inject Actor type variables (extracted from the verified manifest)
460    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    /// Inject runtime realm_id
474    #[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    /// Resolve a template string, returning the final path
482    pub fn resolve(&self, template: &str) -> HyperResult<PathBuf> {
483        let mut result = template.to_string();
484
485        // Handle {env.VAR} variables
486        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                // do not advance position, re-scan the replaced string
497            } else {
498                pos = abs_start + 1;
499            }
500        }
501
502        // Handle regular variables
503        for (key, value) in &self.vars {
504            result = result.replace(&format!("{{{key}}}"), value);
505        }
506
507        // Check for unresolved variables
508        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    /// Minimal actr.toml body shared across tests. Callers append a
561    /// `[hyper]` section (with an escaped `data_dir` pointing into the
562    /// test's tempdir) so `node_from_config_file` never writes into the
563    /// user's real `~/.actr`.
564    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}