pub mod config;
pub mod error;
pub mod runtime_error;
pub mod verify;
#[cfg(not(target_arch = "wasm32"))]
pub mod actr_ref;
#[cfg(not(target_arch = "wasm32"))]
pub mod ais_client;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod key_cache;
#[cfg(not(target_arch = "wasm32"))]
pub mod storage;
#[cfg(all(not(target_arch = "wasm32"), feature = "test-utils"))]
pub mod inbound;
#[cfg(all(not(target_arch = "wasm32"), not(feature = "test-utils")))]
pub(crate) mod inbound;
#[cfg(not(target_arch = "wasm32"))]
pub mod lifecycle;
#[cfg(all(not(target_arch = "wasm32"), feature = "test-utils"))]
pub mod outbound;
#[cfg(all(not(target_arch = "wasm32"), not(feature = "test-utils")))]
pub(crate) mod outbound;
#[cfg(not(target_arch = "wasm32"))]
pub mod transport;
#[cfg(not(target_arch = "wasm32"))]
pub mod wire;
#[cfg(all(not(target_arch = "wasm32"), feature = "test-utils"))]
pub mod test_support;
#[cfg(not(target_arch = "wasm32"))]
pub mod context;
#[cfg(not(target_arch = "wasm32"))]
pub mod workload;
#[cfg(not(target_arch = "wasm32"))]
mod service_spec;
#[cfg(all(not(target_arch = "wasm32"), feature = "wasm-engine"))]
pub mod wasm;
#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
pub mod dynclib;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod monitoring;
#[cfg(not(target_arch = "wasm32"))]
pub mod observability;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod resource;
pub use actr_pack::{PackageManifest, VerifiedPackage};
pub use config::HyperConfig;
pub use error::HyperError;
pub(crate) use error::HyperResult;
pub use actr_protocol::{Acl, ActrId, ActrType, ServiceSpec};
pub use actr_framework::{MediaSample, MediaType};
pub use runtime_error::{ActorResult, ActrError, Classify, ErrorKind};
pub use actr_platform_traits::{CryptoProvider, KvStore, PlatformError, PlatformProvider};
#[cfg(not(target_arch = "wasm32"))]
pub use ais_client::AisClient;
#[cfg(not(target_arch = "wasm32"))]
pub use storage::ActorStore;
#[cfg(not(target_arch = "wasm32"))]
pub use verify::{ChainTrust, MfrCertCache, RegistryTrust, StaticTrust, TrustProvider};
#[cfg(not(target_arch = "wasm32"))]
pub use observability::{ObservabilityGuard, init_observability};
#[cfg(not(target_arch = "wasm32"))]
pub use actr_ref::ActrRef;
#[cfg(not(target_arch = "wasm32"))]
pub use lifecycle::{CredentialState, NetworkEventHandle};
#[cfg(all(not(target_arch = "wasm32"), feature = "test-utils"))]
pub use transport::{
ConnType, DataLane, DefaultWireBuilder, DefaultWireBuilderConfig, HostTransport, PeerTransport,
WireBuilder, WireHandle,
};
#[cfg(not(target_arch = "wasm32"))]
pub use transport::{Dest, ExponentialBackoff, NetworkError, NetworkResult};
#[cfg(not(target_arch = "wasm32"))]
pub use wire::{
AuthConfig, AuthType, DisconnectReason, ReconnectConfig, SignalingClient, SignalingConfig,
SignalingEvent, SignalingStats, WebRtcConfig,
};
#[cfg(all(not(target_arch = "wasm32"), feature = "test-utils"))]
pub use wire::{WebRtcCoordinator, WebSocketSignalingClient};
#[cfg(not(target_arch = "wasm32"))]
pub use actr_runtime_mailbox::{
Mailbox, MailboxStats, MessagePriority, MessageRecord, MessageStatus,
};
#[cfg(not(target_arch = "wasm32"))]
pub use workload::{HostAbiFn, HostOperation, HostOperationResult, InvocationContext};
pub(crate) const INITIAL_CONNECTION_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(10);
pub mod prelude {
pub use crate::verify::{ChainTrust, RegistryTrust, StaticTrust, TrustProvider};
#[cfg(not(target_arch = "wasm32"))]
pub use crate::{Attached, Hyper, Init, Node, Registered, storage::ActorStore};
pub use crate::{HyperConfig, HyperError};
pub use actr_pack::{PackageManifest, VerifiedPackage};
#[cfg(not(target_arch = "wasm32"))]
pub use crate::actr_ref::ActrRef;
pub use actr_framework::{MediaSample, MediaType};
#[cfg(not(target_arch = "wasm32"))]
pub use crate::wire::webrtc::{
AuthConfig, AuthType, DisconnectReason, ReconnectConfig, SignalingClient, SignalingConfig,
SignalingEvent, SignalingStats, WebRtcConfig,
};
#[cfg(feature = "test-utils")]
pub use crate::wire::webrtc::{WebRtcCoordinator, WebSocketSignalingClient};
#[cfg(not(target_arch = "wasm32"))]
pub use actr_runtime_mailbox::{
Mailbox, MailboxStats, MessagePriority, MessageRecord, MessageStatus,
};
#[cfg(feature = "test-utils")]
pub use crate::transport::{
ConnType, DataLane, DefaultWireBuilder, DefaultWireBuilderConfig, HostTransport,
PeerTransport, WireBuilder, WireHandle,
};
#[cfg(not(target_arch = "wasm32"))]
pub use crate::transport::{Dest, NetworkError, NetworkResult};
pub use crate::runtime_error::{ActorResult, ActrError};
pub use actr_protocol::ActrId;
pub use actr_framework::{Context, Workload};
pub use async_trait::async_trait;
pub use anyhow::{Context as AnyhowContext, Result as AnyhowResult};
pub use chrono::{DateTime, Utc};
pub use uuid::Uuid;
pub use tokio::sync::{Mutex, RwLock, broadcast, mpsc, oneshot};
#[cfg(not(target_arch = "wasm32"))]
pub use tokio::time::{Duration, Instant, sleep, timeout};
pub use tracing::{debug, error, info, trace, warn};
}
#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
use std::io::Write;
#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
use std::path::Path;
#[cfg(not(target_arch = "wasm32"))]
use std::path::PathBuf;
#[cfg(not(target_arch = "wasm32"))]
use std::str::FromStr;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(not(target_arch = "wasm32"))]
use prost::Message;
#[cfg(not(target_arch = "wasm32"))]
use tracing::{debug, error, info, warn};
#[cfg(not(target_arch = "wasm32"))]
use uuid::Uuid;
#[cfg(not(target_arch = "wasm32"))]
use actr_platform_traits::KvOp;
#[cfg(not(target_arch = "wasm32"))]
use actr_protocol::{Realm, RegisterAuthMode, RegisterRequest, register_response};
#[cfg(not(target_arch = "wasm32"))]
pub struct Init;
#[cfg(not(target_arch = "wasm32"))]
pub struct Attached;
#[cfg(not(target_arch = "wasm32"))]
pub struct Registered;
#[cfg(not(target_arch = "wasm32"))]
mod node_state_sealed {
pub trait Sealed {}
impl Sealed for super::Init {}
impl Sealed for super::Attached {}
impl Sealed for super::Registered {}
}
#[cfg(not(target_arch = "wasm32"))]
pub trait NodeState: node_state_sealed::Sealed {}
#[cfg(not(target_arch = "wasm32"))]
impl NodeState for Init {}
#[cfg(not(target_arch = "wasm32"))]
impl NodeState for Attached {}
#[cfg(not(target_arch = "wasm32"))]
impl NodeState for Registered {}
#[cfg(not(target_arch = "wasm32"))]
pub struct Hyper {
inner: Arc<HyperInner>,
}
#[cfg(not(target_arch = "wasm32"))]
struct HyperInner {
config: HyperConfig,
instance_id: String,
platform: Option<Arc<dyn PlatformProvider>>,
}
#[cfg(not(target_arch = "wasm32"))]
struct Attachment {
node: crate::lifecycle::node::Inner,
verified: Option<VerifiedPackage>,
package_bytes: bytes::Bytes,
}
#[cfg(not(target_arch = "wasm32"))]
pub struct Node<S: NodeState = Attached> {
hyper: Arc<HyperInner>,
attachment: Option<Attachment>,
pending_runtime_config: Option<actr_config::RuntimeConfig>,
_state: std::marker::PhantomData<S>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinaryKind {
Wasm,
DynClib,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone)]
pub struct WorkloadPackage {
bytes: bytes::Bytes,
}
#[cfg(not(target_arch = "wasm32"))]
impl WorkloadPackage {
pub fn new(bytes: impl Into<bytes::Bytes>) -> Self {
Self {
bytes: bytes.into(),
}
}
pub fn from_path(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
let bytes = std::fs::read(path)?;
Ok(Self {
bytes: bytes.into(),
})
}
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
pub fn manifest(&self) -> HyperResult<actr_pack::PackageManifest> {
actr_pack::read_manifest(&self.bytes)
.map_err(|e| HyperError::InvalidManifest(e.to_string()))
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) struct LoadedWorkload {
pub verified: VerifiedPackage,
pub binary_kind: BinaryKind,
pub workload: crate::workload::Workload,
}
#[cfg(not(target_arch = "wasm32"))]
impl std::fmt::Debug for LoadedWorkload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoadedWorkload")
.field("manifest", &self.verified.manifest)
.field("backend", &self.binary_kind)
.finish_non_exhaustive()
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Hyper {
pub async fn new(config: HyperConfig) -> HyperResult<Self> {
Self::init_inner(config, None).await
}
pub async fn with_platform(
config: HyperConfig,
platform: Arc<dyn PlatformProvider>,
) -> HyperResult<Self> {
Self::init_inner(config, Some(platform)).await
}
async fn init_inner(
config: HyperConfig,
platform: Option<Arc<dyn PlatformProvider>>,
) -> HyperResult<Self> {
info!(
data_dir = %config.data_dir.display(),
"Hyper initializing"
);
let instance_id = if let Some(ref p) = platform {
p.instance_uid()
.await
.map_err(|e| HyperError::Storage(format!("failed to load instance_uid: {e}")))?
} else {
tokio::fs::create_dir_all(&config.data_dir)
.await
.map_err(|e| {
HyperError::Config(format!(
"failed to create data_dir `{}`: {e}",
config.data_dir.display()
))
})?;
load_or_create_instance_uid_local(&config.data_dir).await?
};
debug!(instance_id, "Hyper instance_uid ready");
Ok(Self {
inner: Arc::new(HyperInner {
config,
instance_id,
platform,
}),
})
}
pub async fn verify_package(&self, package: &WorkloadPackage) -> HyperResult<VerifiedPackage> {
self.inner
.config
.trust_provider
.verify_package(package.bytes())
.await
}
#[cfg(feature = "test-utils")]
pub(crate) async fn load_workload_package(
&self,
package: &WorkloadPackage,
) -> HyperResult<LoadedWorkload> {
load_workload_package_inner(&self.inner, package).await
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Node {
pub async fn from_config_file(path: impl AsRef<std::path::Path>) -> HyperResult<Node<Init>> {
config::node_from_config_file(path.as_ref()).await
}
pub fn from_hyper(hyper: Hyper, runtime_config: actr_config::RuntimeConfig) -> Node<Init> {
Node {
hyper: hyper.inner,
attachment: None,
pending_runtime_config: Some(runtime_config),
_state: std::marker::PhantomData,
}
}
pub async fn run_from_config(
path: impl AsRef<std::path::Path>,
package: &WorkloadPackage,
) -> HyperResult<ActrRef> {
let init = Self::from_config_file(path).await?;
let ais_endpoint = init
.pending_runtime_config
.as_ref()
.map(|c| c.ais_endpoint.clone())
.expect("Node<Init> without pending runtime config");
let attached = init.attach(package).await?;
let registered = attached.register(&ais_endpoint).await?;
registered
.start()
.await
.map_err(|e| HyperError::Runtime(format!("failed to start node: {e}")))
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Node<Init> {
pub fn runtime_config(&self) -> &actr_config::RuntimeConfig {
self.pending_runtime_config
.as_ref()
.expect("Node<Init> without pending runtime config")
}
pub fn with_actor_type(mut self, actor_type: actr_protocol::ActrType) -> Self {
let runtime_config = self
.pending_runtime_config
.as_mut()
.expect("Node<Init> without pending runtime config");
runtime_config.package.name = actor_type.name.clone();
runtime_config.package.actr_type = actor_type;
self
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Node<Init> {
pub async fn attach(self, package: &WorkloadPackage) -> HyperResult<Node<Attached>> {
let runtime_config = self
.pending_runtime_config
.expect("Node<Init> without pending runtime config");
let hyper_inner = self.hyper;
let loaded = load_workload_package_inner(&hyper_inner, package).await?;
let packaged_lock = actr_pack::read_lock_file(package.bytes())
.map_err(|e| HyperError::Runtime(e.to_string()))?
.map(|bytes| {
let raw = std::str::from_utf8(&bytes).map_err(|e| {
HyperError::Runtime(format!("manifest.lock.toml is not valid UTF-8: {e}"))
})?;
actr_config::lock::LockFile::from_str(raw).map_err(|e| {
HyperError::Runtime(format!("failed to parse manifest.lock.toml: {e}"))
})
})
.transpose()?;
let mailbox_backpressure_threshold =
hyper_inner.config.resolved_mailbox_backpressure_threshold();
let credential_expiry_warning = hyper_inner.config.credential_expiry_warning;
let mut node_inner = crate::lifecycle::node::Inner::build(
runtime_config,
loaded.workload,
Some(loaded.verified.manifest.clone()),
packaged_lock,
mailbox_backpressure_threshold,
credential_expiry_warning,
)
.await
.map_err(|e| HyperError::Runtime(e.to_string()))?;
let observer: Arc<dyn crate::lifecycle::hooks::WorkloadHookObserver> =
Arc::new(crate::workload::PackageHookObserver {
workload_dispatch: node_inner.workload_dispatch.clone(),
});
node_inner.hook_observer = Some(observer);
Ok(Node {
hyper: hyper_inner,
attachment: Some(Attachment {
node: node_inner,
verified: Some(loaded.verified),
package_bytes: package.bytes.clone(),
}),
pending_runtime_config: None,
_state: std::marker::PhantomData,
})
}
pub(crate) async fn link_handle(
self,
handle: Arc<dyn workload::LinkedWorkloadHandle>,
) -> HyperResult<Node<Attached>> {
let runtime_config = self
.pending_runtime_config
.expect("Node<Init> without pending runtime config");
let hyper_inner = self.hyper;
let mailbox_backpressure_threshold =
hyper_inner.config.resolved_mailbox_backpressure_threshold();
let credential_expiry_warning = hyper_inner.config.credential_expiry_warning;
let mut node_inner = crate::lifecycle::node::Inner::build(
runtime_config,
crate::workload::Workload::Linked(handle.clone()),
None,
None,
mailbox_backpressure_threshold,
credential_expiry_warning,
)
.await
.map_err(|e| HyperError::Runtime(e.to_string()))?;
let observer: Arc<dyn crate::lifecycle::hooks::WorkloadHookObserver> =
Arc::new(crate::workload::LinkedHandleObserver { handle });
node_inner.hook_observer = Some(observer);
Ok(Node {
hyper: hyper_inner,
attachment: Some(Attachment {
node: node_inner,
verified: None,
package_bytes: bytes::Bytes::new(),
}),
pending_runtime_config: None,
_state: std::marker::PhantomData,
})
}
pub async fn link<W: actr_framework::Workload>(
self,
workload: W,
) -> HyperResult<Node<Attached>> {
let handle: Arc<dyn workload::LinkedWorkloadHandle> =
workload::WorkloadAdapter::new(workload);
self.link_handle(handle).await
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Node<Attached> {
pub async fn register(self, ais_endpoint: &str) -> HyperResult<Node<Registered>> {
let attachment = self
.attachment
.as_ref()
.expect("Node<Attached> without attachment");
let service_spec = if let Some(verified) = attachment.verified.as_ref() {
crate::service_spec::calculate_service_spec_from_package(
&attachment.package_bytes,
&verified.manifest,
)?
} else {
None
};
self.register_with(ais_endpoint, service_spec).await
}
pub async fn register_with(
mut self,
ais_endpoint: &str,
service_spec: Option<ServiceSpec>,
) -> HyperResult<Node<Registered>> {
let attachment = self
.attachment
.as_mut()
.expect("Node<Attached> without attachment");
let realm_id = attachment.node.config.realm.realm_id;
let acl = attachment.node.config.acl.clone();
let realm_secret = attachment.node.config.realm_secret.clone();
let register_ok = if let Some(verified) = attachment.verified.as_ref() {
let verified = verified.clone();
bootstrap_credential_inner(
&self.hyper,
&verified,
ais_endpoint,
realm_id,
service_spec,
acl,
realm_secret.as_deref(),
)
.await?
} else {
bootstrap_linked_credential_inner(&attachment.node.config, ais_endpoint, service_spec)
.await?
};
attachment.node.set_preregistered_credential(register_ok);
Ok(Node {
hyper: self.hyper,
attachment: self.attachment,
pending_runtime_config: None,
_state: std::marker::PhantomData,
})
}
pub fn create_network_event_handle(
&mut self,
debounce_ms: u64,
) -> crate::lifecycle::NetworkEventHandle {
self.attachment
.as_mut()
.expect("Node<Attached> without attachment")
.node
.create_network_event_handle(debounce_ms)
}
pub fn ais_endpoint(&self) -> &str {
&self
.attachment
.as_ref()
.expect("Node<Attached> without attachment")
.node
.config
.ais_endpoint
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Node<Registered> {
pub async fn start(self) -> actr_protocol::ActorResult<crate::actr_ref::ActrRef> {
let Attachment { node, .. } = self
.attachment
.expect("Node<Registered> without attachment");
node.start().await
}
pub fn create_network_event_handle(
&mut self,
debounce_ms: u64,
) -> crate::lifecycle::NetworkEventHandle {
self.attachment
.as_mut()
.expect("Node<Registered> without attachment")
.node
.create_network_event_handle(debounce_ms)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Hyper {
pub fn resolve_storage_path(&self, manifest: &PackageManifest) -> HyperResult<PathBuf> {
resolve_storage_path_for(&self.inner, manifest)
}
pub async fn bootstrap_credential(
&self,
verified: &VerifiedPackage,
ais_endpoint: &str,
realm_id: u32,
service_spec: Option<ServiceSpec>,
acl: Option<Acl>,
) -> HyperResult<register_response::RegisterOk> {
bootstrap_credential_inner(
&self.inner,
verified,
ais_endpoint,
realm_id,
service_spec,
acl,
None,
)
.await
}
pub fn instance_id(&self) -> &str {
&self.inner.instance_id
}
pub fn config(&self) -> &HyperConfig {
&self.inner.config
}
}
#[cfg(not(target_arch = "wasm32"))]
fn resolve_storage_path_for(
inner: &HyperInner,
manifest: &PackageManifest,
) -> HyperResult<PathBuf> {
let resolver = config::NamespaceResolver::new(&inner.config, &inner.instance_id)?
.with_actor_type(&manifest.manufacturer, &manifest.name, &manifest.version);
resolver.resolve(&inner.config.storage_path_template)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn load_workload_package_inner(
inner: &HyperInner,
package: &WorkloadPackage,
) -> HyperResult<LoadedWorkload> {
let bytes = package.bytes();
let verified = inner.config.trust_provider.verify_package(bytes).await?;
let binary_kind = detect_binary_kind(&verified.manifest)?;
let workload = match binary_kind {
BinaryKind::Wasm => load_wasm_workload_inner(inner, bytes, &verified.manifest).await?,
BinaryKind::DynClib => load_dynclib_workload_inner(inner, bytes, &verified.manifest)?,
};
Ok(LoadedWorkload {
verified,
binary_kind,
workload,
})
}
#[cfg(not(target_arch = "wasm32"))]
async fn load_wasm_workload_inner(
_inner: &HyperInner,
bytes: &[u8],
manifest: &PackageManifest,
) -> HyperResult<crate::workload::Workload> {
#[cfg(feature = "wasm-engine")]
{
if matches!(
manifest.binary.resolved_kind(),
actr_pack::BinaryKind::CoreModule
) {
return Err(HyperError::InvalidManifest(format!(
"package `{}` uses the legacy core wasm module format, which was retired in Phase 1. \
Rebuild with actr 0.2+ (`actr build`, target wasm32-wasip2 + wasm-component-ld 0.5.22+) \
to produce a Component Model binary, and set `binary.kind = \"component\"` in manifest.toml.",
manifest.actr_type_str()
)));
}
let wasm_bytes = actr_pack::load_binary(bytes).map_err(|e| {
HyperError::Runtime(format!(
"failed to extract package binary `{}` for target `{}`: {e}",
manifest.binary.path, manifest.binary.target
))
})?;
let host = crate::wasm::WasmHost::compile(&wasm_bytes).map_err(|e| {
HyperError::Runtime(format!(
"failed to compile WASM package target `{}`: {e}",
manifest.binary.target
))
})?;
let mut instance = host.instantiate().await.map_err(|e| {
HyperError::Runtime(format!(
"failed to instantiate WASM package target `{}`: {e}",
manifest.binary.target
))
})?;
instance
.init(&actr_framework::guest::dynclib_abi::InitPayloadV1 {
version: actr_framework::guest::dynclib_abi::version::V1,
actr_type: manifest.actr_type_str(),
credential: Vec::new(),
actor_id: Vec::new(),
realm_id: 0,
})
.map_err(|e| {
HyperError::Runtime(format!(
"failed to initialize WASM package target `{}`: {e}",
manifest.binary.target
))
})?;
Ok(crate::workload::Workload::Wasm(instance))
}
#[cfg(not(feature = "wasm-engine"))]
{
let _ = (bytes, manifest);
Err(HyperError::Runtime(
"package target requires the `wasm-engine` feature, but it is not enabled".to_string(),
))
}
}
#[cfg(not(target_arch = "wasm32"))]
fn load_dynclib_workload_inner(
_inner: &HyperInner,
bytes: &[u8],
manifest: &PackageManifest,
) -> HyperResult<crate::workload::Workload> {
#[cfg(feature = "dynclib-engine")]
{
let cache_path = ensure_dynclib_cache_path(&_inner.config.data_dir, bytes, manifest)?;
let host = load_dynclib_host_with_rebuild(&cache_path, bytes, manifest)?;
let instance = host
.instantiate(&actr_framework::guest::dynclib_abi::InitPayloadV1 {
version: actr_framework::guest::dynclib_abi::version::V1,
actr_type: manifest.actr_type_str(),
credential: Vec::new(),
actor_id: Vec::new(),
realm_id: 0,
})
.map_err(|e| {
HyperError::Runtime(format!(
"failed to initialize dynclib package target `{}`: {e}",
manifest.binary.target
))
})?;
Ok(crate::workload::Workload::DynClib(
crate::dynclib::DynClibWorkload::new(host, instance),
))
}
#[cfg(not(feature = "dynclib-engine"))]
{
let _ = (bytes, manifest);
Err(HyperError::Runtime(
"package target requires the `dynclib-engine` feature, but it is not enabled"
.to_string(),
))
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn bootstrap_credential_inner(
inner: &HyperInner,
verified: &VerifiedPackage,
ais_endpoint: &str,
realm_id: u32,
service_spec: Option<ServiceSpec>,
acl: Option<Acl>,
realm_secret: Option<&str>,
) -> HyperResult<register_response::RegisterOk> {
let manifest = &verified.manifest;
info!(
actr_type = manifest.actr_type_str(),
ais_endpoint, realm_id, "starting credential bootstrap with AIS"
);
let storage_path = resolve_storage_path_for(inner, manifest)?;
let store: Arc<dyn KvStore> = if let Some(ref platform) = inner.platform {
let ns = storage_path.to_string_lossy().to_string();
platform
.secret_store(&ns)
.await
.map_err(|e| HyperError::Storage(format!("failed to open secret store: {e}")))?
} else {
Arc::new(ActorStore::open(&storage_path).await?)
};
let valid_psk = load_valid_psk_dyn(&*store).await?;
let mut ais = AisClient::new(ais_endpoint);
if let Some(secret) = realm_secret {
ais = ais.with_realm_secret(secret);
}
let actr_type = ActrType {
manufacturer: manifest.manufacturer.clone(),
name: manifest.name.clone(),
version: manifest.version.clone(),
};
let realm = Realm { realm_id };
let response = if let Some(psk_token) = valid_psk {
debug!(
actr_type = manifest.actr_type_str(),
"renewing credential using PSK"
);
let req = RegisterRequest {
actr_type,
realm,
service_spec,
acl,
service: None,
ws_address: None,
manifest_raw: None,
mfr_signature: None,
psk_token: Some(psk_token.into()),
target: Some(manifest.binary.target.clone()),
auth_mode: Some(RegisterAuthMode::Package as i32),
};
ais.register_with_psk(req).await?
} else {
info!(
actr_type = manifest.actr_type_str(),
"first registration: registering with AIS using MFR manifest"
);
let req = RegisterRequest {
actr_type,
realm,
service_spec,
acl,
service: None,
ws_address: None,
manifest_raw: Some(verified.manifest_raw.clone().into()),
mfr_signature: Some(verified.sig_raw.clone().into()),
psk_token: None,
target: Some(manifest.binary.target.clone()),
auth_mode: Some(RegisterAuthMode::Package as i32),
};
ais.register_with_manifest(req).await?
};
let ok = match response.result {
Some(register_response::Result::Success(ok)) => ok,
Some(register_response::Result::Error(e)) => {
error!(
actr_type = manifest.actr_type_str(),
error_code = e.code,
error_message = %e.message,
"AIS registration returned error"
);
return Err(HyperError::AisBootstrapFailed(format!(
"AIS rejected registration (code={}): {}",
e.code, e.message
)));
}
None => {
error!(
actr_type = manifest.actr_type_str(),
"AIS response missing result field"
);
return Err(HyperError::AisBootstrapFailed(
"AIS response missing result field".to_string(),
));
}
};
if let (Some(psk), Some(psk_expires_at)) = (&ok.psk, ok.psk_expires_at) {
info!(
actr_type = manifest.actr_type_str(),
psk_expires_at, "received PSK from AIS, storing in ActorStore"
);
let expires_at_bytes = (psk_expires_at as u64).to_le_bytes().to_vec();
store
.batch(vec![
KvOp::Set {
key: "hyper:psk:token".to_string(),
value: psk.to_vec(),
},
KvOp::Set {
key: "hyper:psk:expires_at".to_string(),
value: expires_at_bytes,
},
])
.await
.map_err(|e| HyperError::Storage(format!("failed to store PSK: {e}")))?;
debug!(
actr_type = manifest.actr_type_str(),
"PSK successfully persisted to ActorStore"
);
}
let pubkey_bytes = ok.signing_pubkey.to_vec();
let key_id_bytes = ok.signing_key_id.to_le_bytes().to_vec();
store
.batch(vec![
KvOp::Set {
key: "hyper:ais:signing_pubkey".to_string(),
value: pubkey_bytes,
},
KvOp::Set {
key: "hyper:ais:signing_key_id".to_string(),
value: key_id_bytes,
},
])
.await
.map_err(|e| HyperError::Storage(format!("failed to store signing key: {e}")))?;
debug!(
actr_type = manifest.actr_type_str(),
signing_key_id = ok.signing_key_id,
"AIS signing public key persisted to ActorStore"
);
info!(
actr_type = manifest.actr_type_str(),
credential_len = ok.credential.encode_to_vec().len(),
"AIS credential bootstrap succeeded"
);
Ok(ok)
}
#[cfg(not(target_arch = "wasm32"))]
async fn bootstrap_linked_credential_inner(
config: &actr_config::RuntimeConfig,
ais_endpoint: &str,
service_spec: Option<ServiceSpec>,
) -> HyperResult<register_response::RegisterOk> {
let mut ais = AisClient::new(ais_endpoint);
if let Some(ref secret) = config.realm_secret {
ais = ais.with_realm_secret(secret.clone());
}
let req = build_linked_register_request(config, service_spec);
let response = ais.register_linked(req).await?;
match response.result {
Some(register_response::Result::Success(ok)) => Ok(ok),
Some(register_response::Result::Error(e)) => Err(HyperError::AisBootstrapFailed(format!(
"AIS rejected registration (code={}): {}",
e.code, e.message
))),
None => Err(HyperError::AisBootstrapFailed(
"AIS response missing result field".to_string(),
)),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn build_linked_register_request(
config: &actr_config::RuntimeConfig,
service_spec: Option<ServiceSpec>,
) -> RegisterRequest {
let ws_address = if let Some(port) = config.websocket_listen_port {
let host = config
.websocket_advertised_host
.as_deref()
.unwrap_or("127.0.0.1");
Some(format!("ws://{}:{}", host, port))
} else {
None
};
RegisterRequest {
actr_type: config.actr_type().clone(),
realm: config.realm,
service_spec,
acl: config.acl.clone(),
service: None,
ws_address,
auth_mode: Some(RegisterAuthMode::Linked as i32),
..Default::default()
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn load_valid_psk_dyn(store: &dyn KvStore) -> HyperResult<Option<Vec<u8>>> {
let token = store
.get("hyper:psk:token")
.await
.map_err(|e| HyperError::Storage(format!("failed to read PSK token: {e}")))?;
let expires_at_raw = store
.get("hyper:psk:expires_at")
.await
.map_err(|e| HyperError::Storage(format!("failed to read PSK expires_at: {e}")))?;
check_psk_expiry(token, expires_at_raw)
}
#[cfg(all(not(target_arch = "wasm32"), test))]
async fn load_valid_psk(store: &ActorStore) -> HyperResult<Option<Vec<u8>>> {
let token = store.kv_get("hyper:psk:token").await?;
let expires_at_raw = store.kv_get("hyper:psk:expires_at").await?;
check_psk_expiry(token, expires_at_raw)
}
#[cfg(not(target_arch = "wasm32"))]
fn check_psk_expiry(
token: Option<Vec<u8>>,
expires_at_raw: Option<Vec<u8>>,
) -> HyperResult<Option<Vec<u8>>> {
match (token, expires_at_raw) {
(Some(token), Some(expires_bytes)) => {
if expires_bytes.len() != 8 {
warn!("PSK expires_at has unexpected format, falling back to first registration");
return Ok(None);
}
let expires_at = u64::from_le_bytes(expires_bytes.as_slice().try_into().unwrap());
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now_secs >= expires_at {
warn!(
psk_expires_at = expires_at,
now = now_secs,
"PSK expired, falling back to first registration"
);
Ok(None)
} else {
debug!(
psk_expires_at = expires_at,
now = now_secs,
remaining_secs = expires_at - now_secs,
"PSK valid, using PSK renewal path"
);
Ok(Some(token))
}
}
_ => {
debug!("no PSK in ActorStore, proceeding with first registration");
Ok(None)
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(target_arch = "wasm32"))]
fn detect_binary_kind(manifest: &PackageManifest) -> HyperResult<BinaryKind> {
if manifest.binary.is_wasm_target() {
return Ok(BinaryKind::Wasm);
}
if is_compatible_native_target(&manifest.binary.target) {
return Ok(BinaryKind::DynClib);
}
Err(HyperError::InvalidManifest(format!(
"unsupported binary target `{}` for host `{}-{}`; expected `wasm32-*` or a native target matching this host",
manifest.binary.target,
std::env::consts::ARCH,
std::env::consts::OS,
)))
}
#[cfg(not(target_arch = "wasm32"))]
fn is_compatible_native_target(target: &str) -> bool {
let segments: Vec<&str> = target.split('-').filter(|s| !s.is_empty()).collect();
if segments.len() < 3 {
return false;
}
let target_arch = segments[0];
let target_os = segments[2];
let arch_matches = match (target_arch, std::env::consts::ARCH) {
(a, b) if a == b => true,
("x86_64", "x86_64") => true,
("aarch64", "aarch64") => true,
_ => false,
};
let os_matches = match (target_os, std::env::consts::OS) {
(a, b) if a == b => true,
("darwin", "macos") | ("macos", "darwin") => true,
_ => false,
};
arch_matches && os_matches
}
#[cfg(all(
not(target_arch = "wasm32"),
feature = "dynclib-engine",
target_os = "macos"
))]
fn dynclib_tempfile_suffix() -> &'static str {
".dylib"
}
#[cfg(all(
not(target_arch = "wasm32"),
feature = "dynclib-engine",
target_os = "linux"
))]
fn dynclib_tempfile_suffix() -> &'static str {
".so"
}
#[cfg(all(
not(target_arch = "wasm32"),
feature = "dynclib-engine",
target_os = "windows"
))]
fn dynclib_tempfile_suffix() -> &'static str {
".dll"
}
#[cfg(all(
not(target_arch = "wasm32"),
feature = "dynclib-engine",
not(any(target_os = "macos", target_os = "linux", target_os = "windows"))
))]
fn dynclib_tempfile_suffix() -> &'static str {
".dynlib"
}
#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
const DYNCLIB_CACHE_DIR: &str = "dynclib-cache";
#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
fn dynclib_cache_dir(data_dir: &Path) -> PathBuf {
data_dir.join(DYNCLIB_CACHE_DIR)
}
#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
fn dynclib_cache_path(data_dir: &Path, binary_hash: &[u8; 32]) -> PathBuf {
dynclib_cache_dir(data_dir).join(format!(
"{}{}",
hex::encode(binary_hash),
dynclib_tempfile_suffix()
))
}
#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
fn extract_dynclib_binary(bytes: &[u8], manifest: &PackageManifest) -> HyperResult<Vec<u8>> {
actr_pack::load_binary(bytes).map_err(|e| {
HyperError::Runtime(format!(
"failed to extract package binary `{}` for target `{}`: {e}",
manifest.binary.path, manifest.binary.target
))
})
}
#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
fn write_dynclib_cache_file(cache_path: &Path, binary_bytes: &[u8]) -> HyperResult<()> {
let cache_dir = cache_path.parent().ok_or_else(|| {
HyperError::Runtime("dynclib cache path has no parent directory".to_string())
})?;
std::fs::create_dir_all(cache_dir).map_err(|e| {
HyperError::Runtime(format!(
"failed to create dynclib cache directory `{}`: {e}",
cache_dir.display()
))
})?;
let mut temp_file = tempfile::Builder::new()
.prefix("actr-dynclib-")
.tempfile_in(cache_dir)
.map_err(|e| {
HyperError::Runtime(format!(
"failed to allocate dynclib cache temp file in `{}`: {e}",
cache_dir.display()
))
})?;
temp_file.write_all(binary_bytes).map_err(|e| {
HyperError::Runtime(format!(
"failed to write dynclib cache temp file `{}`: {e}",
temp_file.path().display()
))
})?;
temp_file.flush().map_err(|e| {
HyperError::Runtime(format!(
"failed to flush dynclib cache temp file `{}`: {e}",
temp_file.path().display()
))
})?;
match temp_file.persist_noclobber(cache_path) {
Ok(_) => Ok(()),
Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(err) => Err(HyperError::Runtime(format!(
"failed to persist dynclib cache file `{}`: {}",
cache_path.display(),
err.error
))),
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
fn ensure_dynclib_cache_path(
data_dir: &Path,
bytes: &[u8],
manifest: &PackageManifest,
) -> HyperResult<PathBuf> {
let binary_hash = manifest
.binary
.hash_bytes()
.map_err(|e| HyperError::InvalidManifest(e.to_string()))?;
let cache_path = dynclib_cache_path(data_dir, &binary_hash);
if cache_path.exists() {
return Ok(cache_path);
}
let binary_bytes = extract_dynclib_binary(bytes, manifest)?;
write_dynclib_cache_file(&cache_path, &binary_bytes)?;
Ok(cache_path)
}
#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
fn rebuild_dynclib_cache_file(
cache_path: &Path,
bytes: &[u8],
manifest: &PackageManifest,
) -> HyperResult<()> {
match std::fs::remove_file(cache_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(HyperError::Runtime(format!(
"failed to remove corrupt dynclib cache file `{}`: {err}",
cache_path.display()
)));
}
}
let binary_bytes = extract_dynclib_binary(bytes, manifest)?;
write_dynclib_cache_file(cache_path, &binary_bytes)
}
#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
fn load_dynclib_host_with_rebuild(
cache_path: &Path,
bytes: &[u8],
manifest: &PackageManifest,
) -> HyperResult<crate::dynclib::DynclibHost> {
match crate::dynclib::DynclibHost::load(cache_path) {
Ok(host) => Ok(host),
Err(first_err) => {
warn!(
path = %cache_path.display(),
target = %manifest.binary.target,
error = %first_err,
"cached dynclib load failed, rebuilding cache once"
);
rebuild_dynclib_cache_file(cache_path, bytes, manifest)?;
crate::dynclib::DynclibHost::load(cache_path).map_err(|second_err| {
HyperError::Runtime(format!(
"failed to load dynclib package target `{}` from cache `{}` after rebuild; first load error: {first_err}; second load error: {second_err}",
manifest.binary.target,
cache_path.display()
))
})
}
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn load_or_create_instance_uid_local(data_dir: &std::path::Path) -> HyperResult<String> {
let id_file = data_dir.join(".hyper-instance-uid");
if id_file.exists() {
let id = tokio::fs::read_to_string(&id_file)
.await
.map_err(|e| HyperError::Storage(format!("failed to read instance_uid file: {e}")))?;
let id = id.trim().to_string();
if !id.is_empty() {
return Ok(id);
}
warn!("instance_uid file is empty; generating a new one");
}
let new_id = Uuid::new_v4().to_string();
tokio::fs::write(&id_file, &new_id)
.await
.map_err(|e| HyperError::Storage(format!("failed to write instance_uid file: {e}")))?;
info!(instance_uid = %new_id, "generated a new Hyper instance_uid");
Ok(new_id)
}
#[cfg(all(not(target_arch = "wasm32"), test))]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
#[cfg(feature = "dynclib-engine")]
use std::sync::{Arc, Barrier};
use tempfile::TempDir;
fn dev_config(dir: &TempDir) -> HyperConfig {
let signing_key = SigningKey::generate(&mut OsRng);
let pubkey = signing_key.verifying_key().to_bytes();
HyperConfig::new(
dir.path(),
Arc::new(crate::verify::StaticTrust::new(pubkey).unwrap()),
)
}
#[tokio::test]
async fn init_creates_data_dir_and_instance_id() {
let dir = TempDir::new().unwrap();
let sub = dir.path().join("subdir/nested");
let signing_key = SigningKey::generate(&mut OsRng);
let config = HyperConfig::new(
&sub,
Arc::new(
crate::verify::StaticTrust::new(signing_key.verifying_key().to_bytes()).unwrap(),
),
);
let hyper = Hyper::new(config).await.unwrap();
assert!(sub.exists());
assert!(!hyper.instance_id().is_empty());
}
#[tokio::test]
async fn instance_id_is_stable_across_reinit() {
let dir = TempDir::new().unwrap();
let config1 = dev_config(&dir);
let hyper1 = Hyper::new(config1).await.unwrap();
let id1 = hyper1.instance_id().to_string();
let config2 = dev_config(&dir);
let hyper2 = Hyper::new(config2).await.unwrap();
let id2 = hyper2.instance_id().to_string();
assert_eq!(id1, id2, "instance_id should remain stable across restarts");
}
#[tokio::test]
async fn verify_package_rejects_non_wasm() {
let dir = TempDir::new().unwrap();
let hyper = Hyper::new(dev_config(&dir)).await.unwrap();
let result = hyper
.verify_package(&WorkloadPackage::new(b"not a wasm file".to_vec()))
.await;
assert!(matches!(result, Err(HyperError::InvalidManifest(_))));
}
#[tokio::test]
async fn verify_package_rejects_non_actr_format() {
let dir = TempDir::new().unwrap();
let hyper = Hyper::new(dev_config(&dir)).await.unwrap();
let result = hyper
.verify_package(&WorkloadPackage::new(b"\0asm\x01\x00\x00\x00".to_vec()))
.await;
assert!(matches!(result, Err(HyperError::InvalidManifest(_))));
}
async fn open_test_store(dir: &TempDir) -> ActorStore {
let db_path = dir.path().join("test.db");
ActorStore::open(&db_path).await.unwrap()
}
#[tokio::test]
async fn psk_valid_returns_token() {
let dir = TempDir::new().unwrap();
let store = open_test_store(&dir).await;
let psk_token = b"test-psk-secret".to_vec();
let expires_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600;
store.kv_set("hyper:psk:token", &psk_token).await.unwrap();
store
.kv_set("hyper:psk:expires_at", &expires_at.to_le_bytes())
.await
.unwrap();
let result = load_valid_psk(&store).await.unwrap();
assert_eq!(result, Some(psk_token), "A valid PSK should be returned");
}
#[tokio::test]
async fn psk_expired_returns_none() {
let dir = TempDir::new().unwrap();
let store = open_test_store(&dir).await;
let psk_token = b"expired-psk".to_vec();
let expires_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(1);
store.kv_set("hyper:psk:token", &psk_token).await.unwrap();
store
.kv_set("hyper:psk:expires_at", &expires_at.to_le_bytes())
.await
.unwrap();
let result = load_valid_psk(&store).await.unwrap();
assert_eq!(result, None, "An expired PSK should return None");
}
#[tokio::test]
async fn psk_absent_returns_none() {
let dir = TempDir::new().unwrap();
let store = open_test_store(&dir).await;
let result = load_valid_psk(&store).await.unwrap();
assert_eq!(result, None, "Missing PSK should return None");
}
#[tokio::test]
async fn psk_missing_expires_at_returns_none() {
let dir = TempDir::new().unwrap();
let store = open_test_store(&dir).await;
store
.kv_set("hyper:psk:token", b"orphan-token")
.await
.unwrap();
let result = load_valid_psk(&store).await.unwrap();
assert_eq!(result, None, "Missing expires_at should return None");
}
fn fake_manifest() -> VerifiedPackage {
VerifiedPackage {
manifest: actr_pack::PackageManifest {
manufacturer: "test-mfr".to_string(),
name: "TestActor".to_string(),
version: "0.1.0".to_string(),
binary: actr_pack::BinaryEntry {
path: "bin/actor.wasm".to_string(),
target: "wasm32-wasip1".to_string(),
hash: "0".repeat(64),
size: None,
kind: None,
},
signature_algorithm: "ed25519".to_string(),
signing_key_id: None,
resources: vec![],
proto_files: vec![],
lock_file: None,
metadata: actr_pack::ManifestMetadata::default(),
},
manifest_raw: vec![],
sig_raw: vec![0u8; 64],
}
}
fn fake_register_response_bytes(with_psk: bool) -> Vec<u8> {
use actr_protocol::{
AIdCredential, ActrId, ActrType, IdentityClaims, Realm, RegisterResponse,
TurnCredential, register_response,
};
let claims = IdentityClaims {
realm_id: 1,
actor_id: "test-actor-id".to_string(),
expires_at: u64::MAX,
};
let claims_bytes = claims.encode_to_vec();
let credential = AIdCredential {
key_id: 1,
claims: claims_bytes.into(),
signature: vec![0u8; 64].into(),
};
let actr_id = ActrId {
realm: Realm { realm_id: 1 },
serial_number: 42,
r#type: ActrType {
manufacturer: "test-mfr".to_string(),
name: "TestActor".to_string(),
version: "0.1.0".to_string(),
},
};
let turn = TurnCredential {
username: "user".to_string(),
password: "pass".to_string(),
expires_at: u64::MAX,
};
let mut ok = register_response::RegisterOk {
actr_id,
credential,
turn_credential: turn,
credential_expires_at: None,
signaling_heartbeat_interval_secs: 30,
signing_pubkey: vec![0u8; 32].into(),
signing_key_id: 1,
psk: None,
psk_expires_at: None,
};
if with_psk {
ok.psk = Some(b"fresh-psk-from-ais".to_vec().into());
ok.psk_expires_at = Some(
(SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 86400) as i64,
);
}
RegisterResponse {
result: Some(register_response::Result::Success(ok)),
}
.encode_to_vec()
}
fn test_service_spec() -> Option<ServiceSpec> {
Some(ServiceSpec {
name: "EchoService".to_string(),
description: Some("test service".to_string()),
fingerprint: "fp-123".to_string(),
protobufs: vec![],
published_at: None,
tags: vec!["latest".to_string()],
})
}
fn test_acl() -> Option<Acl> {
Some(Acl { rules: vec![] })
}
fn linked_runtime_config(dir: &TempDir) -> actr_config::RuntimeConfig {
actr_config::RuntimeConfig {
package: actr_config::PackageInfo {
name: "LinkedActor".to_string(),
actr_type: actr_protocol::ActrType {
manufacturer: "test-mfr".to_string(),
name: "LinkedActor".to_string(),
version: "0.1.0".to_string(),
},
description: None,
authors: vec![],
license: None,
},
signaling_url: url::Url::parse("ws://localhost:8081/signaling/ws").unwrap(),
realm: Realm { realm_id: 7 },
ais_endpoint: "http://localhost:8081/ais".to_string(),
realm_secret: Some("test-realm-secret".to_string()),
visible_in_discovery: true,
acl: test_acl(),
mailbox_path: None,
scripts: std::collections::HashMap::new(),
webrtc: actr_config::WebRtcConfig::default(),
websocket_listen_port: Some(9100),
websocket_advertised_host: Some("127.0.0.1".to_string()),
observability: actr_config::ObservabilityConfig {
filter_level: "info".to_string(),
tracing_enabled: false,
tracing_endpoint: "http://localhost:4317".to_string(),
tracing_service_name: "linked-test".to_string(),
},
config_dir: dir.path().to_path_buf(),
trust: vec![],
package_path: None,
web: None,
}
}
#[test]
fn linked_register_request_uses_linked_auth_mode() {
let dir = TempDir::new().unwrap();
let req = build_linked_register_request(&linked_runtime_config(&dir), test_service_spec());
assert_eq!(req.auth_mode, Some(RegisterAuthMode::Linked as i32));
assert_eq!(req.manifest_raw, None);
assert_eq!(req.mfr_signature, None);
assert_eq!(req.psk_token, None);
assert_eq!(req.ws_address.as_deref(), Some("ws://127.0.0.1:9100"));
}
#[test]
fn compatible_native_target_matches_current_host() {
let current = format!(
"{}-unknown-{}",
std::env::consts::ARCH,
if std::env::consts::OS == "macos" {
"darwin"
} else {
std::env::consts::OS
}
);
assert!(
is_compatible_native_target(¤t),
"current host target `{current}` should be compatible"
);
}
#[test]
fn compatible_native_target_rejects_cross_platform() {
assert!(!is_compatible_native_target("riscv64gc-unknown-linux-gnu"));
assert!(!is_compatible_native_target("s390x-unknown-linux-gnu"));
}
#[test]
fn compatible_native_target_rejects_short_triples() {
assert!(!is_compatible_native_target("invalid-target"));
assert!(!is_compatible_native_target("single"));
assert!(!is_compatible_native_target(""));
}
#[cfg(feature = "dynclib-engine")]
fn fake_dynclib_manifest() -> PackageManifest {
let target = format!(
"{}-unknown-{}",
std::env::consts::ARCH,
if std::env::consts::OS == "macos" {
"darwin"
} else {
std::env::consts::OS
}
);
PackageManifest {
manufacturer: "test-mfr".to_string(),
name: "DynActor".to_string(),
version: "1.0.0".to_string(),
binary: actr_pack::BinaryEntry {
path: format!("bin/actor{}", dynclib_tempfile_suffix()),
target,
hash: String::new(),
size: None,
kind: None,
},
signature_algorithm: "ed25519".to_string(),
signing_key_id: None,
resources: vec![],
proto_files: vec![],
lock_file: None,
metadata: actr_pack::ManifestMetadata::default(),
}
}
#[cfg(feature = "dynclib-engine")]
fn fake_dynclib_package_bytes(binary_bytes: &[u8]) -> (Vec<u8>, PackageManifest) {
let manifest = fake_dynclib_manifest();
let signing_key = SigningKey::generate(&mut OsRng);
let package_bytes = actr_pack::pack(&actr_pack::PackOptions {
manifest: manifest.clone(),
binary_bytes: binary_bytes.to_vec(),
resources: vec![],
proto_files: vec![],
lock_file: None,
signing_key,
})
.unwrap();
let packed_manifest = actr_pack::read_manifest(&package_bytes).unwrap();
(package_bytes, packed_manifest)
}
#[cfg(feature = "dynclib-engine")]
#[test]
fn dynclib_cache_path_uses_hash_and_platform_suffix() {
let dir = TempDir::new().unwrap();
let path = dynclib_cache_path(dir.path(), &[0xAB; 32]);
assert_eq!(path.parent().unwrap(), dynclib_cache_dir(dir.path()));
assert_eq!(
path.file_name().unwrap().to_string_lossy(),
format!("{}{}", hex::encode([0xAB; 32]), dynclib_tempfile_suffix())
);
}
#[cfg(feature = "dynclib-engine")]
#[test]
fn ensure_dynclib_cache_path_preserves_existing_file() {
let dir = TempDir::new().unwrap();
let initial_binary_bytes = b"initial dylib bytes";
let (initial_package_bytes, manifest) = fake_dynclib_package_bytes(initial_binary_bytes);
let cache_path =
ensure_dynclib_cache_path(dir.path(), &initial_package_bytes, &manifest).unwrap();
let second_path =
ensure_dynclib_cache_path(dir.path(), &initial_package_bytes, &manifest).unwrap();
assert_eq!(cache_path, second_path);
assert_eq!(std::fs::read(&cache_path).unwrap(), initial_binary_bytes);
}
#[cfg(feature = "dynclib-engine")]
#[test]
fn ensure_dynclib_cache_path_handles_concurrent_creation() {
let dir = TempDir::new().unwrap();
let binary_bytes = b"shared dylib bytes".to_vec();
let (package_bytes, manifest) = fake_dynclib_package_bytes(&binary_bytes);
let package_bytes = Arc::new(package_bytes);
let binary_bytes = Arc::new(binary_bytes);
let data_dir = Arc::new(dir.path().to_path_buf());
let barrier = Arc::new(Barrier::new(3));
let handles: Vec<_> = (0..2)
.map(|_| {
let barrier = Arc::clone(&barrier);
let data_dir = Arc::clone(&data_dir);
let manifest = manifest.clone();
let package_bytes = Arc::clone(&package_bytes);
std::thread::spawn(move || {
barrier.wait();
ensure_dynclib_cache_path(&data_dir, &package_bytes, &manifest)
})
})
.collect();
barrier.wait();
let results: Vec<_> = handles
.into_iter()
.map(|handle| handle.join().unwrap().unwrap())
.collect();
assert_eq!(results[0], results[1]);
assert_eq!(
std::fs::read(&results[0]).unwrap(),
binary_bytes.as_ref().as_slice()
);
}
#[tokio::test]
async fn bootstrap_first_registration_stores_psk() {
let response_body = fake_register_response_bytes(true);
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/register")
.with_status(200)
.with_header("content-type", "application/x-protobuf")
.with_body(response_body)
.create_async()
.await;
let dir = TempDir::new().unwrap();
let config = dev_config(&dir);
let hyper = Hyper::new(config).await.unwrap();
let manifest = fake_manifest();
let result = hyper
.bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
.await;
mock.assert_async().await;
assert!(
result.is_ok(),
"Initial registration should succeed, got: {:?}",
result.err()
);
let storage_path = hyper.resolve_storage_path(&manifest.manifest).unwrap();
let store = ActorStore::open(&storage_path).await.unwrap();
let psk = store.kv_get("hyper:psk:token").await.unwrap();
assert!(
psk.is_some(),
"PSK should be stored in ActorStore after initial registration"
);
assert_eq!(psk.unwrap(), b"fresh-psk-from-ais".to_vec());
}
#[tokio::test]
async fn bootstrap_psk_renewal_skips_manifest() {
let response_body = fake_register_response_bytes(false);
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/register")
.with_status(200)
.with_header("content-type", "application/x-protobuf")
.with_body(response_body)
.expect(1) .create_async()
.await;
let dir = TempDir::new().unwrap();
let config = dev_config(&dir);
let hyper = Hyper::new(config).await.unwrap();
let manifest = fake_manifest();
let storage_path = hyper.resolve_storage_path(&manifest.manifest).unwrap();
let store = ActorStore::open(&storage_path).await.unwrap();
let expires_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600;
store
.kv_set("hyper:psk:token", b"existing-valid-psk")
.await
.unwrap();
store
.kv_set("hyper:psk:expires_at", &expires_at.to_le_bytes())
.await
.unwrap();
let result = hyper
.bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
.await;
mock.assert_async().await;
assert!(
result.is_ok(),
"PSK renewal should succeed, got: {:?}",
result.err()
);
}
#[tokio::test]
async fn bootstrap_expired_psk_falls_back_to_manifest() {
let response_body = fake_register_response_bytes(true);
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/register")
.with_status(200)
.with_header("content-type", "application/x-protobuf")
.with_body(response_body)
.expect(1)
.create_async()
.await;
let dir = TempDir::new().unwrap();
let config = dev_config(&dir);
let hyper = Hyper::new(config).await.unwrap();
let manifest = fake_manifest();
let storage_path = hyper.resolve_storage_path(&manifest.manifest).unwrap();
let store = ActorStore::open(&storage_path).await.unwrap();
let expired_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(10); store
.kv_set("hyper:psk:token", b"expired-psk")
.await
.unwrap();
store
.kv_set("hyper:psk:expires_at", &expired_at.to_le_bytes())
.await
.unwrap();
let result = hyper
.bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
.await;
mock.assert_async().await;
assert!(
result.is_ok(),
"Manifest registration should succeed after PSK expiration, got: {:?}",
result.err()
);
}
#[tokio::test]
async fn bootstrap_ais_error_propagates() {
use actr_protocol::{ErrorResponse, RegisterResponse, register_response};
let error_resp = RegisterResponse {
result: Some(register_response::Result::Error(ErrorResponse {
code: 403,
message: "manufacturer not trusted".to_string(),
})),
}
.encode_to_vec();
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/register")
.with_status(200)
.with_header("content-type", "application/x-protobuf")
.with_body(error_resp)
.create_async()
.await;
let dir = TempDir::new().unwrap();
let config = dev_config(&dir);
let hyper = Hyper::new(config).await.unwrap();
let manifest = fake_manifest();
let result = hyper
.bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
.await;
assert!(
matches!(result, Err(HyperError::AisBootstrapFailed(_))),
"AIS errors should propagate as AisBootstrapFailed, got: {:?}",
result
);
}
}