//! Cached discovery documents and import checks under PROTOCOL.md section 10.4. //! Refreshes use immutable copies. The proc layer only reads serialized cache entries. use crate::{Host, RpcResult}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::{ collections::BTreeMap, path::{Path, PathBuf}, }; #[derive(Clone)] pub(crate) struct Interface { pub(crate) hash: String, pub(crate) document: Value, pub(crate) error: Option, } pub(crate) struct Graph { pub(crate) plugins: BTreeMap, json: Vec, dot: Vec, } impl Default for Graph { /// Exposes a valid empty graph before background discovery completes. fn default() -> Self { Self { plugins: BTreeMap::new(), json: b"{\"plugins\":{}}".to_vec(), dot: b"digraph plugins {}\n".to_vec(), } } } /// Hashes exactly the bytes copied for discovery or installation. pub(crate) fn hash(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } /// Reads and hashes one immutable snapshot without blocking an async worker. pub(crate) async fn read_hashed(path: PathBuf) -> std::io::Result<(Vec, String)> { tokio::task::spawn_blocking(move || -> std::io::Result<_> { let bytes = std::fs::read(path)?; let hash = hash(&bytes); Ok((bytes, hash)) }) .await .map_err(std::io::Error::other)? } /// Lists broken imports, optionally restricted to changed consumers or providers. pub(crate) fn problems( plugins: &BTreeMap, changed: Option<&[String]>, ) -> Vec { let mut failures = Vec::new(); for (consumer, interface) in plugins { if let Some(imports) = interface.document["x-sausage-imports"].as_object() { for (provider, expected) in imports { if changed .is_some_and(|names| !names.contains(consumer) && !names.contains(provider)) { continue; } let Some(actual) = plugins.get(provider) else { failures.push(json!({"consumer":consumer,"provider":provider,"status":"missing","message":"the provider does not exist"})); continue; }; if let Some(error) = &actual.error { failures.push(json!({"consumer":consumer,"provider":provider,"status":"unavailable","message":"discovery failed","error":error})); continue; } for problem in sausage_interface::compat::check(expected, &actual.document) { failures.push(json!({"consumer":consumer,"provider":provider,"status":"broken","method":problem.method,"message":problem.message,"counterexample":problem.counterexample,"display":problem.to_string()})); } } } } failures } impl Host { /// The cached JSON graph. This does not perform filesystem or RPC operations. pub(crate) fn graph_json(&self) -> Vec { self.0.graph.lock().unwrap().json.clone() } /// The cached DOT graph, with an edge for every import. pub(crate) fn graph_dot(&self) -> Vec { self.0.graph.lock().unwrap().dot.clone() } /// Plugin names currently present in the discovery cache. pub(crate) fn discovered_plugins(&self) -> Vec { self.0 .graph .lock() .unwrap() .plugins .keys() .cloned() .collect() } /// The current document, if discovery succeeded. pub(crate) fn discover_document(&self, plugin: &str) -> Option { self.0 .graph .lock() .unwrap() .plugins .get(plugin) .filter(|entry| entry.error.is_none()) .map(|entry| entry.document.clone()) } /// Discovers a private copy and caches successful documents by file hash. pub(crate) async fn discover_copy( &self, plugin: &str, path: &Path, hash: String, ) -> Result { let request = json!(uuid::Uuid::now_v7().to_string()); let discovery = self.route_from( "core".into(), Some(request.clone()), format!("{plugin}.rpc.discover"), json!({}), true, Some(path.to_path_buf()), ); let result = tokio::time::timeout(std::time::Duration::from_secs(10), discovery).await; let processes: Vec<_> = self .0 .state .lock() .unwrap() .processes .iter() .filter(|(_, process)| process.id.as_ref() == Some(&request)) .map(|(pid, _)| pid.clone()) .collect(); for pid in processes { self.begin_stop(&pid); } let document = result .map_err(|_| crate::error(-32003, "plugin discovery timed out after 10 seconds"))??; if !document["methods"].is_array() { return Err(crate::error( -32003, "rpc.discover did not return an OpenRPC document", )); } Ok(Interface { hash, document, error: None, }) } /// Refreshes every installed plugin while the caller holds the activation mutex. pub(crate) async fn refresh_graph(&self) -> RpcResult { self.refresh_graph_except(&[]).await } pub(crate) async fn refresh_graph_except(&self, candidates: &[String]) -> RpcResult { let mut directory = tokio::fs::read_dir(self.0.world.root.join("plugins")) .await .map_err(io_error)?; let mut plugins = BTreeMap::new(); while let Some(entry) = directory.next_entry().await.map_err(io_error)? { if !entry.file_type().await.map_err(io_error)?.is_dir() { continue; } let name = entry.file_name().to_string_lossy().into_owned(); let source = entry.path().join("plugin.wasm"); let (bytes, hash) = match read_hashed(source).await { Ok(snapshot) => snapshot, Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(error) => { plugins.insert( name, Interface { hash: String::new(), document: Value::Null, error: Some(io_error(error)), }, ); continue; } }; let previous = self.0.graph.lock().unwrap().plugins.get(&name).cloned(); let interface = if candidates.contains(&name) { previous .filter(|entry| entry.hash == hash) .unwrap_or(Interface { hash, document: Value::Null, error: None, }) } else if let Some(previous) = previous.filter(|entry| entry.hash == hash && entry.error.is_none()) { previous } else { let copy = entry .path() .join(format!("discover-{}.wasm", uuid::Uuid::now_v7())); let result = match tokio::fs::write(©, &bytes).await { Ok(()) => self.discover_copy(&name, ©, hash.clone()).await, Err(error) => Err(io_error(error)), }; let _ = tokio::fs::remove_file(©).await; match result { Ok(interface) => interface, Err(error) => Interface { hash, document: Value::Null, error: Some(error), }, } }; plugins.insert(name, interface); } let admission = self.0.admission.write().await; let old_plugins = self.0.graph.lock().unwrap().plugins.clone(); let mut upgrades = Vec::new(); for (plugin, current) in &plugins { if let Some(old) = old_plugins.get(plugin).filter(|old| { !old.hash.is_empty() && old.hash != current.hash && current.error.is_none() }) { upgrades.push(self.snapshot_upgrade(plugin, old.hash.clone(), ¤t.hash)); } } self.publish_graph(plugins); drop(admission); for upgrade in upgrades { self.begin_upgrade(upgrade); } Ok(json!({})) } /// Rebuilds serialized snapshots after a watcher refresh or an activation. pub(crate) fn publish_graph(&self, plugins: BTreeMap) { let failures = problems(&plugins, None); for failure in &failures { self.0 .journal .write("core", "core", "core", &format!("broken import {failure}")); } let mut entries = serde_json::Map::new(); let mut dot = String::from("digraph plugins {\n"); for (name, interface) in &plugins { dot.push_str(&format!(" {};\n", json!(name))); let mut imports = serde_json::Map::new(); for provider in interface.document["x-sausage-imports"] .as_object() .into_iter() .flat_map(|imports| imports.keys()) { let issues: Vec<_> = failures .iter() .filter(|problem| { problem["consumer"] == *name && problem["provider"] == *provider }) .cloned() .collect(); let status = issues .first() .and_then(|problem| problem["status"].as_str()) .unwrap_or("compatible"); imports.insert(provider.clone(), json!({"status":status,"problems":issues})); dot.push_str(&format!( " {} -> {} [label={}];\n", json!(name), json!(provider), json!(status) )); } entries.insert( name.clone(), json!({"hash":interface.hash,"imports":imports,"error":interface.error}), ); } dot.push_str("}\n"); let mut graph = self.0.graph.lock().unwrap(); graph.json = serde_json::to_vec(&json!({"plugins":entries})).unwrap(); graph.dot = dot.into_bytes(); graph.plugins = plugins; } } /// Converts a filesystem failure into a JSON-RPC internal error. pub(crate) fn io_error(error: std::io::Error) -> Value { crate::error(-32603, &error.to_string()) }