use async_trait::async_trait;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tracing::info;
use wasmtime::Store;
use wasmtime::component::{Component, Linker};
use crate::context::CapsuleContext;
use crate::engine::ExecutionEngine;
use crate::engine::wasm::host_state::{HostState, LifecyclePhase, PrincipalMount};
use crate::error::{CapsuleError, CapsuleResult};
use crate::manifest::CapsuleManifest;
pub mod bindings;
pub mod host;
pub mod host_state;
#[cfg(test)]
mod test_fixtures;
fn today_date_string() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let days = secs / 86400;
let (y, m, d) = civil_from_days(days as i64);
format!("{y:04}-{m:02}-{d:02}")
}
#[expect(clippy::arithmetic_side_effects)]
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = (yoe as i64) + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn prune_old_logs(log_dir: &std::path::Path, max_days: u64) {
let cutoff = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(max_days * 86400))
.unwrap_or(std::time::UNIX_EPOCH);
let Ok(entries) = std::fs::read_dir(log_dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.ends_with(".log") || name_str.len() != 14 {
continue;
}
if let Ok(meta) = entry.metadata()
&& let Ok(modified) = meta.modified()
&& modified < cutoff
{
let _ = std::fs::remove_file(entry.path());
}
}
}
fn read_expected_wasm_hash(capsule_dir: &std::path::Path) -> Option<String> {
let meta_path = capsule_dir.join("meta.json");
let content = std::fs::read_to_string(&meta_path).ok()?;
let meta: serde_json::Value = serde_json::from_str(&content).ok()?;
meta.get("wasm_hash")?.as_str().map(String::from)
}
fn resolve_content_addressed_wasm(capsule_dir: &std::path::Path) -> Option<PathBuf> {
let meta_path = capsule_dir.join("meta.json");
let content = std::fs::read_to_string(&meta_path).ok()?;
let meta: serde_json::Value = serde_json::from_str(&content).ok()?;
let hash = meta.get("wasm_hash")?.as_str()?;
let home = astrid_core::dirs::AstridHome::resolve().ok()?;
let wasm_path = home.bin_dir().join(format!("{hash}.wasm"));
if wasm_path.exists() {
Some(wasm_path)
} else {
None
}
}
fn read_baked_schemas(
capsule_dir: &std::path::Path,
) -> std::collections::HashMap<String, serde_json::Value> {
let meta_path = capsule_dir.join("meta.json");
let content = match std::fs::read_to_string(&meta_path) {
Ok(c) => c,
Err(_) => return std::collections::HashMap::new(),
};
let meta: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return std::collections::HashMap::new(),
};
let mut schemas = std::collections::HashMap::new();
if let Some(topics) = meta.get("topics").and_then(|t| t.as_array()) {
for topic in topics {
if let (Some(name), Some(schema)) = (
topic.get("name").and_then(|n| n.as_str()),
topic.get("schema").filter(|s| !s.is_null()),
) {
schemas.insert(name.to_string(), schema.clone());
}
}
}
schemas
}
const WASM_CAPSULE_TIMEOUT_SECS: u64 = 5 * 60;
const EPOCH_TICK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
pub struct WasmEngine {
manifest: CapsuleManifest,
_capsule_dir: PathBuf,
wasmtime_engine: Option<wasmtime::Engine>,
store: Option<Arc<Mutex<Store<HostState>>>>,
instance: Option<wasmtime::component::Instance>,
inbound_rx: Option<tokio::sync::mpsc::Receiver<astrid_core::InboundMessage>>,
run_handle: Option<tokio::task::JoinHandle<()>>,
ready_rx: Option<tokio::sync::Mutex<tokio::sync::watch::Receiver<bool>>>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
epoch_ticker: Option<EpochTickerGuard>,
profile_cache: Option<Arc<crate::profile_cache::PrincipalProfileCache>>,
owner_principal: Option<astrid_core::PrincipalId>,
overlay_registry: Option<Arc<astrid_vfs::OverlayVfsRegistry>>,
}
impl WasmEngine {
pub fn new(manifest: CapsuleManifest, capsule_dir: PathBuf) -> Self {
Self {
manifest,
_capsule_dir: capsule_dir,
wasmtime_engine: None,
store: None,
instance: None,
inbound_rx: None,
run_handle: None,
ready_rx: None,
cancel_token: None,
epoch_ticker: None,
profile_cache: None,
owner_principal: None,
overlay_registry: None,
}
}
}
const WASM_MAX_MEMORY_BYTES: usize = 64 * 1024 * 1024;
pub fn configure_kernel_linker(
linker: &mut wasmtime::component::Linker<HostState>,
) -> wasmtime::Result<()> {
bindings::Kernel::add_to_linker::<HostState, wasmtime::component::HasSelf<HostState>>(
linker,
|state| state,
)
}
fn build_wasmtime_engine() -> CapsuleResult<wasmtime::Engine> {
let mut config = wasmtime::Config::new();
config.wasm_component_model(true).epoch_interruption(true);
wasmtime::Engine::new(&config).map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!("Failed to create wasmtime engine: {e}"))
})
}
fn build_wasi_ctx() -> wasmtime_wasi::WasiCtx {
wasmtime_wasi::WasiCtxBuilder::new()
.inherit_stderr()
.build()
}
#[derive(Default)]
pub(crate) struct PrincipalVfsBundle {
home: Option<PrincipalMount>,
tmp: Option<PrincipalMount>,
}
pub(crate) fn mount_dir(root: &std::path::Path) -> Option<PrincipalMount> {
if !root.exists() {
return None;
}
let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let vfs = astrid_vfs::HostVfs::new();
let handle = astrid_capabilities::DirHandle::new();
match tokio::runtime::Handle::current()
.block_on(async { vfs.register_dir(handle.clone(), canonical.clone()).await })
{
Ok(()) => Some(PrincipalMount {
root: canonical,
vfs: Arc::new(vfs) as Arc<dyn astrid_vfs::Vfs>,
handle,
}),
Err(e) => {
tracing::warn!(
root = %canonical.display(),
error = %e,
"failed to register principal VFS; denying scheme access",
);
None
},
}
}
pub(crate) fn build_principal_vfs_bundle(
principal: &astrid_core::PrincipalId,
) -> PrincipalVfsBundle {
let Ok(astrid_home) = astrid_core::dirs::AstridHome::resolve() else {
return PrincipalVfsBundle::default();
};
build_principal_vfs_bundle_at(&astrid_home.principal_home(principal))
}
pub(crate) fn open_capsule_log(
principal: &astrid_core::PrincipalId,
capsule_name: &str,
prune: bool,
) -> Option<Arc<Mutex<std::fs::File>>> {
let astrid_home = astrid_core::dirs::AstridHome::resolve().ok()?;
open_capsule_log_at(&astrid_home.principal_home(principal), capsule_name, prune)
}
fn open_capsule_log_at(
ph: &astrid_core::dirs::PrincipalHome,
capsule_name: &str,
prune: bool,
) -> Option<Arc<Mutex<std::fs::File>>> {
if !ph.root().exists() {
return None;
}
let log_dir = ph.log_dir().join(capsule_name);
std::fs::create_dir_all(&log_dir).ok()?;
if prune {
prune_old_logs(&log_dir, 7);
}
let today = today_date_string();
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_dir.join(format!("{today}.log")))
.ok()
.map(|f| Arc::new(Mutex::new(f)))
}
fn build_principal_vfs_bundle_at(ph: &astrid_core::dirs::PrincipalHome) -> PrincipalVfsBundle {
let home = mount_dir(ph.root());
let tmp = home.as_ref().and_then(|_| {
let t = ph.tmp_dir();
if t.exists() || std::fs::create_dir_all(&t).is_ok() {
mount_dir(&t)
} else {
None
}
});
PrincipalVfsBundle { home, tmp }
}
fn check_principal_enabled(
profile: &astrid_core::profile::PrincipalProfile,
invoking: &astrid_core::PrincipalId,
capsule_name: &str,
action: &str,
) -> Result<(), CapsuleError> {
if profile.enabled {
return Ok(());
}
tracing::warn!(
security_event = true,
principal = %invoking,
capsule = %capsule_name,
action = action,
"Disabled principal denied at Layer 3 — fail-closed (issue #672)"
);
Err(CapsuleError::WasmError(format!(
"principal '{invoking}' is disabled"
)))
}
pub struct EpochTickerGuard {
handle: Option<std::thread::JoinHandle<()>>,
stop: Arc<std::sync::atomic::AtomicBool>,
}
impl Drop for EpochTickerGuard {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
fn spawn_epoch_ticker(engine: &wasmtime::Engine) -> EpochTickerGuard {
let engine = engine.clone();
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_clone = stop.clone();
let handle = std::thread::Builder::new()
.name("wasm-epoch-ticker".into())
.spawn(move || {
while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(EPOCH_TICK_INTERVAL);
engine.increment_epoch();
}
})
.expect("failed to spawn epoch ticker thread");
EpochTickerGuard {
handle: Some(handle),
stop,
}
}
#[async_trait]
impl ExecutionEngine for WasmEngine {
async fn load(&mut self, ctx: &CapsuleContext) -> CapsuleResult<()> {
info!(
capsule = %self.manifest.package.name,
"Loading WASM component (Component Model)"
);
let component = self.manifest.components.first().ok_or_else(|| {
CapsuleError::UnsupportedEntryPoint(
"WASM engine requires at least one component definition".into(),
)
})?;
let wasm_path = if component.path.is_absolute() {
component.path.clone()
} else {
let local = self._capsule_dir.join(&component.path);
if local.exists() {
local
} else {
resolve_content_addressed_wasm(&self._capsule_dir).unwrap_or(local)
}
};
let workspace_root = ctx.workspace_root.clone();
let kv = ctx.kv.clone();
let event_bus = astrid_events::EventBus::clone(&ctx.event_bus);
let manifest = self.manifest.clone();
let mut wasm_config = std::collections::HashMap::new();
if let Ok(astrid_home) = astrid_core::dirs::AstridHome::resolve() {
wasm_config.insert(
"ASTRID_SOCKET_PATH".to_string(),
serde_json::Value::String(astrid_home.socket_path().to_string_lossy().into_owned()),
);
}
let reserved_keys: Vec<String> = wasm_config.keys().cloned().collect();
let resolved_env =
super::resolve_env(&self.manifest, ctx, &reserved_keys, "wasm_engine").await?;
for (key, val) in resolved_env {
wasm_config.insert(key, serde_json::Value::String(val));
}
let capsule_uuid = uuid::Uuid::new_v4();
let host_semaphore = HostState::default_host_semaphore();
let cancel_token = tokio_util::sync::CancellationToken::new();
let cancel_token_for_state = cancel_token.clone();
let process_tracker = Arc::new(crate::engine::wasm::host::process::ProcessTracker::new());
let process_tracker_for_listener = process_tracker.clone();
let capsule_dir_for_verify = self._capsule_dir.clone();
let (store_arc, instance, rx, has_run, ready_rx, wt_engine) =
tokio::task::block_in_place(move || {
let wasm_bytes = std::fs::read(&wasm_path).map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!("Failed to read WASM: {e}"))
})?;
let actual_hash = blake3::hash(&wasm_bytes).to_hex().to_string();
match read_expected_wasm_hash(&capsule_dir_for_verify) {
Some(expected_hash) if actual_hash == expected_hash => {
},
Some(expected_hash) => {
return Err(CapsuleError::UnsupportedEntryPoint(format!(
"WASM integrity check failed: expected BLAKE3 {expected_hash}, \
got {actual_hash}. The binary may have been tampered with."
)));
},
None => {
return Err(CapsuleError::UnsupportedEntryPoint(format!(
"WASM capsule '{}' has no BLAKE3 hash in meta.json. \
Capsules must be installed via `astrid capsule install` \
which records the hash. Refusing to load unverified binary.",
manifest.package.name
)));
},
}
let (tx, rx) = if !manifest.uplinks.is_empty() {
let (tx, rx) = tokio::sync::mpsc::channel(128);
(Some(tx), Some(rx))
} else {
(None, None)
};
let lower_vfs = astrid_vfs::HostVfs::new();
let upper_vfs = astrid_vfs::HostVfs::new();
let root_handle = astrid_capabilities::DirHandle::new();
let home_root = ctx.home_root.clone();
let upper_temp = tempfile::TempDir::new().map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!(
"Failed to create overlay temp dir: {e}"
))
})?;
tokio::runtime::Handle::current()
.block_on(async {
lower_vfs
.register_dir(root_handle.clone(), workspace_root.clone())
.await?;
upper_vfs
.register_dir(root_handle.clone(), upper_temp.path().to_path_buf())
.await?;
Ok::<(), astrid_vfs::VfsError>(())
})
.map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!(
"Failed to register VFS directory: {e}"
))
})?;
let home_mount: Option<PrincipalMount> = match home_root.as_deref() {
Some(g_root) if !g_root.exists() => {
tracing::warn!(
home_root = %g_root.display(),
"home:// VFS not mounted: directory does not exist. \
Capsules requesting home:// paths will receive errors \
until the directory is created and the kernel is restarted."
);
None
},
Some(g_root) => mount_dir(g_root),
None => None,
};
let overlay_vfs = Arc::new(astrid_vfs::OverlayVfs::new(
Box::new(lower_vfs),
Box::new(upper_vfs),
));
let gate_home_root = home_mount.as_ref().map(|m| m.root.clone());
let security_gate = Arc::new(crate::security::ManifestSecurityGate::new(
manifest.clone(),
workspace_root.clone(),
gate_home_root,
));
let tmp_mount: Option<PrincipalMount> = astrid_core::dirs::AstridHome::resolve()
.ok()
.and_then(|astrid_home| {
let dir = astrid_home.principal_home(&ctx.principal).tmp_dir();
if dir.exists() || std::fs::create_dir_all(&dir).is_ok() {
mount_dir(&dir)
} else {
None
}
});
let capsule_log = open_capsule_log(&ctx.principal, &manifest.package.name, true);
let secret_store = astrid_storage::build_secret_store(
&manifest.package.name,
kv.clone(),
tokio::runtime::Handle::current(),
);
let host_state = HostState {
wasi_ctx: build_wasi_ctx(),
resource_table: wasmtime::component::ResourceTable::new(),
store_limits: wasmtime::StoreLimitsBuilder::new()
.memory_size(WASM_MAX_MEMORY_BYTES)
.build(),
principal: ctx.principal.clone(),
capsule_uuid,
caller_context: None,
invocation_kv: None,
capsule_log,
capsule_id: crate::capsule::CapsuleId::new(&manifest.package.name)
.map_err(|e| CapsuleError::UnsupportedEntryPoint(e.to_string()))?,
workspace_root,
vfs: Arc::clone(&overlay_vfs) as Arc<dyn astrid_vfs::Vfs>,
vfs_root_handle: root_handle,
home: home_mount,
tmp: tmp_mount,
invocation_home: None,
invocation_tmp: None,
invocation_secret_store: None,
invocation_capsule_log: None,
invocation_profile: None,
overlay_vfs: Some(overlay_vfs),
upper_dir: Some(Arc::new(upper_temp)),
kv,
event_bus,
ipc_limiter: astrid_events::ipc::IpcRateLimiter::new(),
config: wasm_config,
secret_env: manifest
.env
.iter()
.filter(|(_, d)| d.env_type.eq_ignore_ascii_case("secret"))
.map(|(k, _)| k.clone())
.collect(),
ipc_publish_patterns: manifest.effective_ipc_publish_patterns(),
ipc_subscribe_patterns: manifest.effective_ipc_subscribe_patterns(),
cli_socket_listener: if manifest.capabilities.net_bind.is_empty() {
None
} else {
ctx.cli_socket_listener.clone()
},
active_http_streams: std::collections::HashMap::new(),
next_http_stream_id: 1,
security: Some(security_gate),
hook_manager: None, capsule_registry: ctx.capsule_registry.clone(),
runtime_handle: tokio::runtime::Handle::current(),
has_uplink_capability: manifest.capabilities.uplink,
inbound_tx: tx,
registered_uplinks: Vec::new(),
lifecycle_phase: None,
secret_store,
ready_tx: None,
host_semaphore,
cancel_token: cancel_token_for_state,
session_token: if manifest.capabilities.net_bind.is_empty() {
None
} else {
ctx.session_token.clone()
},
interceptor_handles: Vec::new(),
allowance_store: ctx.allowance_store.clone(),
identity_store: ctx.identity_store.clone(),
process_tracker: process_tracker.clone(),
net_stream_count: 0,
subscription_count: 0,
process_count_total: 0,
process_count_by_principal: std::collections::HashMap::new(),
};
let has_run_export = wasm_exports_contain_run(&wasm_bytes);
let wt_engine = build_wasmtime_engine()?;
let mut store = Store::new(&wt_engine, host_state);
store.limiter(|state| &mut state.store_limits);
let is_daemon = !manifest.uplinks.is_empty() || manifest.capabilities.uplink;
if !is_daemon && !has_run_export {
let deadline =
WASM_CAPSULE_TIMEOUT_SECS * 1000 / EPOCH_TICK_INTERVAL.as_millis() as u64;
store.set_epoch_deadline(deadline);
} else {
store.set_epoch_deadline(u64::MAX);
}
let mut linker: Linker<HostState> = Linker::new(&wt_engine);
configure_kernel_linker(&mut linker).map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!(
"Failed to add Astrid host to linker: {e}"
))
})?;
let wasm_component =
Component::from_binary(&wt_engine, &wasm_bytes).map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!(
"Failed to compile WASM component: {e}"
))
})?;
let instance = linker
.instantiate(&mut store, &wasm_component)
.map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!(
"Failed to instantiate WASM component: {e}"
))
})?;
let has_run = has_run_export;
let store_arc = Arc::new(Mutex::new(store));
let ready_rx = if has_run {
let (ready_tx, ready_rx) = tokio::sync::watch::channel(false);
let mut s = store_arc.lock().map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!("Store lock poisoned: {e}"))
})?;
s.data_mut().ready_tx = Some(ready_tx);
Some(ready_rx)
} else {
None
};
let effective_interceptors = manifest.effective_interceptors();
if has_run && !effective_interceptors.is_empty() {
const MAX_AUTO_SUBSCRIBE: usize = 64;
if effective_interceptors.len() > MAX_AUTO_SUBSCRIBE {
return Err(CapsuleError::UnsupportedEntryPoint(format!(
"Capsule '{}' declares {} interceptors, exceeding the \
auto-subscribe limit ({MAX_AUTO_SUBSCRIBE})",
manifest.package.name,
effective_interceptors.len()
)));
}
for interceptor in &effective_interceptors {
if !crate::topic::has_valid_segments(&interceptor.event) {
return Err(CapsuleError::UnsupportedEntryPoint(format!(
"Interceptor event '{}' has invalid segment structure \
(empty segments, leading/trailing dots, or empty string)",
interceptor.event
)));
}
}
let mut s = store_arc.lock().map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!("Store lock poisoned: {e}"))
})?;
let state = s.data_mut();
let count = effective_interceptors.len();
for (idx, interceptor) in effective_interceptors.into_iter().enumerate() {
state
.interceptor_handles
.push(host_state::InterceptorHandle {
handle_id: idx as u64,
action: interceptor.action,
topic: interceptor.event,
});
}
tracing::debug!(
capsule = %manifest.package.name,
count,
"Auto-subscribed interceptors for run-loop capsule"
);
}
Ok::<_, CapsuleError>((store_arc, instance, rx, has_run, ready_rx, wt_engine))
})?;
let capsule_id = crate::capsule::CapsuleId::new(&self.manifest.package.name)
.map_err(|e| CapsuleError::UnsupportedEntryPoint(e.to_string()))?;
if let Some(registry) = &ctx.capsule_registry {
registry
.write()
.await
.register_uuid(capsule_uuid, capsule_id.clone());
}
let baked_schemas = read_baked_schemas(&self._capsule_dir);
ctx.schema_catalog
.register_topics(&capsule_id, &self.manifest.topics, &baked_schemas)
.await;
self.cancel_token = Some(cancel_token.clone());
self.wasmtime_engine = Some(wt_engine.clone());
self.epoch_ticker = Some(spawn_epoch_ticker(&wt_engine));
if !self.manifest.capabilities.host_process.is_empty() {
let bus = ctx.event_bus.clone();
let tracker = process_tracker_for_listener;
let ct = cancel_token.clone();
let capsule_name = self.manifest.package.name.clone();
tokio::task::spawn(async move {
let mut receiver = bus.subscribe_topic("tool.v1.request.cancel");
let handle = tokio::runtime::Handle::current();
loop {
tokio::select! {
biased;
() = ct.cancelled() => break,
event = receiver.recv() => {
match event.as_deref() {
Some(astrid_events::AstridEvent::Ipc { message, .. }) => {
if let astrid_events::ipc::IpcPayload::ToolCancelRequest { call_ids } = &message.payload {
tracing::info!(
capsule = %capsule_name,
?call_ids,
"Received tool cancel event, killing tracked processes"
);
tracker.cancel_by_call_ids(call_ids, &handle);
}
},
Some(_) => {}, None => break, }
}
}
}
});
}
if has_run {
self.ready_rx = ready_rx.map(tokio::sync::Mutex::new);
let capsule_name = self.manifest.package.name.clone();
let run_store = Arc::clone(&store_arc);
let run_instance = instance;
self.run_handle = Some(tokio::task::spawn(async move {
tracing::info!(capsule = %capsule_name, "Starting background WASM run loop");
tokio::task::block_in_place(|| {
let mut s = match run_store.lock() {
Ok(guard) => guard,
Err(e) => {
tracing::error!(capsule = %capsule_name, error = %e, "WASM store lock was poisoned");
return;
},
};
let call_result = run_instance
.get_typed_func::<(), ()>(&mut *s, "run")
.and_then(|f| f.call(&mut *s, ()));
if let Err(e) = call_result {
tracing::error!(capsule = %capsule_name, error = %e, "WASM background loop failed");
}
});
}));
} else {
self.store = Some(store_arc);
self.instance = Some(instance);
}
self.inbound_rx = rx;
self.profile_cache = ctx.profile_cache.clone();
self.overlay_registry = ctx.overlay_registry.clone();
self.owner_principal = Some(ctx.principal.clone());
Ok(())
}
async fn unload(&mut self) -> CapsuleResult<()> {
info!(
capsule = %self.manifest.package.name,
"Unloading WASM component"
);
if let Some(token) = self.cancel_token.take() {
token.cancel();
}
if let Some(handle) = self.run_handle.take() {
handle.abort();
}
drop(self.epoch_ticker.take());
self.store = None; self.instance = None;
self.wasmtime_engine = None;
self.ready_rx = None; Ok(())
}
async fn wait_ready(&self, timeout: std::time::Duration) -> crate::capsule::ReadyStatus {
use crate::capsule::ReadyStatus;
let Some(rx_mutex) = &self.ready_rx else {
return ReadyStatus::Ready;
};
let mut rx = rx_mutex.lock().await.clone();
match tokio::time::timeout(timeout, rx.wait_for(|&v| v)).await {
Ok(Ok(_)) => ReadyStatus::Ready,
Ok(Err(_)) => ReadyStatus::Crashed, Err(_) => ReadyStatus::Timeout,
}
}
fn take_inbound_rx(
&mut self,
) -> Option<tokio::sync::mpsc::Receiver<astrid_core::InboundMessage>> {
self.inbound_rx.take()
}
fn invoke_interceptor(
&self,
action: &str,
payload: &[u8],
caller: Option<&astrid_events::ipc::IpcMessage>,
) -> CapsuleResult<crate::capsule::InterceptResult> {
let store = self.store.as_ref().ok_or_else(|| {
CapsuleError::NotSupported(
"plugin handles interceptors internally via IPC auto-subscribe".into(),
)
})?;
let instance = self
.instance
.as_ref()
.ok_or_else(|| CapsuleError::NotSupported("WASM component not instantiated".into()))?;
let invocation_profile: Option<Arc<astrid_core::profile::PrincipalProfile>> = match self
.profile_cache
.as_ref()
{
Some(cache) => {
let invoking = caller
.and_then(|msg| msg.principal.as_deref())
.and_then(|p| astrid_core::PrincipalId::new(p).ok())
.or_else(|| self.owner_principal.clone())
.unwrap_or_default();
let profile = cache.resolve(&invoking).map_err(|e| {
tracing::error!(principal = %invoking, error = %e,
"profile load failed; denying invocation (issue #666)");
CapsuleError::WasmError(format!("principal '{invoking}' profile invalid: {e}"))
})?;
check_principal_enabled(
&profile,
&invoking,
self.manifest.package.name.as_str(),
action,
)?;
Some(profile)
},
None => None,
};
let is_daemon = !self.manifest.uplinks.is_empty() || self.manifest.capabilities.uplink;
if let Some(registry) = self.overlay_registry.as_ref() {
let invoking = caller
.and_then(|msg| msg.principal.as_deref())
.and_then(|p| astrid_core::PrincipalId::new(p).ok())
.or_else(|| self.owner_principal.clone())
.unwrap_or_default();
let resolved = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(registry.resolve(&invoking))
});
if let Err(e) = resolved {
tracing::error!(
principal = %invoking,
error = %e,
"overlay registry resolve failed; denying invocation (issue #668)"
);
return Err(CapsuleError::WasmError(format!(
"principal '{invoking}' overlay resolve failed: {e}"
)));
}
}
{
let mut s = match store.lock() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::error!(
"Store lock poisoned during set; recovering to prevent \
principal context leak"
);
poisoned.into_inner()
},
};
let applied_profile: Arc<astrid_core::profile::PrincipalProfile> =
invocation_profile.clone().unwrap_or_else(|| {
Arc::new(astrid_core::profile::PrincipalProfile::default_ref().clone())
});
if !is_daemon {
let deadline = applied_profile.quotas.max_timeout_secs.saturating_mul(1000)
/ EPOCH_TICK_INTERVAL.as_millis() as u64;
s.set_epoch_deadline(deadline);
}
let state = s.data_mut();
state.caller_context = caller.cloned();
state.store_limits = wasmtime::StoreLimitsBuilder::new()
.memory_size(
usize::try_from(applied_profile.quotas.max_memory_bytes).unwrap_or(usize::MAX),
)
.build();
state.invocation_profile = invocation_profile.clone();
let invocation_principal: Option<astrid_core::PrincipalId> = caller
.and_then(|msg| msg.principal.as_deref())
.and_then(|p| astrid_core::PrincipalId::new(p).ok())
.filter(|p| *p != state.principal);
state.invocation_kv = invocation_principal.as_ref().and_then(|p| {
let ns = format!("{}:capsule:{}", p, state.capsule_id);
match state.kv.with_namespace(&ns) {
Ok(kv) => Some(kv),
Err(e) => {
tracing::warn!(
principal = %p,
error = %e,
"Failed to create invocation KV scope"
);
None
},
}
});
if let Some(ref p) = invocation_principal {
tokio::task::block_in_place(|| {
let bundle = build_principal_vfs_bundle(p);
state.invocation_home = bundle.home;
state.invocation_tmp = bundle.tmp;
state.invocation_capsule_log =
open_capsule_log(p, state.capsule_id.as_str(), false);
state.invocation_secret_store = state.invocation_kv.as_ref().map(|kv| {
astrid_storage::build_secret_store(
&format!("{}:{}", state.capsule_id, p),
kv.clone(),
state.runtime_handle.clone(),
)
});
});
}
}
type HookTriggerResult = bindings::astrid::guest::lifecycle::CapsuleResult;
let result = tokio::task::block_in_place(|| {
let mut s = store
.lock()
.map_err(|e| CapsuleError::WasmError(format!("store lock poisoned: {e}")))?;
let func = instance
.get_typed_func::<(String, Vec<u8>), (HookTriggerResult,)>(
&mut *s,
"astrid-hook-trigger",
)
.map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!(
"capsule does not export `astrid-hook-trigger`: {e}"
))
})?;
func.call(&mut *s, (action.to_string(), payload.to_vec()))
.map(|(cr,)| cr)
.map_err(|e| CapsuleError::WasmError(format!("astrid_hook_trigger failed: {e:?}")))
});
{
let mut s = match store.lock() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::error!(
"Store lock poisoned during post-invocation clear; \
recovering to prevent principal context leak"
);
poisoned.into_inner()
},
};
let state = s.data_mut();
state.caller_context = None;
state.invocation_kv = None;
state.invocation_home = None;
state.invocation_tmp = None;
state.invocation_secret_store = None;
state.invocation_capsule_log = None;
state.invocation_profile = None;
}
result.map(|cr| {
crate::capsule::InterceptResult::from_capsule_result(&cr.action, cr.data.as_deref())
})
}
fn check_health(&self) -> crate::capsule::CapsuleState {
if let Some(handle) = &self.run_handle
&& handle.is_finished()
{
return crate::capsule::CapsuleState::Failed(
"WASM run loop exited unexpectedly".into(),
);
}
crate::capsule::CapsuleState::Ready
}
}
pub struct LifecycleConfig {
pub wasm_bytes: Vec<u8>,
pub capsule_id: crate::capsule::CapsuleId,
pub workspace_root: PathBuf,
pub home_root: Option<PathBuf>,
pub kv: astrid_storage::ScopedKvStore,
pub event_bus: astrid_events::EventBus,
pub config: std::collections::HashMap<String, serde_json::Value>,
pub secret_store: std::sync::Arc<dyn astrid_storage::secret::SecretStore>,
}
pub fn run_lifecycle(
cfg: LifecycleConfig,
phase: LifecyclePhase,
previous_version: Option<&str>,
) -> CapsuleResult<()> {
let export_name = match phase {
LifecyclePhase::Install => "astrid-install",
LifecyclePhase::Upgrade => "astrid-upgrade",
};
let has_export = wasm_exports_contain(export_name, &cfg.wasm_bytes);
if !has_export {
tracing::debug!(
capsule = %cfg.capsule_id,
export = export_name,
"Capsule does not export lifecycle hook, skipping"
);
return Ok(());
}
let vfs = astrid_vfs::HostVfs::new();
let root_handle = astrid_capabilities::DirHandle::new();
tokio::runtime::Handle::current()
.block_on(async {
vfs.register_dir(root_handle.clone(), cfg.workspace_root.clone())
.await
})
.map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!(
"Failed to register VFS directory for lifecycle: {e}"
))
})?;
let home_mount: Option<PrincipalMount> = cfg.home_root.as_ref().and_then(|h_root| {
let canonical = h_root.canonicalize().unwrap_or_else(|_| h_root.clone());
mount_dir(&canonical)
});
let host_state = HostState {
wasi_ctx: build_wasi_ctx(),
store_limits: wasmtime::StoreLimitsBuilder::new()
.memory_size(WASM_MAX_MEMORY_BYTES)
.build(),
resource_table: wasmtime::component::ResourceTable::new(),
principal: astrid_core::PrincipalId::default(),
capsule_uuid: uuid::Uuid::new_v4(),
caller_context: None,
invocation_kv: None,
capsule_log: None,
capsule_id: cfg.capsule_id.clone(),
workspace_root: cfg.workspace_root,
vfs: Arc::new(vfs),
vfs_root_handle: root_handle,
home: home_mount,
tmp: None,
invocation_home: None,
invocation_tmp: None,
invocation_secret_store: None,
invocation_capsule_log: None,
invocation_profile: None,
overlay_vfs: None,
upper_dir: None,
kv: cfg.kv,
event_bus: cfg.event_bus,
ipc_limiter: astrid_events::ipc::IpcRateLimiter::new(),
config: cfg.config,
secret_env: std::collections::HashSet::new(),
ipc_publish_patterns: Vec::new(),
ipc_subscribe_patterns: Vec::new(),
security: None,
hook_manager: None,
capsule_registry: None,
runtime_handle: tokio::runtime::Handle::current(),
has_uplink_capability: false,
inbound_tx: None,
registered_uplinks: Vec::new(),
cli_socket_listener: None,
active_http_streams: std::collections::HashMap::new(),
next_http_stream_id: 1,
lifecycle_phase: Some(phase),
secret_store: cfg.secret_store,
ready_tx: None,
host_semaphore: HostState::default_host_semaphore(),
cancel_token: tokio_util::sync::CancellationToken::new(),
session_token: None,
interceptor_handles: Vec::new(),
allowance_store: None,
identity_store: None,
process_tracker: Arc::new(host::process::ProcessTracker::new()),
net_stream_count: 0,
subscription_count: 0,
process_count_total: 0,
process_count_by_principal: std::collections::HashMap::new(),
};
const LIFECYCLE_TIMEOUT_SECS: u64 = 10 * 60;
let wt_engine = build_wasmtime_engine()?;
let mut store = Store::new(&wt_engine, host_state);
let deadline_ticks = LIFECYCLE_TIMEOUT_SECS * 10; store.set_epoch_deadline(deadline_ticks);
let _epoch_guard = spawn_epoch_ticker(&wt_engine);
let mut linker: Linker<HostState> = Linker::new(&wt_engine);
configure_kernel_linker(&mut linker).map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!(
"Failed to add Astrid host to linker for lifecycle: {e}"
))
})?;
let wasm_component = Component::from_binary(&wt_engine, &cfg.wasm_bytes).map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!(
"Failed to compile WASM component for lifecycle: {e}"
))
})?;
let instance = linker
.instantiate(&mut store, &wasm_component)
.map_err(|e| {
CapsuleError::UnsupportedEntryPoint(format!(
"Failed to instantiate WASM component for lifecycle: {e}"
))
})?;
tracing::info!(
capsule = %cfg.capsule_id,
phase = ?phase,
previous_version = previous_version.unwrap_or("(none)"),
"Running lifecycle hook"
);
let func = instance
.get_typed_func::<(), ()>(&mut store, export_name)
.map_err(|_| {
CapsuleError::UnsupportedEntryPoint(format!(
"capsule does not export lifecycle hook `{export_name}`"
))
})?;
func.call(&mut store, ()).map_err(|e| {
CapsuleError::ExecutionFailed(format!("lifecycle hook {export_name} failed: {e}"))
})?;
let _ = phase;
tracing::info!(
capsule = %cfg.capsule_id,
phase = ?phase,
"Lifecycle hook completed successfully"
);
Ok(())
}
fn wasm_exports_contain_run(wasm_bytes: &[u8]) -> bool {
wasm_exports_contain("run", wasm_bytes)
}
const STUB_PRONE_EXPORTS: [&str; 3] = ["run", "astrid-install", "astrid-upgrade"];
fn wasm_exports_contain(name: &str, wasm_bytes: &[u8]) -> bool {
let trio_position = |export_name: &str| -> Option<usize> {
STUB_PRONE_EXPORTS.iter().position(|n| *n == export_name)
};
let resolve = |trio: &[Option<u32>; STUB_PRONE_EXPORTS.len()]| -> Option<bool> {
let pos = trio_position(name)?;
let target = trio[pos]?;
let aliased = trio
.iter()
.enumerate()
.any(|(i, idx)| i != pos && *idx == Some(target));
Some(!aliased)
};
for payload in wasmparser::Parser::new(0).parse_all(wasm_bytes) {
match payload {
Ok(wasmparser::Payload::ExportSection(reader)) => {
let mut trio: [Option<u32>; STUB_PRONE_EXPORTS.len()] =
[None; STUB_PRONE_EXPORTS.len()];
let mut name_present = false;
for export in reader {
let e = match export {
Ok(e) => e,
Err(e) => {
tracing::warn!("failed to parse WASM export entry: {e}");
return true; },
};
if e.kind != wasmparser::ExternalKind::Func {
continue;
}
if e.name == name {
name_present = true;
}
if let Some(pos) = trio_position(e.name) {
trio[pos] = Some(e.index);
}
}
if let Some(real) = resolve(&trio) {
return real;
}
if name_present {
return true;
}
},
Ok(wasmparser::Payload::ComponentExportSection(reader)) => {
let mut trio: [Option<u32>; STUB_PRONE_EXPORTS.len()] =
[None; STUB_PRONE_EXPORTS.len()];
let mut name_present = false;
for export in reader {
let e = match export {
Ok(e) => e,
Err(e) => {
tracing::warn!("failed to parse component export entry: {e}");
return true;
},
};
if e.kind != wasmparser::ComponentExternalKind::Func {
continue;
}
if e.name.0 == name {
name_present = true;
}
if let Some(pos) = trio_position(e.name.0) {
trio[pos] = Some(e.index);
}
}
if let Some(real) = resolve(&trio) {
return real;
}
if name_present {
return true;
}
},
Err(e) => {
tracing::warn!("failed to pre-scan WASM binary: {e}");
return true; },
_ => {},
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
fn poison_mutex<T: Send + 'static>(mutex: &Arc<Mutex<T>>) {
let m = Arc::clone(mutex);
let _ = std::thread::spawn(move || {
let _guard = m.lock().unwrap();
panic!("intentional panic to poison mutex");
})
.join();
}
fn pid(name: &str) -> astrid_core::PrincipalId {
astrid_core::PrincipalId::new(name).unwrap()
}
#[test]
fn check_principal_enabled_allows_enabled_profile() {
let profile = astrid_core::profile::PrincipalProfile::default();
assert!(profile.enabled, "default profile must be enabled");
check_principal_enabled(&profile, &pid("alice"), "test-capsule", "do-thing")
.expect("enabled profile must pass the gate");
}
#[test]
fn check_principal_enabled_rejects_disabled_profile() {
let profile = astrid_core::profile::PrincipalProfile {
enabled: false,
..Default::default()
};
let err = check_principal_enabled(&profile, &pid("bob"), "test-capsule", "do-thing")
.expect_err("disabled profile must be denied");
let msg = err.to_string();
assert!(
msg.contains("disabled") && msg.contains("bob"),
"expected error to name principal and reason: {msg}"
);
}
#[test]
fn check_principal_enabled_denies_even_for_admin_group() {
let profile = astrid_core::profile::PrincipalProfile {
groups: vec!["admin".to_string()],
enabled: false,
..Default::default()
};
assert!(check_principal_enabled(&profile, &pid("admin_user"), "x", "y").is_err());
}
#[tokio::test]
async fn poisoned_lock_in_run_loop_does_not_panic() {
let store_arc: Arc<Mutex<String>> = Arc::new(Mutex::new("fake_store".into()));
poison_mutex(&store_arc);
let handle = tokio::task::spawn_blocking(move || {
let capsule_name = "test-capsule";
let _s = match store_arc.lock() {
Ok(guard) => guard,
Err(e) => {
tracing::error!(capsule = %capsule_name, error = %e, "WASM store lock was poisoned");
return false;
},
};
true
});
let result = handle.await;
assert!(result.is_ok(), "spawn_blocking should not panic");
assert!(!result.unwrap(), "should have taken the poison error path");
}
#[test]
fn poisoned_lock_in_interceptor_returns_error() {
let store: Arc<Mutex<String>> = Arc::new(Mutex::new("fake_store".into()));
poison_mutex(&store);
let result: CapsuleResult<Vec<u8>> = store
.lock()
.map_err(|e| CapsuleError::WasmError(format!("store lock poisoned: {e}")))
.map(|_guard| vec![]);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, CapsuleError::WasmError(_)),
"expected WasmError, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("poisoned"),
"error message should mention poisoning: {msg}"
);
}
#[test]
fn build_onboarding_field_text() {
let def = crate::manifest::EnvDef {
env_type: "string".into(),
request: Some("Enter owner address".into()),
description: Some("The wallet address".into()),
default: None,
enum_values: vec![],
placeholder: None,
scope: crate::manifest::EnvScope::default(),
};
let field = crate::engine::build_onboarding_field("owner", &def);
assert_eq!(field.key, "owner");
assert_eq!(field.prompt, "Enter owner address");
assert_eq!(field.description.as_deref(), Some("The wallet address"));
assert_eq!(
field.field_type,
astrid_events::ipc::OnboardingFieldType::Text
);
assert!(field.default.is_none());
}
#[test]
fn build_onboarding_field_secret() {
let def = crate::manifest::EnvDef {
env_type: "secret".into(),
request: None,
description: None,
default: None,
enum_values: vec!["a".into()], placeholder: None,
scope: crate::manifest::EnvScope::default(),
};
let field = crate::engine::build_onboarding_field("apiKey", &def);
assert_eq!(
field.field_type,
astrid_events::ipc::OnboardingFieldType::Secret
);
}
#[test]
fn build_onboarding_field_enum_with_default() {
let def = crate::manifest::EnvDef {
env_type: "string".into(),
request: Some("Select network".into()),
description: None,
default: Some(serde_json::json!("testnet")),
enum_values: vec!["testnet".into(), "mainnet".into()],
placeholder: None,
scope: crate::manifest::EnvScope::default(),
};
let field = crate::engine::build_onboarding_field("network", &def);
assert_eq!(
field.field_type,
astrid_events::ipc::OnboardingFieldType::Enum(vec!["testnet".into(), "mainnet".into()])
);
assert_eq!(field.default.as_deref(), Some("testnet"));
}
#[test]
fn build_onboarding_field_fallback_prompt() {
let def = crate::manifest::EnvDef {
env_type: "string".into(),
request: None,
description: None,
default: None,
enum_values: vec![],
placeholder: None,
scope: crate::manifest::EnvScope::default(),
};
let field = crate::engine::build_onboarding_field("someKey", &def);
assert_eq!(field.prompt, "Please enter value for someKey");
}
#[test]
fn build_onboarding_field_single_enum_degrades_to_text_with_autofill() {
let def = crate::manifest::EnvDef {
env_type: "string".into(),
request: None,
description: None,
default: None,
enum_values: vec!["only".into()],
placeholder: None,
scope: crate::manifest::EnvScope::default(),
};
let field = crate::engine::build_onboarding_field("single", &def);
assert_eq!(
field.field_type,
astrid_events::ipc::OnboardingFieldType::Text,
"Single-choice enum should degrade to text"
);
assert_eq!(
field.default.as_deref(),
Some("only"),
"Single-choice enum should auto-fill the sole valid value"
);
}
#[test]
fn build_onboarding_field_array() {
let def = crate::manifest::EnvDef {
env_type: "array".into(),
request: Some("Enter relay URLs".into()),
description: Some("Nostr relay endpoints".into()),
default: None,
enum_values: vec![],
placeholder: None,
scope: crate::manifest::EnvScope::default(),
};
let field = crate::engine::build_onboarding_field("relays", &def);
assert_eq!(
field.field_type,
astrid_events::ipc::OnboardingFieldType::Array
);
assert_eq!(field.prompt, "Enter relay URLs");
}
#[test]
fn build_onboarding_field_empty_enum_degrades_to_text() {
let def = crate::manifest::EnvDef {
env_type: "string".into(),
request: None,
description: None,
default: None,
enum_values: vec![],
placeholder: None,
scope: crate::manifest::EnvScope::default(),
};
let field = crate::engine::build_onboarding_field("empty", &def);
assert_eq!(
field.field_type,
astrid_events::ipc::OnboardingFieldType::Text,
"Empty enum should degrade to text"
);
}
async fn wait_ready_from_rx(
rx: &tokio::sync::Mutex<tokio::sync::watch::Receiver<bool>>,
timeout: std::time::Duration,
) -> crate::capsule::ReadyStatus {
use crate::capsule::ReadyStatus;
let mut rx = rx.lock().await.clone();
match tokio::time::timeout(timeout, rx.wait_for(|&v| v)).await {
Ok(Ok(_)) => ReadyStatus::Ready,
Ok(Err(_)) => ReadyStatus::Crashed,
Err(_) => ReadyStatus::Timeout,
}
}
#[tokio::test]
async fn wait_ready_returns_ready_when_pre_signaled() {
let (tx, rx) = tokio::sync::watch::channel(false);
let _ = tx.send(true);
let rx_mutex = tokio::sync::Mutex::new(rx);
let status = wait_ready_from_rx(&rx_mutex, std::time::Duration::from_millis(100)).await;
assert_eq!(status, crate::capsule::ReadyStatus::Ready);
}
#[tokio::test]
async fn wait_ready_returns_timeout_when_never_signaled() {
let (_tx, rx) = tokio::sync::watch::channel(false);
let rx_mutex = tokio::sync::Mutex::new(rx);
let status = wait_ready_from_rx(&rx_mutex, std::time::Duration::from_millis(10)).await;
assert_eq!(status, crate::capsule::ReadyStatus::Timeout);
}
#[tokio::test]
async fn wait_ready_returns_crashed_when_sender_dropped() {
let (tx, rx) = tokio::sync::watch::channel(false);
drop(tx); let rx_mutex = tokio::sync::Mutex::new(rx);
let status = wait_ready_from_rx(&rx_mutex, std::time::Duration::from_millis(100)).await;
assert_eq!(status, crate::capsule::ReadyStatus::Crashed);
}
#[tokio::test]
async fn wait_ready_returns_ready_when_signaled_after_delay() {
let (tx, rx) = tokio::sync::watch::channel(false);
let rx_mutex = tokio::sync::Mutex::new(rx);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let _ = tx.send(true);
});
let status = wait_ready_from_rx(&rx_mutex, std::time::Duration::from_millis(500)).await;
assert_eq!(status, crate::capsule::ReadyStatus::Ready);
}
fn build_wasm_module(export_names: &[&str]) -> Vec<u8> {
use wasm_encoder::{
CodeSection, ExportKind, ExportSection, Function, FunctionSection, Module, TypeSection,
};
let mut module = Module::new();
let mut types = TypeSection::new();
types.ty().function(vec![], vec![]);
module.section(&types);
let mut functions = FunctionSection::new();
for _ in export_names {
functions.function(0);
}
module.section(&functions);
let mut exports = ExportSection::new();
for (i, name) in export_names.iter().enumerate() {
exports.export(name, ExportKind::Func, i as u32);
}
module.section(&exports);
let mut code = CodeSection::new();
for _ in export_names {
let mut f = Function::new(vec![]);
f.instruction(&wasm_encoder::Instruction::End);
code.function(&f);
}
module.section(&code);
module.finish()
}
#[test]
fn prescan_detects_run_export() {
let wasm = build_wasm_module(&["run"]);
assert!(wasm_exports_contain_run(&wasm), "should detect run export");
}
#[test]
fn prescan_returns_false_without_run() {
let wasm = build_wasm_module(&["tool_call", "install"]);
assert!(
!wasm_exports_contain_run(&wasm),
"should not detect run when absent"
);
}
#[test]
fn prescan_detects_run_among_multiple_exports() {
let wasm = build_wasm_module(&["install", "run", "tool_call"]);
assert!(
wasm_exports_contain_run(&wasm),
"should detect run among multiple exports"
);
}
#[test]
fn prescan_returns_false_for_empty_export_section() {
let wasm = build_wasm_module(&[]);
assert!(
!wasm_exports_contain_run(&wasm),
"empty export section should not have run"
);
}
#[test]
fn prescan_returns_false_for_module_with_no_export_section() {
use wasm_encoder::{Module, TypeSection};
let mut module = Module::new();
let mut types = TypeSection::new();
types.ty().function(vec![], vec![]);
module.section(&types);
let wasm = module.finish();
assert!(
!wasm_exports_contain_run(&wasm),
"module with no export section should not have run"
);
}
#[test]
fn prescan_returns_true_for_corrupt_binary() {
let garbage = b"not a wasm module at all";
assert!(
wasm_exports_contain_run(garbage),
"corrupt binary should default to true (safe: no timeout)"
);
}
fn build_wasm_module_with_aliases(exports: &[(&str, u32)]) -> Vec<u8> {
use wasm_encoder::{
CodeSection, ExportKind, ExportSection, Function, FunctionSection, Module, TypeSection,
};
let mut module = Module::new();
let mut types = TypeSection::new();
types.ty().function(vec![], vec![]);
module.section(&types);
let max_idx = exports.iter().map(|(_, i)| *i).max().unwrap_or(0);
let func_count = (max_idx + 1) as usize;
let mut functions = FunctionSection::new();
for _ in 0..func_count {
functions.function(0);
}
module.section(&functions);
let mut export_section = ExportSection::new();
for (name, idx) in exports {
export_section.export(name, ExportKind::Func, *idx);
}
module.section(&export_section);
let mut code = CodeSection::new();
for _ in 0..func_count {
let mut f = Function::new(vec![]);
f.instruction(&wasm_encoder::Instruction::End);
code.function(&f);
}
module.section(&code);
module.finish()
}
#[test]
fn prescan_rejects_run_aliased_with_install_and_upgrade() {
let wasm = build_wasm_module_with_aliases(&[
("astrid-hook-trigger", 0),
("run", 1),
("astrid-install", 1),
("astrid-upgrade", 1),
]);
assert!(
!wasm_exports_contain_run(&wasm),
"stub run aliased to install/upgrade must be treated as no run loop"
);
}
#[test]
fn prescan_accepts_run_distinct_from_install_stubs() {
let wasm = build_wasm_module_with_aliases(&[
("astrid-hook-trigger", 0),
("run", 1),
("astrid-install", 2),
("astrid-upgrade", 2),
]);
assert!(
wasm_exports_contain_run(&wasm),
"run distinct from aliased install/upgrade stubs is a real run loop"
);
}
#[test]
fn prescan_accepts_all_three_distinct_implementations() {
let wasm = build_wasm_module_with_aliases(&[
("astrid-hook-trigger", 0),
("run", 1),
("astrid-install", 2),
("astrid-upgrade", 3),
]);
assert!(wasm_exports_contain_run(&wasm));
assert!(wasm_exports_contain("astrid-install", &wasm));
assert!(wasm_exports_contain("astrid-upgrade", &wasm));
}
#[test]
fn prescan_distinguishes_real_install_from_run_upgrade_stubs() {
let wasm = build_wasm_module_with_aliases(&[
("astrid-hook-trigger", 0),
("run", 1),
("astrid-upgrade", 1),
("astrid-install", 2),
]);
assert!(
!wasm_exports_contain_run(&wasm),
"run aliased to upgrade is a stub even when install is real"
);
assert!(
wasm_exports_contain("astrid-install", &wasm),
"install with a unique index is real"
);
assert!(
!wasm_exports_contain("astrid-upgrade", &wasm),
"upgrade aliased to run is a stub"
);
}
#[test]
fn prescan_rejects_stubbed_lifecycle_exports() {
let wasm = build_wasm_module_with_aliases(&[
("astrid-hook-trigger", 0),
("run", 1),
("astrid-install", 1),
("astrid-upgrade", 1),
]);
assert!(!wasm_exports_contain("astrid-install", &wasm));
assert!(!wasm_exports_contain("astrid-upgrade", &wasm));
}
#[test]
fn prescan_non_trio_name_uses_plain_presence() {
let wasm = build_wasm_module_with_aliases(&[
("astrid-hook-trigger", 0),
("astrid-cron-trigger", 0),
]);
assert!(
wasm_exports_contain("astrid-hook-trigger", &wasm),
"non-trio names take face value even if shared"
);
assert!(wasm_exports_contain("astrid-cron-trigger", &wasm));
}
#[test]
fn prescan_ignores_non_func_run_export() {
use wasm_encoder::{
ExportKind, ExportSection, GlobalSection, GlobalType, Module, TypeSection, ValType,
};
let mut module = Module::new();
let mut types = TypeSection::new();
types.ty().function(vec![], vec![]);
module.section(&types);
let mut globals = GlobalSection::new();
globals.global(
GlobalType {
val_type: ValType::I32,
mutable: false,
shared: false,
},
&wasm_encoder::ConstExpr::i32_const(42),
);
module.section(&globals);
let mut exports = ExportSection::new();
exports.export("run", ExportKind::Global, 0);
module.section(&exports);
let wasm = module.finish();
assert!(
!wasm_exports_contain_run(&wasm),
"global named 'run' should not be detected as a function export"
);
}
async fn build_bundle_async_safe(ph: astrid_core::dirs::PrincipalHome) -> PrincipalVfsBundle {
tokio::task::spawn_blocking(move || build_principal_vfs_bundle_at(&ph))
.await
.expect("spawn_blocking join")
}
#[tokio::test(flavor = "multi_thread")]
async fn build_bundle_returns_empty_for_unregistered_principal() {
let tmp = tempfile::tempdir().unwrap();
let ph = astrid_core::dirs::PrincipalHome::from_path(tmp.path().join("home/mallory"));
let bundle = build_bundle_async_safe(ph).await;
assert!(bundle.home.is_none(), "unknown principal: no home mount");
assert!(bundle.tmp.is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn build_bundle_populated_for_registered_principal() {
let tmp = tempfile::tempdir().unwrap();
let alice_root = tmp.path().join("home/alice");
let ph = astrid_core::dirs::PrincipalHome::from_path(&alice_root);
ph.ensure().unwrap();
let alice_canonical = alice_root.canonicalize().unwrap();
let bundle = build_bundle_async_safe(ph).await;
let home = bundle.home.as_ref().expect("home mount present");
assert_eq!(home.root, alice_canonical);
let tmp_mount = bundle.tmp.as_ref().expect("tmp mount present");
assert_eq!(tmp_mount.root, alice_canonical.join(".local").join("tmp"));
}
#[tokio::test(flavor = "multi_thread")]
async fn build_bundle_isolates_distinct_principals() {
let tmp = tempfile::tempdir().unwrap();
let alice_root = tmp.path().join("home/alice");
let bob_root = tmp.path().join("home/bob");
let alice_ph = astrid_core::dirs::PrincipalHome::from_path(&alice_root);
let bob_ph = astrid_core::dirs::PrincipalHome::from_path(&bob_root);
alice_ph.ensure().unwrap();
bob_ph.ensure().unwrap();
let alice_canonical = alice_root.canonicalize().unwrap();
let bob_canonical = bob_root.canonicalize().unwrap();
let alice_bundle = build_bundle_async_safe(alice_ph).await;
let bob_bundle = build_bundle_async_safe(bob_ph).await;
let alice_home = &alice_bundle.home.as_ref().unwrap().root;
let bob_home = &bob_bundle.home.as_ref().unwrap().root;
assert_ne!(
alice_home, bob_home,
"distinct principals, distinct home roots"
);
assert_eq!(alice_home, &alice_canonical);
assert_eq!(bob_home, &bob_canonical);
std::fs::write(alice_home.join("note.txt"), b"alice").unwrap();
std::fs::write(bob_home.join("note.txt"), b"bob").unwrap();
assert_eq!(
std::fs::read(alice_home.join("note.txt")).unwrap(),
b"alice"
);
assert_eq!(std::fs::read(bob_home.join("note.txt")).unwrap(), b"bob");
}
#[test]
fn open_capsule_log_returns_none_for_unregistered_principal() {
let tmp = tempfile::tempdir().unwrap();
let ph = astrid_core::dirs::PrincipalHome::from_path(tmp.path().join("home/mallory"));
assert!(open_capsule_log_at(&ph, "some-capsule", false).is_none());
assert!(open_capsule_log_at(&ph, "some-capsule", true).is_none());
assert!(
!ph.root().exists(),
"must not auto-mkdir an unregistered principal's home"
);
}
#[test]
fn open_capsule_log_opens_file_under_principal_tree() {
let tmp = tempfile::tempdir().unwrap();
let alice_root = tmp.path().join("home/alice");
let ph = astrid_core::dirs::PrincipalHome::from_path(&alice_root);
ph.ensure().unwrap();
let file = open_capsule_log_at(&ph, "my-capsule", false).expect("open ok");
let log_dir = ph.log_dir().join("my-capsule");
assert!(log_dir.is_dir(), "log dir auto-created under alice's tree");
let today = today_date_string();
let expected = log_dir.join(format!("{today}.log"));
assert!(
expected.is_file(),
"today's log file opened at {expected:?}"
);
use std::io::Write;
{
let mut f = file.lock().unwrap();
writeln!(f, "hello-alice").unwrap();
f.flush().unwrap();
}
let contents = std::fs::read_to_string(&expected).unwrap();
assert!(contents.contains("hello-alice"));
}
#[test]
fn open_capsule_log_isolates_distinct_principals() {
let tmp = tempfile::tempdir().unwrap();
let alice_root = tmp.path().join("home/alice");
let bob_root = tmp.path().join("home/bob");
let alice_ph = astrid_core::dirs::PrincipalHome::from_path(&alice_root);
let bob_ph = astrid_core::dirs::PrincipalHome::from_path(&bob_root);
alice_ph.ensure().unwrap();
bob_ph.ensure().unwrap();
let alice_log = open_capsule_log_at(&alice_ph, "shared-capsule", false).unwrap();
let bob_log = open_capsule_log_at(&bob_ph, "shared-capsule", false).unwrap();
use std::io::Write;
writeln!(alice_log.lock().unwrap(), "alice-line").unwrap();
writeln!(bob_log.lock().unwrap(), "bob-line").unwrap();
let today = today_date_string();
let alice_file = alice_ph
.log_dir()
.join("shared-capsule")
.join(format!("{today}.log"));
let bob_file = bob_ph
.log_dir()
.join("shared-capsule")
.join(format!("{today}.log"));
let alice_contents = std::fs::read_to_string(&alice_file).unwrap();
let bob_contents = std::fs::read_to_string(&bob_file).unwrap();
assert!(alice_contents.contains("alice-line"));
assert!(!alice_contents.contains("bob-line"));
assert!(bob_contents.contains("bob-line"));
assert!(!bob_contents.contains("alice-line"));
}
#[test]
fn open_capsule_log_with_prune_does_not_delete_todays_file() {
let tmp = tempfile::tempdir().unwrap();
let alice_root = tmp.path().join("home/alice");
let ph = astrid_core::dirs::PrincipalHome::from_path(&alice_root);
ph.ensure().unwrap();
let f1 = open_capsule_log_at(&ph, "c", true).unwrap();
use std::io::Write;
writeln!(f1.lock().unwrap(), "pre-prune line").unwrap();
f1.lock().unwrap().flush().unwrap();
drop(f1);
let f2 = open_capsule_log_at(&ph, "c", true).unwrap();
drop(f2);
let today = today_date_string();
let path = ph.log_dir().join("c").join(format!("{today}.log"));
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("pre-prune line"));
}
#[test]
fn civil_from_days_epoch() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
}
#[test]
fn civil_from_days_known_dates() {
assert_eq!(civil_from_days(59), (1970, 3, 1)); assert_eq!(civil_from_days(365), (1971, 1, 1)); assert_eq!(civil_from_days(11_016), (2000, 2, 29)); assert_eq!(civil_from_days(20_564), (2026, 4, 21)); }
#[test]
fn today_date_string_matches_civil_from_days() {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let days = secs / 86400;
let (y, m, d) = civil_from_days(days as i64);
assert_eq!(today_date_string(), format!("{y:04}-{m:02}-{d:02}"));
}
}