1use std::path::{Path, PathBuf};
16
17use base64::Engine;
18use ed25519_dalek::{Signature, Verifier, VerifyingKey};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22use super::widget_packages::{self as wp, WidgetState};
23use super::widget_signature as sig;
24
25pub const DEFAULT_CATALOG_URL: &str = "https://agentmuxai.github.io/widgets/index.json";
27
28pub const CATALOG_KEYS: &[&str] = &[
33 "iFz5TLnwIg/oApX6WVrjmdVT6HDl133MeaWlQpA+MjE=",
35];
36
37const MAX_INDEX_BYTES: usize = 5 * 1024 * 1024;
38const CATALOG_VERSION: u32 = 1;
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
41#[serde(rename_all = "camelCase")]
42#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "WidgetCatalogEntry")]
43pub struct CatalogEntry {
44 pub id: String,
45 pub name: String,
46 pub version: String,
47 #[serde(default)]
48 pub description: Option<String>,
49 #[serde(default)]
50 pub author: Option<String>,
51 #[serde(default)]
52 pub homepage: Option<String>,
53 #[serde(default)]
54 pub icon: Option<String>,
55 #[serde(default)]
56 pub permissions: Vec<String>,
57 pub hash: String,
59 pub publisher_key: String,
61 pub zip: String,
62 pub zip_sha256: String,
63}
64
65#[derive(Debug, Clone, Deserialize)]
66#[serde(rename_all = "camelCase")]
67struct CatalogIndex {
68 catalog_version: u32,
69 #[serde(default)]
70 widgets: Vec<CatalogEntry>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
76#[ts(export, export_to = "../../../frontend/types/rpc/")]
77pub struct WidgetCatalogItem {
78 pub entry: CatalogEntry,
79 pub fingerprint: String,
80 pub installed_version: Option<String>,
81 pub installed_state: Option<WidgetState>,
82 pub current: bool,
84}
85
86pub fn verify_index(raw: &[u8], sig_b64: &str, keys: &[&str], index_url: &str) -> Result<Vec<CatalogEntry>, String> {
89 let b64 = base64::engine::general_purpose::STANDARD;
90 let signature: [u8; 64] = b64
91 .decode(sig_b64.trim())
92 .ok()
93 .and_then(|s| s.try_into().ok())
94 .ok_or("the catalog's signature isn't 64 bytes of base64")?;
95 let signature = Signature::from_bytes(&signature);
96 let signed = keys.iter().any(|k| {
97 b64.decode(k)
98 .ok()
99 .and_then(|k| <[u8; 32]>::try_from(k).ok())
100 .and_then(|k| VerifyingKey::from_bytes(&k).ok())
101 .is_some_and(|k| k.verify(raw, &signature).is_ok())
102 });
103 if !signed {
104 return Err("the catalog's signature doesn't verify: it isn't the AgentMux catalog, or it was changed".to_string());
105 }
106 let index: CatalogIndex = serde_json::from_slice(raw).map_err(|e| format!("the catalog index: {e}"))?;
107 if index.catalog_version != CATALOG_VERSION {
108 return Err(format!("the catalog is version {}, this AgentMux reads {CATALOG_VERSION}", index.catalog_version));
109 }
110 let origin = origin_of(index_url).ok_or("the catalog URL isn't an https:// URL")?;
111 for e in &index.widgets {
112 if !wp::valid_id(&e.id) {
113 return Err(format!("the catalog lists {:?}, which isn't a widget id", e.id));
114 }
115 if origin_of(&e.zip).as_deref() != Some(origin.as_str()) {
116 return Err(format!("the catalog's {} isn't served from the catalog itself", e.id));
117 }
118 }
119 Ok(index.widgets)
120}
121
122fn origin_of(url: &str) -> Option<String> {
124 let rest = url.strip_prefix("https://")?;
125 let host = rest.split(['/', '?', '#']).next()?;
126 (!host.is_empty() && !host.contains('@')).then(|| format!("https://{host}"))
127}
128
129fn client() -> Result<reqwest::Client, String> {
130 reqwest::Client::builder()
131 .timeout(std::time::Duration::from_secs(60))
132 .redirect(reqwest::redirect::Policy::none())
133 .build()
134 .map_err(|e| e.to_string())
135}
136
137async fn get(url: &str, max: usize) -> Result<Vec<u8>, String> {
140 let too_big = || format!("{url} is over the {} KB it may be", max / 1024);
141 let mut resp = client()?.get(url).send().await.map_err(|e| format!("can't reach the catalog: {e}"))?;
142 if !resp.status().is_success() {
143 return Err(format!("the catalog answered {} for {url}", resp.status()));
144 }
145 if resp.content_length().is_some_and(|n| n > max as u64) {
146 return Err(too_big());
147 }
148 let mut body = Vec::new();
149 while let Some(chunk) = resp.chunk().await.map_err(|e| format!("the catalog: {e}"))? {
150 if body.len() + chunk.len() > max {
151 return Err(too_big());
152 }
153 body.extend_from_slice(&chunk);
154 }
155 Ok(body)
156}
157
158pub async fn fetch(url: &str) -> Result<Vec<CatalogEntry>, String> {
160 let raw = get(url, MAX_INDEX_BYTES).await?;
161 let sig_text = String::from_utf8(get(&format!("{url}.sig"), 1024).await?).map_err(|_| "the catalog's signature isn't text")?;
162 verify_index(&raw, &sig_text, CATALOG_KEYS, url)
163}
164
165pub fn items(entries: Vec<CatalogEntry>, installed: &[wp::WidgetPackageInfo]) -> Vec<WidgetCatalogItem> {
167 entries
168 .into_iter()
169 .map(|entry| {
170 let i = installed.iter().find(|p| p.id == entry.id);
171 WidgetCatalogItem {
172 fingerprint: sig::fingerprint(&entry.publisher_key),
173 installed_version: i.map(|p| p.version.clone()),
174 installed_state: i.map(|p| p.state.clone()),
175 current: i.is_some_and(|p| p.hash == entry.hash),
176 entry,
177 }
178 })
179 .collect()
180}
181
182pub fn check_download(zip: &[u8], entry: &CatalogEntry) -> Result<(tempfile::TempDir, PathBuf), String> {
186 if hex::encode(Sha256::digest(zip)) != entry.zip_sha256 {
187 return Err(format!("{}'s download doesn't match the catalog", entry.id));
188 }
189 let tmp = tempfile::tempdir().map_err(|e| e.to_string())?;
190 let zip_path = tmp.path().join("pkg.zip");
191 std::fs::write(&zip_path, zip).map_err(|e| e.to_string())?;
192 let stage = tmp.path().join("pkg");
193 wp::unzip(&zip_path, &stage)?;
194 let root = package_root(&stage)?;
195 let hash = wp::package_hash(&wp::hash_files(&root)?);
196 if hash != entry.hash {
197 return Err(format!("{}'s files don't match the catalog", entry.id));
198 }
199 let manifest: wp::Manifest = serde_json::from_slice(&std::fs::read(root.join(wp::MANIFEST_FILE)).map_err(|e| e.to_string())?)
200 .map_err(|e| format!("widget.json: {e}"))?;
201 if manifest.id != entry.id || manifest.version != entry.version {
202 return Err(format!("{}'s widget.json doesn't match the catalog", entry.id));
203 }
204 if manifest.kind() != wp::WidgetKind::Sandboxed {
205 return Err("the catalog lists sandboxed widgets only".to_string());
206 }
207 match sig::check(&root, &entry.id, &entry.version, &hash) {
208 Some(Ok(key)) if key == entry.publisher_key => {}
209 Some(Ok(_)) | None => return Err(format!("{} isn't signed by the publisher the catalog lists", entry.id)),
210 Some(Err(e)) => return Err(e),
211 }
212 std::fs::remove_file(&zip_path).ok();
213 Ok((tmp, root))
214}
215
216fn package_root(stage: &Path) -> Result<PathBuf, String> {
218 if stage.join(wp::MANIFEST_FILE).exists() {
219 return Ok(stage.to_path_buf());
220 }
221 let mut dirs = std::fs::read_dir(stage).map_err(|e| e.to_string())?.flatten().filter(|e| e.path().is_dir());
222 match (dirs.next(), dirs.next()) {
223 (Some(d), None) if d.path().join(wp::MANIFEST_FILE).exists() => Ok(d.path()),
224 _ => Err("no widget.json at the package's top level".to_string()),
225 }
226}
227
228pub async fn install(widgets_dir: &Path, entry: &CatalogEntry) -> Result<String, String> {
231 let zip = get(&entry.zip, (wp::MAX_PACKAGE_BYTES + 1024 * 1024) as usize).await?;
232 let (entry, dir) = (entry.clone(), widgets_dir.to_path_buf());
233 tokio::task::spawn_blocking(move || {
234 let (_tmp, root) = check_download(&zip, &entry)?;
235 wp::install(&dir, &root, true)
236 })
237 .await
238 .map_err(|e| e.to_string())?
239}
240
241#[cfg(test)]
242#[path = "widget_catalog_tests.rs"]
243mod tests;