1use anyhow::{anyhow, Context};
2use fd_lock::RwLock;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::BTreeMap;
6use std::fs;
7use std::io::{Cursor, Read};
8use std::path::{Path, PathBuf};
9
10const MANIFEST_FILE_NAME: &str = "manifest.json";
11const CURRENT_RUNTIME_API_VERSION: u32 = 2;
12const LOCK_FILE_NAME: &str = ".agentzero-plugins.lock";
13
14fn open_install_lock(install_root: &Path) -> anyhow::Result<RwLock<fs::File>> {
17 fs::create_dir_all(install_root)
18 .with_context(|| format!("failed to create install root {}", install_root.display()))?;
19 let lock_path = install_root.join(LOCK_FILE_NAME);
20 let lock_file = fs::OpenOptions::new()
21 .create(true)
22 .write(true)
23 .truncate(true)
24 .open(&lock_path)
25 .with_context(|| format!("failed to open lock file {}", lock_path.display()))?;
26 Ok(RwLock::new(lock_file))
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct PluginManifest {
31 pub id: String,
32 pub version: String,
33 #[serde(default)]
34 pub description: Option<String>,
35 pub entrypoint: String,
36 pub wasm_file: String,
37 pub wasm_sha256: String,
38 #[serde(default)]
39 pub capabilities: Vec<String>,
40 #[serde(default)]
41 pub hooks: Vec<String>,
42 #[serde(default = "default_runtime_api_version")]
43 pub min_runtime_api: u32,
44 #[serde(default = "default_runtime_api_version")]
45 pub max_runtime_api: u32,
46 pub allowed_host_calls: Vec<String>,
47}
48
49impl PluginManifest {
50 pub fn validate(&self) -> anyhow::Result<()> {
51 if self.id.trim().is_empty() {
52 return Err(anyhow!("plugin id cannot be empty"));
53 }
54 if self.version.trim().is_empty() {
55 return Err(anyhow!("plugin version cannot be empty"));
56 }
57 if self.entrypoint.trim().is_empty() {
58 return Err(anyhow!("plugin entrypoint cannot be empty"));
59 }
60 if self.wasm_file.trim().is_empty() {
61 return Err(anyhow!("plugin wasm_file cannot be empty"));
62 }
63 if !self.wasm_file.ends_with(".wasm") {
64 return Err(anyhow!("plugin wasm_file must end with .wasm"));
65 }
66 if self.wasm_sha256.len() != 64 || !self.wasm_sha256.chars().all(|c| c.is_ascii_hexdigit())
67 {
68 return Err(anyhow!("plugin wasm_sha256 must be a 64-char hex digest"));
69 }
70 if self.min_runtime_api == 0 {
71 return Err(anyhow!("plugin min_runtime_api must be >= 1"));
72 }
73 if self.max_runtime_api == 0 {
74 return Err(anyhow!("plugin max_runtime_api must be >= 1"));
75 }
76 if self.min_runtime_api > self.max_runtime_api {
77 return Err(anyhow!(
78 "plugin runtime API range is invalid (min_runtime_api > max_runtime_api)"
79 ));
80 }
81 self.validate_runtime_compatibility(CURRENT_RUNTIME_API_VERSION)?;
82 Ok(())
83 }
84
85 pub fn validate_runtime_compatibility(&self, current_api: u32) -> anyhow::Result<()> {
86 if current_api < self.min_runtime_api || current_api > self.max_runtime_api {
87 return Err(anyhow!(
88 "plugin runtime API compatibility failed: current={current_api}, supported={}..={}",
89 self.min_runtime_api,
90 self.max_runtime_api
91 ));
92 }
93 Ok(())
94 }
95}
96
97fn default_runtime_api_version() -> u32 {
98 CURRENT_RUNTIME_API_VERSION
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct InstalledPlugin {
103 pub install_dir: PathBuf,
104 pub manifest_path: PathBuf,
105 pub wasm_path: PathBuf,
106 pub manifest: PluginManifest,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110pub struct InstalledPluginRecord {
111 pub id: String,
112 pub version: String,
113 pub install_dir: PathBuf,
114 pub manifest_path: PathBuf,
115}
116
117pub fn package_plugin(
118 wasm_module_path: impl AsRef<Path>,
119 mut manifest: PluginManifest,
120 package_path: impl AsRef<Path>,
121) -> anyhow::Result<()> {
122 let wasm_module_path = wasm_module_path.as_ref();
123 let package_path = package_path.as_ref();
124
125 if wasm_module_path.extension().and_then(|v| v.to_str()) != Some("wasm") {
126 return Err(anyhow!("plugin module must be a .wasm file"));
127 }
128 let wasm_bytes = fs::read(wasm_module_path).with_context(|| {
129 format!(
130 "failed to read wasm module at {}",
131 wasm_module_path.display()
132 )
133 })?;
134
135 manifest.wasm_sha256 = sha256_hex(&wasm_bytes);
137 manifest.validate()?;
138
139 let manifest_bytes =
140 serde_json::to_vec_pretty(&manifest).context("failed to serialize plugin manifest")?;
141
142 if let Some(parent) = package_path.parent() {
143 fs::create_dir_all(parent)
144 .with_context(|| format!("failed to create package output dir {}", parent.display()))?;
145 }
146
147 let file = fs::File::create(package_path)
148 .with_context(|| format!("failed to create package file {}", package_path.display()))?;
149 let mut builder = tar::Builder::new(file);
150
151 let mut manifest_header = tar::Header::new_gnu();
152 manifest_header.set_size(manifest_bytes.len() as u64);
153 manifest_header.set_mode(0o644);
154 manifest_header.set_cksum();
155 builder
156 .append_data(
157 &mut manifest_header,
158 MANIFEST_FILE_NAME,
159 Cursor::new(manifest_bytes),
160 )
161 .context("failed to append manifest to package")?;
162
163 let mut wasm_header = tar::Header::new_gnu();
164 wasm_header.set_size(wasm_bytes.len() as u64);
165 wasm_header.set_mode(0o644);
166 wasm_header.set_cksum();
167 builder
168 .append_data(
169 &mut wasm_header,
170 &manifest.wasm_file,
171 Cursor::new(wasm_bytes),
172 )
173 .context("failed to append wasm module to package")?;
174
175 builder
176 .finish()
177 .context("failed to finalize plugin package")?;
178 Ok(())
179}
180
181pub fn install_packaged_plugin(
182 package_path: impl AsRef<Path>,
183 install_root: impl AsRef<Path>,
184) -> anyhow::Result<InstalledPlugin> {
185 let package_path = package_path.as_ref();
186 let install_root = install_root.as_ref();
187
188 let mut lock = open_install_lock(install_root)?;
190 let _guard = lock
191 .write()
192 .map_err(|e| anyhow!("failed to acquire install lock: {e}"))?;
193
194 let archive_file = fs::File::open(package_path)
195 .with_context(|| format!("failed to open package {}", package_path.display()))?;
196 let mut archive = tar::Archive::new(archive_file);
197
198 let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
199 for entry in archive
200 .entries()
201 .context("failed to read package entries")?
202 {
203 let mut entry = entry.context("failed to parse package entry")?;
204
205 let entry_type = entry.header().entry_type();
207 if entry_type.is_symlink() || entry_type.is_hard_link() {
208 anyhow::bail!("plugin package contains a symlink entry (rejected for security)");
209 }
210
211 let entry_path = entry
212 .path()
213 .context("failed to read package entry path")?
214 .to_string_lossy()
215 .to_string();
216
217 if entry_path.starts_with('/') || entry_path.contains("..") {
219 anyhow::bail!(
220 "path traversal in plugin package: `{entry_path}` (rejected for security)"
221 );
222 }
223
224 let mut bytes = Vec::new();
225 entry
226 .read_to_end(&mut bytes)
227 .with_context(|| format!("failed to read package entry `{entry_path}`"))?;
228 files.insert(entry_path, bytes);
229 }
230
231 let manifest_bytes = files
232 .get(MANIFEST_FILE_NAME)
233 .ok_or_else(|| anyhow!("package missing manifest.json"))?;
234 let manifest: PluginManifest =
235 serde_json::from_slice(manifest_bytes).context("failed to deserialize plugin manifest")?;
236 manifest.validate()?;
237
238 let wasm_bytes = files
239 .get(&manifest.wasm_file)
240 .ok_or_else(|| anyhow!("package missing wasm module `{}`", manifest.wasm_file))?;
241
242 let digest = sha256_hex(wasm_bytes);
243 if digest != manifest.wasm_sha256 {
244 return Err(anyhow!(
245 "integrity check failed for `{}`: checksum mismatch",
246 manifest.wasm_file
247 ));
248 }
249
250 let install_dir = install_root.join(&manifest.id).join(&manifest.version);
251 fs::create_dir_all(&install_dir)
252 .with_context(|| format!("failed to create install dir {}", install_dir.display()))?;
253
254 let manifest_path = install_dir.join(MANIFEST_FILE_NAME);
255 let wasm_path = install_dir.join(&manifest.wasm_file);
256 fs::write(&manifest_path, manifest_bytes)
257 .with_context(|| format!("failed to write manifest at {}", manifest_path.display()))?;
258 fs::write(&wasm_path, wasm_bytes)
259 .with_context(|| format!("failed to write wasm at {}", wasm_path.display()))?;
260
261 Ok(InstalledPlugin {
262 install_dir,
263 manifest_path,
264 wasm_path,
265 manifest,
266 })
267}
268
269pub fn list_installed_plugins(
270 install_root: impl AsRef<Path>,
271) -> anyhow::Result<Vec<InstalledPluginRecord>> {
272 let install_root = install_root.as_ref();
273 if !install_root.exists() {
274 return Ok(Vec::new());
275 }
276
277 let mut records = Vec::new();
278 for plugin_dir in fs::read_dir(install_root)
279 .with_context(|| format!("failed to read install root {}", install_root.display()))?
280 {
281 let plugin_dir = plugin_dir.context("failed to read plugin dir entry")?;
282 if !plugin_dir.file_type()?.is_dir() {
283 continue;
284 }
285 let plugin_id = plugin_dir.file_name().to_string_lossy().to_string();
286
287 for version_dir in fs::read_dir(plugin_dir.path()).with_context(|| {
288 format!(
289 "failed to read plugin versions for {}",
290 plugin_dir.path().display()
291 )
292 })? {
293 let version_dir = version_dir.context("failed to read plugin version entry")?;
294 if !version_dir.file_type()?.is_dir() {
295 continue;
296 }
297 let version = version_dir.file_name().to_string_lossy().to_string();
298 let manifest_path = version_dir.path().join(MANIFEST_FILE_NAME);
299 if !manifest_path.exists() {
300 continue;
301 }
302 records.push(InstalledPluginRecord {
303 id: plugin_id.clone(),
304 version,
305 install_dir: version_dir.path(),
306 manifest_path,
307 });
308 }
309 }
310
311 records.sort_by(|a, b| {
312 a.id.cmp(&b.id).then_with(|| {
313 match (
314 semver::Version::parse(&a.version),
315 semver::Version::parse(&b.version),
316 ) {
317 (Ok(va), Ok(vb)) => va.cmp(&vb),
318 _ => a.version.cmp(&b.version),
319 }
320 })
321 });
322 Ok(records)
323}
324
325pub fn remove_installed_plugin(
326 install_root: impl AsRef<Path>,
327 plugin_id: &str,
328 version: Option<&str>,
329) -> anyhow::Result<usize> {
330 let install_root = install_root.as_ref();
331 if plugin_id.trim().is_empty() {
332 return Err(anyhow!("plugin id cannot be empty"));
333 }
334
335 let mut lock = open_install_lock(install_root)?;
337 let _guard = lock
338 .write()
339 .map_err(|e| anyhow!("failed to acquire install lock: {e}"))?;
340
341 let plugin_root = install_root.join(plugin_id);
342 if !plugin_root.exists() {
343 return Ok(0);
344 }
345
346 if let Some(version) = version {
347 let target = plugin_root.join(version);
348 if !target.exists() {
349 return Ok(0);
350 }
351 fs::remove_dir_all(&target)
352 .with_context(|| format!("failed to remove plugin dir {}", target.display()))?;
353 if plugin_root
354 .read_dir()
355 .with_context(|| format!("failed to read plugin dir {}", plugin_root.display()))?
356 .next()
357 .is_none()
358 {
359 fs::remove_dir_all(&plugin_root).with_context(|| {
360 format!("failed to remove plugin root {}", plugin_root.display())
361 })?;
362 }
363 return Ok(1);
364 }
365
366 let mut removed = 0usize;
367 for entry in fs::read_dir(&plugin_root)
368 .with_context(|| format!("failed to read plugin dir {}", plugin_root.display()))?
369 {
370 let entry = entry.context("failed to parse plugin version entry")?;
371 if entry.file_type()?.is_dir() {
372 fs::remove_dir_all(entry.path()).with_context(|| {
373 format!(
374 "failed to remove plugin version dir {}",
375 entry.path().display()
376 )
377 })?;
378 removed += 1;
379 }
380 }
381 fs::remove_dir_all(&plugin_root)
382 .with_context(|| format!("failed to remove plugin root {}", plugin_root.display()))?;
383 Ok(removed)
384}
385
386#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct DiscoveredPlugin {
389 pub manifest: PluginManifest,
390 pub wasm_path: PathBuf,
391 pub dev_mode: bool,
393}
394
395pub fn discover_plugins(
410 global_plugin_dir: Option<&Path>,
411 project_plugin_dir: Option<&Path>,
412 cwd_plugin_dir: Option<&Path>,
413) -> Vec<DiscoveredPlugin> {
414 let mut plugins: std::collections::HashMap<String, DiscoveredPlugin> =
415 std::collections::HashMap::new();
416
417 if let Some(dir) = global_plugin_dir {
419 for plugin in scan_plugin_dir(dir, false) {
420 plugins.insert(plugin.manifest.id.clone(), plugin);
421 }
422 }
423
424 if let Some(dir) = project_plugin_dir {
426 for plugin in scan_plugin_dir(dir, false) {
427 plugins.insert(plugin.manifest.id.clone(), plugin);
428 }
429 }
430
431 if let Some(dir) = cwd_plugin_dir {
433 for plugin in scan_plugin_dir(dir, true) {
434 plugins.insert(plugin.manifest.id.clone(), plugin);
435 }
436 }
437
438 let mut result: Vec<DiscoveredPlugin> = plugins.into_values().collect();
439 result.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
440 result
441}
442
443fn scan_plugin_dir(dir: &Path, dev_mode: bool) -> Vec<DiscoveredPlugin> {
449 let mut found = Vec::new();
450
451 let entries = match fs::read_dir(dir) {
452 Ok(entries) => entries,
453 Err(_) => return found, };
455
456 for entry in entries {
457 let entry = match entry {
458 Ok(e) => e,
459 Err(_) => continue,
460 };
461 if !entry.path().is_dir() {
462 continue;
463 }
464
465 let flat_manifest = entry.path().join(MANIFEST_FILE_NAME);
467 if flat_manifest.exists() {
468 if let Some(plugin) = try_load_plugin(&entry.path(), dev_mode) {
469 found.push(plugin);
470 continue;
471 }
472 }
473
474 if let Ok(version_entries) = fs::read_dir(entry.path()) {
476 let mut best: Option<DiscoveredPlugin> = None;
478 for version_entry in version_entries {
479 let version_entry = match version_entry {
480 Ok(e) => e,
481 Err(_) => continue,
482 };
483 if !version_entry.path().is_dir() {
484 continue;
485 }
486 if let Some(plugin) = try_load_plugin(&version_entry.path(), dev_mode) {
487 match &best {
488 Some(existing)
489 if version_ge(&existing.manifest.version, &plugin.manifest.version) => {
490 }
491 _ => best = Some(plugin),
492 }
493 }
494 }
495 if let Some(plugin) = best {
496 found.push(plugin);
497 }
498 }
499 }
500
501 found
502}
503
504fn try_load_plugin(dir: &Path, dev_mode: bool) -> Option<DiscoveredPlugin> {
506 let manifest_path = dir.join(MANIFEST_FILE_NAME);
507 let bytes = match fs::read(&manifest_path) {
508 Ok(b) => b,
509 Err(_) => return None,
510 };
511 let manifest: PluginManifest = match serde_json::from_slice(&bytes) {
512 Ok(m) => m,
513 Err(e) => {
514 #[cfg(feature = "wasm-runtime")]
515 tracing::warn!(
516 "skipping plugin at {}: invalid manifest: {e}",
517 dir.display()
518 );
519 let _ = e;
520 return None;
521 }
522 };
523 if let Err(e) = manifest.validate() {
524 #[cfg(feature = "wasm-runtime")]
525 tracing::warn!("skipping plugin {}: validation failed: {e}", manifest.id);
526 let _ = e;
527 return None;
528 }
529
530 let wasm_path = dir.join(&manifest.wasm_file);
531 if !wasm_path.exists() {
532 #[cfg(feature = "wasm-runtime")]
533 tracing::warn!(
534 "skipping plugin {}: wasm file not found at {}",
535 manifest.id,
536 wasm_path.display()
537 );
538 return None;
539 }
540
541 Some(DiscoveredPlugin {
542 manifest,
543 wasm_path,
544 dev_mode,
545 })
546}
547
548fn version_ge(a: &str, b: &str) -> bool {
551 match (semver::Version::parse(a), semver::Version::parse(b)) {
552 (Ok(va), Ok(vb)) => va >= vb,
553 _ => a >= b,
554 }
555}
556
557fn sha256_hex(bytes: &[u8]) -> String {
558 let mut hasher = Sha256::new();
559 hasher.update(bytes);
560 let digest = hasher.finalize();
561 format!("{digest:x}")
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct PluginStateEntry {
569 pub version: String,
570 pub enabled: bool,
571 pub installed_at: String,
572 pub source: String,
574}
575
576#[derive(Debug, Clone, Default, Serialize, Deserialize)]
578pub struct PluginState {
579 pub plugins: std::collections::HashMap<String, PluginStateEntry>,
580}
581
582const STATE_FILE_NAME: &str = "plugin-state.json";
583
584impl PluginState {
585 pub fn load(data_dir: &Path) -> Self {
588 let path = data_dir.join(STATE_FILE_NAME);
589 match fs::read_to_string(&path) {
590 Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
591 Err(_) => Self::default(),
592 }
593 }
594
595 pub fn save(&self, data_dir: &Path) -> anyhow::Result<()> {
597 let path = data_dir.join(STATE_FILE_NAME);
598 if let Some(parent) = path.parent() {
599 fs::create_dir_all(parent)
600 .with_context(|| format!("failed to create state dir: {}", parent.display()))?;
601 }
602 let json =
603 serde_json::to_string_pretty(self).context("failed to serialize plugin state")?;
604 fs::write(&path, json)
605 .with_context(|| format!("failed to write plugin state: {}", path.display()))?;
606 Ok(())
607 }
608
609 pub fn is_enabled(&self, id: &str) -> bool {
612 self.plugins.get(id).map(|e| e.enabled).unwrap_or(true)
613 }
614
615 pub fn enable(&mut self, id: &str) -> anyhow::Result<()> {
617 match self.plugins.get_mut(id) {
618 Some(entry) => {
619 entry.enabled = true;
620 Ok(())
621 }
622 None => Err(anyhow!(
623 "plugin '{}' has no state entry (not installed via CLI)",
624 id
625 )),
626 }
627 }
628
629 pub fn disable(&mut self, id: &str) -> anyhow::Result<()> {
631 match self.plugins.get_mut(id) {
632 Some(entry) => {
633 entry.enabled = false;
634 Ok(())
635 }
636 None => Err(anyhow!(
637 "plugin '{}' has no state entry (not installed via CLI)",
638 id
639 )),
640 }
641 }
642
643 pub fn record_install(&mut self, id: &str, version: &str, source: &str) {
645 self.plugins.insert(
646 id.to_string(),
647 PluginStateEntry {
648 version: version.to_string(),
649 enabled: true,
650 installed_at: chrono_now_iso(),
651 source: source.to_string(),
652 },
653 );
654 }
655
656 pub fn remove(&mut self, id: &str) {
658 self.plugins.remove(id);
659 }
660}
661
662fn chrono_now_iso() -> String {
663 let dur = std::time::SystemTime::now()
665 .duration_since(std::time::UNIX_EPOCH)
666 .unwrap_or_default();
667 let secs = dur.as_secs();
668 format!("{secs}")
670}
671
672pub fn filter_by_state(
674 plugins: Vec<DiscoveredPlugin>,
675 state: &PluginState,
676) -> Vec<DiscoveredPlugin> {
677 plugins
678 .into_iter()
679 .filter(|p| state.is_enabled(&p.manifest.id))
680 .collect()
681}
682
683pub fn install_from_url(
686 url: &str,
687 install_root: &Path,
688 expected_sha256: Option<&str>,
689) -> anyhow::Result<InstalledPlugin> {
690 let bytes = if let Some(file_path) = url.strip_prefix("file://") {
696 fs::read(file_path).with_context(|| format!("failed to read local package: {file_path}"))?
697 } else {
698 return Err(anyhow!(
699 "URL-based install requires HTTP support. Use 'file://<path>' for local archives \
700 or download the package manually and use '--package <path>'."
701 ));
702 };
703
704 if let Some(expected) = expected_sha256 {
706 let actual = sha256_hex(&bytes);
707 if actual != expected {
708 return Err(anyhow!(
709 "SHA256 mismatch: expected {expected}, got {actual}"
710 ));
711 }
712 }
713
714 let tmp_dir =
716 std::env::temp_dir().join(format!("agentzero-plugin-download-{}", std::process::id()));
717 fs::create_dir_all(&tmp_dir)?;
718 let tmp_path = tmp_dir.join("package.tar");
719 fs::write(&tmp_path, &bytes)?;
720
721 let result = install_packaged_plugin(&tmp_path, install_root);
722
723 fs::remove_dir_all(&tmp_dir).ok();
725
726 result
727}
728
729#[derive(Debug, Clone, Serialize, Deserialize)]
733pub struct RegistryVersionEntry {
734 pub version: String,
735 pub download_url: String,
736 pub sha256: String,
737 pub min_runtime_api: u32,
738 pub max_runtime_api: u32,
739}
740
741#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct RegistryEntry {
744 pub id: String,
745 pub description: String,
746 #[serde(default)]
747 pub category: String,
748 #[serde(default)]
749 pub author: String,
750 #[serde(default)]
751 pub repository: String,
752 pub latest: String,
753 pub versions: Vec<RegistryVersionEntry>,
754}
755
756#[derive(Debug, Clone, Default, Serialize, Deserialize)]
758pub struct RegistryIndex {
759 pub plugins: Vec<RegistryEntry>,
760}
761
762const REGISTRY_CACHE_DIR: &str = "registry-cache";
763const REGISTRY_INDEX_FILE: &str = "index.json";
764const REGISTRY_CACHE_MAX_AGE_SECS: u64 = 3600; impl RegistryIndex {
767 pub fn load_cached(data_dir: &Path) -> Option<Self> {
770 let cache_path = data_dir.join(REGISTRY_CACHE_DIR).join(REGISTRY_INDEX_FILE);
771 if !cache_path.exists() {
772 return None;
773 }
774
775 if let Ok(meta) = fs::metadata(&cache_path) {
777 if let Ok(modified) = meta.modified() {
778 let age = std::time::SystemTime::now()
779 .duration_since(modified)
780 .unwrap_or_default();
781 if age.as_secs() > REGISTRY_CACHE_MAX_AGE_SECS {
782 return None;
783 }
784 }
785 }
786
787 let data = fs::read_to_string(&cache_path).ok()?;
788 serde_json::from_str(&data).ok()
789 }
790
791 pub fn save_cache(&self, data_dir: &Path) -> anyhow::Result<()> {
793 let cache_dir = data_dir.join(REGISTRY_CACHE_DIR);
794 fs::create_dir_all(&cache_dir)?;
795 let json = serde_json::to_string_pretty(self)?;
796 fs::write(cache_dir.join(REGISTRY_INDEX_FILE), json)?;
797 Ok(())
798 }
799
800 pub fn search(&self, query: &str) -> Vec<&RegistryEntry> {
803 let q = query.to_lowercase();
804 self.plugins
805 .iter()
806 .filter(|p| {
807 p.id.to_lowercase().contains(&q)
808 || p.description.to_lowercase().contains(&q)
809 || p.category.to_lowercase().contains(&q)
810 })
811 .collect()
812 }
813
814 pub fn get(&self, id: &str) -> Option<&RegistryEntry> {
816 self.plugins.iter().find(|p| p.id == id)
817 }
818}
819
820impl RegistryEntry {
821 pub fn latest_version(&self) -> Option<&RegistryVersionEntry> {
823 self.versions.iter().find(|v| v.version == self.latest)
824 }
825
826 pub fn has_update(&self, current: &str) -> bool {
828 self.latest != current
829 }
830}
831
832pub fn load_registry_index(
837 data_dir: &Path,
838 registry_url: Option<&str>,
839) -> anyhow::Result<RegistryIndex> {
840 if let Some(cached) = RegistryIndex::load_cached(data_dir) {
842 return Ok(cached);
843 }
844
845 if let Some(url) = registry_url {
847 if let Some(file_path) = url.strip_prefix("file://") {
848 let data = fs::read_to_string(file_path)
849 .with_context(|| format!("failed to read registry index: {file_path}"))?;
850 let index: RegistryIndex =
851 serde_json::from_str(&data).with_context(|| "failed to parse registry index")?;
852 index.save_cache(data_dir)?;
853 return Ok(index);
854 }
855 return Err(anyhow!(
856 "HTTP registry fetch not yet supported. Use 'file://<path>' for local registries \
857 or run 'plugin refresh' after manually downloading the index."
858 ));
859 }
860
861 Err(anyhow!(
862 "No registry cache found and no registry URL configured. \
863 Set 'plugins.registry_url' in config or run 'plugin refresh --url <url>'."
864 ))
865}
866
867pub fn check_outdated(state: &PluginState, index: &RegistryIndex) -> Vec<(String, String, String)> {
869 let mut outdated = Vec::new();
871 for (id, entry) in &state.plugins {
872 if let Some(reg) = index.get(id) {
873 if reg.has_update(&entry.version) {
874 outdated.push((id.clone(), entry.version.clone(), reg.latest.clone()));
875 }
876 }
877 }
878 outdated
879}
880
881#[derive(Debug, Clone)]
885pub struct RegistryEntryParams<'a> {
886 pub manifest: &'a PluginManifest,
887 pub description: &'a str,
888 pub category: &'a str,
889 pub author: &'a str,
890 pub repository: &'a str,
891 pub download_url: &'a str,
892 pub wasm_sha256: &'a str,
893}
894
895pub fn generate_registry_entry(params: &RegistryEntryParams<'_>) -> RegistryEntry {
897 RegistryEntry {
898 id: params.manifest.id.clone(),
899 description: params.description.to_string(),
900 category: params.category.to_string(),
901 author: params.author.to_string(),
902 repository: params.repository.to_string(),
903 latest: params.manifest.version.clone(),
904 versions: vec![RegistryVersionEntry {
905 version: params.manifest.version.clone(),
906 download_url: params.download_url.to_string(),
907 sha256: params.wasm_sha256.to_string(),
908 min_runtime_api: params.manifest.min_runtime_api,
909 max_runtime_api: params.manifest.max_runtime_api,
910 }],
911 }
912}
913
914#[cfg(test)]
915mod tests {
916 use super::{
917 filter_by_state, install_packaged_plugin, list_installed_plugins, package_plugin,
918 remove_installed_plugin, DiscoveredPlugin, PluginManifest, PluginState,
919 };
920 use anyhow::Context;
921 use std::fs;
922 use std::io::Cursor;
923 use std::path::PathBuf;
924
925 fn sample_manifest() -> PluginManifest {
926 PluginManifest {
927 id: "sample-plugin".to_string(),
928 version: "1.0.0".to_string(),
929 description: None,
930 entrypoint: "run".to_string(),
931 wasm_file: "plugin.wasm".to_string(),
932 wasm_sha256: "0".repeat(64),
933 capabilities: vec!["tool.call".to_string()],
934 hooks: vec!["before_tool_call".to_string()],
935 min_runtime_api: 1,
936 max_runtime_api: 2,
937 allowed_host_calls: vec![],
938 }
939 }
940
941 #[test]
942 fn package_and_install_round_trip_success_path() {
943 let tmp = tempfile::tempdir().expect("temp dir should be created");
944 let wasm_path = tmp.path().join("plugin.wasm");
945 let package_path = tmp.path().join("sample-plugin.tar");
946 let install_root = tmp.path().join("installed");
947
948 let wasm_bytes = wat::parse_str(
949 r#"(module
950 (func (export "run") (result i32)
951 i32.const 7)
952 )"#,
953 )
954 .expect("wat should compile");
955 fs::write(&wasm_path, wasm_bytes).expect("wasm file should be written");
956
957 package_plugin(&wasm_path, sample_manifest(), &package_path)
958 .expect("packaging should succeed");
959 let installed =
960 install_packaged_plugin(&package_path, &install_root).expect("install should succeed");
961
962 assert_eq!(installed.manifest.id, "sample-plugin");
963 assert!(installed.manifest_path.exists());
964 assert!(installed.wasm_path.exists());
965 assert_eq!(
966 installed.install_dir,
967 install_root.join("sample-plugin").join("1.0.0")
968 );
969 }
970
971 #[test]
972 fn install_rejects_checksum_mismatch_negative_path() {
973 let tmp = tempfile::tempdir().expect("temp dir should be created");
974 let package_path = tmp.path().join("tampered-plugin.tar");
975 let install_root = tmp.path().join("installed");
976
977 let wasm_bytes = wat::parse_str(
978 r#"(module
979 (func (export "run") (result i32)
980 i32.const 1)
981 )"#,
982 )
983 .expect("wat should compile");
984
985 let mut manifest = sample_manifest();
986 manifest.wasm_sha256 = "f".repeat(64);
987 let manifest_bytes =
988 serde_json::to_vec_pretty(&manifest).expect("manifest should serialize");
989
990 let file = fs::File::create(&package_path).expect("package should be created");
991 let mut builder = tar::Builder::new(file);
992
993 let mut manifest_header = tar::Header::new_gnu();
994 manifest_header.set_size(manifest_bytes.len() as u64);
995 manifest_header.set_mode(0o644);
996 manifest_header.set_cksum();
997 builder
998 .append_data(
999 &mut manifest_header,
1000 "manifest.json",
1001 Cursor::new(manifest_bytes),
1002 )
1003 .expect("manifest should be added");
1004
1005 let mut wasm_header = tar::Header::new_gnu();
1006 wasm_header.set_size(wasm_bytes.len() as u64);
1007 wasm_header.set_mode(0o644);
1008 wasm_header.set_cksum();
1009 builder
1010 .append_data(&mut wasm_header, "plugin.wasm", Cursor::new(wasm_bytes))
1011 .expect("wasm should be added");
1012 builder.finish().expect("archive should finish");
1013
1014 let err = install_packaged_plugin(&package_path, &install_root)
1015 .context("tampered package should fail integrity")
1016 .expect_err("install should fail");
1017 let err_text = format!("{err:#}");
1018 assert!(
1019 err_text.contains("integrity check failed") || err_text.contains("checksum mismatch"),
1020 "unexpected tamper error: {err_text}"
1021 );
1022 }
1023
1024 #[test]
1025 fn list_and_remove_installed_plugins_success_path() {
1026 let tmp = tempfile::tempdir().expect("temp dir should be created");
1027 let wasm_path = tmp.path().join("plugin.wasm");
1028 let package_path = tmp.path().join("sample-plugin.tar");
1029 let install_root = tmp.path().join("installed");
1030
1031 let wasm_bytes = wat::parse_str(
1032 r#"(module
1033 (func (export "run") (result i32)
1034 i32.const 9)
1035 )"#,
1036 )
1037 .expect("wat should compile");
1038 fs::write(&wasm_path, wasm_bytes).expect("wasm should be written");
1039 package_plugin(&wasm_path, sample_manifest(), &package_path)
1040 .expect("package should succeed");
1041 install_packaged_plugin(&package_path, &install_root).expect("install should succeed");
1042
1043 let listed = list_installed_plugins(&install_root).expect("list should succeed");
1044 assert_eq!(listed.len(), 1);
1045 assert_eq!(listed[0].id, "sample-plugin");
1046 assert_eq!(listed[0].version, "1.0.0");
1047
1048 let removed = remove_installed_plugin(&install_root, "sample-plugin", Some("1.0.0"))
1049 .expect("remove should succeed");
1050 assert_eq!(removed, 1);
1051 assert!(list_installed_plugins(&install_root)
1052 .expect("list should succeed")
1053 .is_empty());
1054 }
1055
1056 #[test]
1057 fn remove_installed_plugin_rejects_empty_id_negative_path() {
1058 let tmp = tempfile::tempdir().expect("temp dir should be created");
1059 let install_root = tmp.path().join("installed");
1060 let err =
1061 remove_installed_plugin(&install_root, "", None).expect_err("empty id should fail");
1062 assert!(err.to_string().contains("plugin id cannot be empty"));
1063 }
1064
1065 #[test]
1066 fn manifest_validate_rejects_incompatible_runtime_api_negative_path() {
1067 let mut manifest = sample_manifest();
1068 manifest.min_runtime_api = 3;
1069 manifest.max_runtime_api = 4;
1070
1071 let err = manifest
1072 .validate()
1073 .expect_err("incompatible API should fail");
1074 assert!(err.to_string().contains("runtime API compatibility failed"));
1075 }
1076
1077 use super::discover_plugins;
1080
1081 fn write_test_plugin(dir: &std::path::Path, id: &str, version: &str) {
1082 fs::create_dir_all(dir).expect("plugin dir should be created");
1083 let wasm_bytes =
1084 wat::parse_str(r#"(module (func (export "run") (result i32) i32.const 42))"#)
1085 .expect("wat should compile");
1086 let sha = super::sha256_hex(&wasm_bytes);
1087 let manifest = PluginManifest {
1088 id: id.to_string(),
1089 version: version.to_string(),
1090 description: None,
1091 entrypoint: "run".to_string(),
1092 wasm_file: "plugin.wasm".to_string(),
1093 wasm_sha256: sha,
1094 capabilities: vec![],
1095 hooks: vec![],
1096 min_runtime_api: 1,
1097 max_runtime_api: 2,
1098 allowed_host_calls: vec![],
1099 };
1100 fs::write(
1101 dir.join("manifest.json"),
1102 serde_json::to_vec_pretty(&manifest).expect("manifest should serialize"),
1103 )
1104 .expect("manifest should write");
1105 fs::write(dir.join("plugin.wasm"), &wasm_bytes).expect("wasm should write");
1106 }
1107
1108 #[test]
1109 fn discover_plugins_empty_dirs_returns_empty() {
1110 let tmp = tempfile::tempdir().expect("temp dir");
1111 let global = tmp.path().join("global");
1112 let project = tmp.path().join("project");
1113 let cwd = tmp.path().join("cwd");
1114 let found = discover_plugins(Some(&global), Some(&project), Some(&cwd));
1116 assert!(found.is_empty());
1117 }
1118
1119 #[test]
1120 fn discover_plugins_finds_versioned_layout() {
1121 let tmp = tempfile::tempdir().expect("temp dir");
1122 let global = tmp.path().join("global");
1123 write_test_plugin(&global.join("my-tool").join("1.0.0"), "my-tool", "1.0.0");
1124
1125 let found = discover_plugins(Some(&global), None, None);
1126 assert_eq!(found.len(), 1);
1127 assert_eq!(found[0].manifest.id, "my-tool");
1128 assert_eq!(found[0].manifest.version, "1.0.0");
1129 assert!(!found[0].dev_mode);
1130 }
1131
1132 #[test]
1133 fn discover_plugins_finds_flat_layout() {
1134 let tmp = tempfile::tempdir().expect("temp dir");
1135 let cwd = tmp.path().join("plugins");
1136 write_test_plugin(&cwd.join("dev-tool"), "dev-tool", "0.1.0");
1137
1138 let found = discover_plugins(None, None, Some(&cwd));
1139 assert_eq!(found.len(), 1);
1140 assert_eq!(found[0].manifest.id, "dev-tool");
1141 assert!(found[0].dev_mode);
1142 }
1143
1144 #[test]
1145 fn discover_plugins_later_tier_overrides_earlier() {
1146 let tmp = tempfile::tempdir().expect("temp dir");
1147 let global = tmp.path().join("global");
1148 let project = tmp.path().join("project");
1149 write_test_plugin(&global.join("shared").join("1.0.0"), "shared", "1.0.0");
1150 write_test_plugin(&project.join("shared").join("2.0.0"), "shared", "2.0.0");
1151
1152 let found = discover_plugins(Some(&global), Some(&project), None);
1153 assert_eq!(found.len(), 1);
1154 assert_eq!(found[0].manifest.version, "2.0.0");
1155 }
1156
1157 #[test]
1158 fn discover_plugins_picks_latest_version() {
1159 let tmp = tempfile::tempdir().expect("temp dir");
1160 let global = tmp.path().join("global");
1161 write_test_plugin(&global.join("multi-v").join("1.0.0"), "multi-v", "1.0.0");
1162 write_test_plugin(&global.join("multi-v").join("2.0.0"), "multi-v", "2.0.0");
1163
1164 let found = discover_plugins(Some(&global), None, None);
1165 assert_eq!(found.len(), 1);
1166 assert_eq!(found[0].manifest.version, "2.0.0");
1167 }
1168
1169 #[test]
1170 fn discover_plugins_skips_invalid_manifest() {
1171 let tmp = tempfile::tempdir().expect("temp dir");
1172 let global = tmp.path().join("global");
1173 let bad_dir = global.join("bad-plugin").join("1.0.0");
1174 fs::create_dir_all(&bad_dir).expect("dir should be created");
1175 fs::write(bad_dir.join("manifest.json"), b"not json").expect("write bad manifest");
1176 fs::write(bad_dir.join("plugin.wasm"), b"\0asm\x01\0\0\0").expect("write wasm");
1177
1178 let found = discover_plugins(Some(&global), None, None);
1179 assert!(found.is_empty(), "invalid manifest should be skipped");
1180 }
1181
1182 #[test]
1183 fn discover_plugins_skips_missing_wasm_file() {
1184 let tmp = tempfile::tempdir().expect("temp dir");
1185 let global = tmp.path().join("global");
1186 let dir = global.join("no-wasm").join("1.0.0");
1187 fs::create_dir_all(&dir).expect("dir should be created");
1188 let manifest = PluginManifest {
1189 id: "no-wasm".to_string(),
1190 version: "1.0.0".to_string(),
1191 description: None,
1192 entrypoint: "run".to_string(),
1193 wasm_file: "plugin.wasm".to_string(),
1194 wasm_sha256: "a".repeat(64),
1195 capabilities: vec![],
1196 hooks: vec![],
1197 min_runtime_api: 1,
1198 max_runtime_api: 2,
1199 allowed_host_calls: vec![],
1200 };
1201 fs::write(
1202 dir.join("manifest.json"),
1203 serde_json::to_vec_pretty(&manifest).expect("serialize"),
1204 )
1205 .expect("write");
1206
1207 let found = discover_plugins(Some(&global), None, None);
1208 assert!(found.is_empty(), "missing wasm should be skipped");
1209 }
1210
1211 #[test]
1212 fn discover_plugins_none_dirs_returns_empty() {
1213 let found = discover_plugins(None, None, None);
1214 assert!(found.is_empty());
1215 }
1216
1217 #[test]
1220 fn plugin_state_load_missing_returns_default() {
1221 let dir = std::env::temp_dir().join(format!("az-state-test-{}", std::process::id()));
1222 let _ = fs::create_dir_all(&dir);
1223 let state = PluginState::load(&dir);
1224 assert!(state.plugins.is_empty());
1225 fs::remove_dir_all(&dir).ok();
1226 }
1227
1228 #[test]
1229 fn plugin_state_save_and_load_round_trip() {
1230 let dir = std::env::temp_dir().join(format!("az-state-test-rt-{}", std::process::id()));
1231 let mut state = PluginState::default();
1232 state.record_install("test-plugin", "1.0.0", "local");
1233 state.save(&dir).expect("save should succeed");
1234
1235 let loaded = PluginState::load(&dir);
1236 assert_eq!(loaded.plugins.len(), 1);
1237 let entry = loaded.plugins.get("test-plugin").unwrap();
1238 assert_eq!(entry.version, "1.0.0");
1239 assert!(entry.enabled);
1240 assert_eq!(entry.source, "local");
1241
1242 fs::remove_dir_all(&dir).ok();
1243 }
1244
1245 #[test]
1246 fn plugin_state_enable_disable_toggle() {
1247 let dir = std::env::temp_dir().join(format!("az-state-test-toggle-{}", std::process::id()));
1248 let mut state = PluginState::default();
1249 state.record_install("toggle-me", "0.1.0", "local");
1250
1251 assert!(state.is_enabled("toggle-me"));
1252
1253 state.disable("toggle-me").unwrap();
1254 assert!(!state.is_enabled("toggle-me"));
1255
1256 state.enable("toggle-me").unwrap();
1257 assert!(state.is_enabled("toggle-me"));
1258
1259 state.save(&dir).expect("save should succeed");
1260 let loaded = PluginState::load(&dir);
1261 assert!(loaded.is_enabled("toggle-me"));
1262
1263 fs::remove_dir_all(&dir).ok();
1264 }
1265
1266 #[test]
1267 fn plugin_state_missing_entry_defaults_to_enabled() {
1268 let state = PluginState::default();
1269 assert!(state.is_enabled("nonexistent"));
1270 }
1271
1272 #[test]
1273 fn plugin_state_enable_missing_fails() {
1274 let mut state = PluginState::default();
1275 let err = state.enable("unknown").expect_err("should fail");
1276 assert!(err.to_string().contains("no state entry"));
1277 }
1278
1279 #[test]
1280 fn plugin_state_remove_entry() {
1281 let mut state = PluginState::default();
1282 state.record_install("removable", "1.0.0", "url");
1283 assert!(state.plugins.contains_key("removable"));
1284 state.remove("removable");
1285 assert!(!state.plugins.contains_key("removable"));
1286 }
1287
1288 #[test]
1289 fn filter_by_state_removes_disabled() {
1290 let mut state = PluginState::default();
1291 state.record_install("enabled-plugin", "1.0.0", "local");
1292 state.record_install("disabled-plugin", "1.0.0", "local");
1293 state.disable("disabled-plugin").unwrap();
1294
1295 let plugins = vec![
1296 DiscoveredPlugin {
1297 manifest: PluginManifest {
1298 id: "enabled-plugin".to_string(),
1299 version: "1.0.0".to_string(),
1300 description: None,
1301 entrypoint: "run".to_string(),
1302 wasm_file: "plugin.wasm".to_string(),
1303 wasm_sha256: "a".repeat(64),
1304 capabilities: vec![],
1305 hooks: vec![],
1306 min_runtime_api: 1,
1307 max_runtime_api: 2,
1308 allowed_host_calls: vec![],
1309 },
1310 wasm_path: PathBuf::from("/tmp/a.wasm"),
1311 dev_mode: false,
1312 },
1313 DiscoveredPlugin {
1314 manifest: PluginManifest {
1315 id: "disabled-plugin".to_string(),
1316 version: "1.0.0".to_string(),
1317 description: None,
1318 entrypoint: "run".to_string(),
1319 wasm_file: "plugin.wasm".to_string(),
1320 wasm_sha256: "b".repeat(64),
1321 capabilities: vec![],
1322 hooks: vec![],
1323 min_runtime_api: 1,
1324 max_runtime_api: 2,
1325 allowed_host_calls: vec![],
1326 },
1327 wasm_path: PathBuf::from("/tmp/b.wasm"),
1328 dev_mode: false,
1329 },
1330 ];
1331
1332 let filtered = filter_by_state(plugins, &state);
1333 assert_eq!(filtered.len(), 1);
1334 assert_eq!(filtered[0].manifest.id, "enabled-plugin");
1335 }
1336
1337 use super::{
1340 check_outdated, generate_registry_entry, load_registry_index, RegistryEntry,
1341 RegistryEntryParams, RegistryIndex, RegistryVersionEntry,
1342 };
1343
1344 fn sample_registry_index() -> RegistryIndex {
1345 RegistryIndex {
1346 plugins: vec![
1347 RegistryEntry {
1348 id: "hardware-tools".to_string(),
1349 description: "Board info and memory tools".to_string(),
1350 category: "hardware".to_string(),
1351 author: "agentzero".to_string(),
1352 repository: "https://github.com/agentzero/plugins".to_string(),
1353 latest: "1.2.0".to_string(),
1354 versions: vec![
1355 RegistryVersionEntry {
1356 version: "1.0.0".to_string(),
1357 download_url: "https://example.com/hw-1.0.0.tar".to_string(),
1358 sha256: "a".repeat(64),
1359 min_runtime_api: 2,
1360 max_runtime_api: 2,
1361 },
1362 RegistryVersionEntry {
1363 version: "1.2.0".to_string(),
1364 download_url: "https://example.com/hw-1.2.0.tar".to_string(),
1365 sha256: "b".repeat(64),
1366 min_runtime_api: 2,
1367 max_runtime_api: 2,
1368 },
1369 ],
1370 },
1371 RegistryEntry {
1372 id: "cron-suite".to_string(),
1373 description: "Cron job management and scheduling".to_string(),
1374 category: "scheduling".to_string(),
1375 author: "agentzero".to_string(),
1376 repository: "https://github.com/agentzero/plugins".to_string(),
1377 latest: "0.3.0".to_string(),
1378 versions: vec![RegistryVersionEntry {
1379 version: "0.3.0".to_string(),
1380 download_url: "https://example.com/cron-0.3.0.tar".to_string(),
1381 sha256: "c".repeat(64),
1382 min_runtime_api: 2,
1383 max_runtime_api: 2,
1384 }],
1385 },
1386 ],
1387 }
1388 }
1389
1390 #[test]
1391 fn registry_search_by_id() {
1392 let index = sample_registry_index();
1393 let results = index.search("hardware");
1394 assert_eq!(results.len(), 1);
1395 assert_eq!(results[0].id, "hardware-tools");
1396 }
1397
1398 #[test]
1399 fn registry_search_by_description() {
1400 let index = sample_registry_index();
1401 let results = index.search("scheduling");
1402 assert_eq!(results.len(), 1);
1403 assert_eq!(results[0].id, "cron-suite");
1404 }
1405
1406 #[test]
1407 fn registry_search_case_insensitive() {
1408 let index = sample_registry_index();
1409 let results = index.search("CRON");
1410 assert_eq!(results.len(), 1);
1411 }
1412
1413 #[test]
1414 fn registry_search_no_match() {
1415 let index = sample_registry_index();
1416 let results = index.search("nonexistent");
1417 assert!(results.is_empty());
1418 }
1419
1420 #[test]
1421 fn registry_get_by_id() {
1422 let index = sample_registry_index();
1423 let entry = index.get("hardware-tools");
1424 assert!(entry.is_some());
1425 assert_eq!(entry.unwrap().latest, "1.2.0");
1426 }
1427
1428 #[test]
1429 fn registry_get_missing_returns_none() {
1430 let index = sample_registry_index();
1431 assert!(index.get("missing").is_none());
1432 }
1433
1434 #[test]
1435 fn registry_entry_latest_version() {
1436 let index = sample_registry_index();
1437 let entry = index.get("hardware-tools").unwrap();
1438 let latest = entry.latest_version().unwrap();
1439 assert_eq!(latest.version, "1.2.0");
1440 assert!(latest.download_url.contains("1.2.0"));
1441 }
1442
1443 #[test]
1444 fn registry_entry_has_update() {
1445 let index = sample_registry_index();
1446 let entry = index.get("hardware-tools").unwrap();
1447 assert!(entry.has_update("1.0.0"));
1448 assert!(!entry.has_update("1.2.0"));
1449 }
1450
1451 #[test]
1452 fn registry_cache_save_and_load() {
1453 let dir =
1454 std::env::temp_dir().join(format!("az-registry-cache-test-{}", std::process::id()));
1455 let _ = fs::create_dir_all(&dir);
1456
1457 let index = sample_registry_index();
1458 index.save_cache(&dir).expect("save should succeed");
1459
1460 let loaded = RegistryIndex::load_cached(&dir);
1461 assert!(loaded.is_some());
1462 let loaded = loaded.unwrap();
1463 assert_eq!(loaded.plugins.len(), 2);
1464
1465 fs::remove_dir_all(&dir).ok();
1466 }
1467
1468 #[test]
1469 fn registry_cache_missing_returns_none() {
1470 let dir =
1471 std::env::temp_dir().join(format!("az-registry-cache-miss-{}", std::process::id()));
1472 assert!(RegistryIndex::load_cached(&dir).is_none());
1473 }
1474
1475 #[test]
1476 fn load_registry_from_file_url() {
1477 let dir =
1478 std::env::temp_dir().join(format!("az-registry-file-test-{}", std::process::id()));
1479 let _ = fs::create_dir_all(&dir);
1480
1481 let index = sample_registry_index();
1482 let index_path = dir.join("test-index.json");
1483 fs::write(&index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
1484
1485 let url = format!("file://{}", index_path.display());
1486 let loaded = load_registry_index(&dir, Some(&url)).expect("should load from file");
1487 assert_eq!(loaded.plugins.len(), 2);
1488
1489 let cached = RegistryIndex::load_cached(&dir);
1491 assert!(cached.is_some());
1492
1493 fs::remove_dir_all(&dir).ok();
1494 }
1495
1496 #[test]
1497 fn load_registry_no_cache_no_url_fails() {
1498 let dir = std::env::temp_dir().join(format!("az-registry-no-cache-{}", std::process::id()));
1499 let err = load_registry_index(&dir, None).expect_err("should fail");
1500 assert!(err.to_string().contains("No registry cache"));
1501 }
1502
1503 #[test]
1504 fn check_outdated_finds_updates() {
1505 let index = sample_registry_index();
1506 let mut state = PluginState::default();
1507 state.record_install("hardware-tools", "1.0.0", "registry");
1508 state.record_install("cron-suite", "0.3.0", "registry");
1509
1510 let outdated = check_outdated(&state, &index);
1511 assert_eq!(outdated.len(), 1);
1512 assert_eq!(outdated[0].0, "hardware-tools");
1513 assert_eq!(outdated[0].1, "1.0.0"); assert_eq!(outdated[0].2, "1.2.0"); }
1516
1517 #[test]
1518 fn check_outdated_none_when_up_to_date() {
1519 let index = sample_registry_index();
1520 let mut state = PluginState::default();
1521 state.record_install("hardware-tools", "1.2.0", "registry");
1522
1523 let outdated = check_outdated(&state, &index);
1524 assert!(outdated.is_empty());
1525 }
1526
1527 #[test]
1528 fn generate_registry_entry_round_trip() {
1529 let manifest = sample_manifest();
1530 let entry = generate_registry_entry(&RegistryEntryParams {
1531 manifest: &manifest,
1532 description: "A sample plugin",
1533 category: "general",
1534 author: "test-author",
1535 repository: "https://github.com/test/repo",
1536 download_url: "https://example.com/sample-1.0.0.tar",
1537 wasm_sha256: &"f".repeat(64),
1538 });
1539 assert_eq!(entry.id, "sample-plugin");
1540 assert_eq!(entry.latest, "1.0.0");
1541 assert_eq!(entry.versions.len(), 1);
1542 assert_eq!(
1543 entry.versions[0].download_url,
1544 "https://example.com/sample-1.0.0.tar"
1545 );
1546 }
1547
1548 fn build_tar_with_malicious_entry(entry_name: &str) -> Vec<u8> {
1554 let wasm_bytes =
1555 wat::parse_str(r#"(module (func (export "run") (result i32) i32.const 42))"#)
1556 .expect("wat should compile");
1557 let sha = super::sha256_hex(&wasm_bytes);
1558 let mut manifest = sample_manifest();
1559 manifest.wasm_sha256 = sha;
1560 let manifest_bytes =
1561 serde_json::to_vec_pretty(&manifest).expect("manifest should serialize");
1562
1563 let file_path = std::env::temp_dir().join(format!(
1564 "az-tar-test-{}-{}",
1565 std::process::id(),
1566 entry_name.replace(['/', '.'], "_")
1567 ));
1568 {
1569 let file = fs::File::create(&file_path).expect("create tar");
1570 let mut builder = tar::Builder::new(file);
1571
1572 let mut manifest_header = tar::Header::new_gnu();
1573 manifest_header.set_size(manifest_bytes.len() as u64);
1574 manifest_header.set_mode(0o644);
1575 manifest_header.set_cksum();
1576 builder
1577 .append_data(
1578 &mut manifest_header,
1579 "manifest.json",
1580 Cursor::new(&manifest_bytes),
1581 )
1582 .expect("add manifest");
1583
1584 let mut header = tar::Header::new_gnu();
1587 header.set_size(wasm_bytes.len() as u64);
1588 header.set_mode(0o644);
1589 header.set_entry_type(tar::EntryType::Regular);
1590 {
1592 let path_bytes = entry_name.as_bytes();
1593 let header_bytes = header.as_mut_bytes();
1594 let len = path_bytes.len().min(100);
1596 header_bytes[..len].copy_from_slice(&path_bytes[..len]);
1597 for b in &mut header_bytes[len..100] {
1599 *b = 0;
1600 }
1601 }
1602 header.set_cksum();
1603 builder
1604 .append(&header, Cursor::new(&wasm_bytes))
1605 .expect("add malicious entry");
1606
1607 builder.finish().expect("finish");
1608 }
1609 let bytes = fs::read(&file_path).expect("read tar");
1610 fs::remove_file(&file_path).ok();
1611 bytes
1612 }
1613
1614 #[test]
1615 fn install_rejects_path_traversal_with_dotdot() {
1616 let tmp = tempfile::tempdir().expect("temp dir");
1617 let package_path = tmp.path().join("evil.tar");
1618 let install_root = tmp.path().join("installed");
1619
1620 let tar_bytes = build_tar_with_malicious_entry("../../etc/passwd");
1621 fs::write(&package_path, tar_bytes).expect("write tar");
1622
1623 let err = install_packaged_plugin(&package_path, &install_root)
1624 .expect_err("path traversal should be rejected");
1625 let msg = err.to_string();
1626 assert!(
1627 msg.contains("path traversal"),
1628 "error should mention path traversal: {msg}"
1629 );
1630 }
1631
1632 #[test]
1633 fn install_rejects_absolute_path_entry() {
1634 let tmp = tempfile::tempdir().expect("temp dir");
1635 let package_path = tmp.path().join("evil-abs.tar");
1636 let install_root = tmp.path().join("installed");
1637
1638 let tar_bytes = build_tar_with_malicious_entry("/etc/passwd");
1639 fs::write(&package_path, tar_bytes).expect("write tar");
1640
1641 let err = install_packaged_plugin(&package_path, &install_root)
1642 .expect_err("absolute path should be rejected");
1643 let msg = err.to_string();
1644 assert!(
1645 msg.contains("path traversal"),
1646 "error should mention path traversal: {msg}"
1647 );
1648 }
1649
1650 #[test]
1651 fn install_rejects_symlink_entry() {
1652 let tmp = tempfile::tempdir().expect("temp dir");
1653 let package_path = tmp.path().join("evil-symlink.tar");
1654 let install_root = tmp.path().join("installed");
1655
1656 let wasm_bytes =
1657 wat::parse_str(r#"(module (func (export "run") (result i32) i32.const 42))"#)
1658 .expect("wat should compile");
1659 let sha = super::sha256_hex(&wasm_bytes);
1660 let mut manifest = sample_manifest();
1661 manifest.wasm_sha256 = sha;
1662 let manifest_bytes =
1663 serde_json::to_vec_pretty(&manifest).expect("manifest should serialize");
1664
1665 let file = fs::File::create(&package_path).expect("create tar");
1666 let mut builder = tar::Builder::new(file);
1667
1668 let mut manifest_header = tar::Header::new_gnu();
1669 manifest_header.set_size(manifest_bytes.len() as u64);
1670 manifest_header.set_mode(0o644);
1671 manifest_header.set_cksum();
1672 builder
1673 .append_data(
1674 &mut manifest_header,
1675 "manifest.json",
1676 Cursor::new(&manifest_bytes),
1677 )
1678 .expect("add manifest");
1679
1680 let mut symlink_header = tar::Header::new_gnu();
1682 symlink_header.set_entry_type(tar::EntryType::Symlink);
1683 symlink_header.set_size(0);
1684 symlink_header.set_mode(0o777);
1685 symlink_header.set_cksum();
1686 builder
1687 .append_link(&mut symlink_header, "plugin.wasm", "/etc/passwd")
1688 .expect("add symlink");
1689
1690 builder.finish().expect("finish");
1691
1692 let err = install_packaged_plugin(&package_path, &install_root)
1693 .expect_err("symlink entry should be rejected");
1694 let msg = err.to_string();
1695 assert!(
1696 msg.contains("symlink"),
1697 "error should mention symlink: {msg}"
1698 );
1699 }
1700
1701 use super::version_ge;
1704
1705 #[test]
1706 fn version_ge_semver_correct_ordering() {
1707 assert!(version_ge("10.0.0", "9.0.0"), "10.0.0 >= 9.0.0");
1709 assert!(version_ge("0.10.0", "0.2.0"), "0.10.0 >= 0.2.0");
1710 assert!(version_ge("1.0.0", "1.0.0"), "1.0.0 >= 1.0.0");
1711 assert!(!version_ge("0.2.0", "0.10.0"), "0.2.0 < 0.10.0");
1712 assert!(!version_ge("9.0.0", "10.0.0"), "9.0.0 < 10.0.0");
1713 }
1714
1715 #[test]
1716 fn version_ge_falls_back_to_string_for_non_semver() {
1717 assert!(version_ge("beta", "alpha"));
1719 assert!(!version_ge("alpha", "beta"));
1720 }
1721
1722 #[test]
1723 fn discover_plugins_picks_semver_latest_not_lexicographic() {
1724 let tmp = tempfile::tempdir().expect("temp dir");
1725 let global = tmp.path().join("global");
1726 write_test_plugin(
1728 &global.join("semver-test").join("0.2.0"),
1729 "semver-test",
1730 "0.2.0",
1731 );
1732 write_test_plugin(
1733 &global.join("semver-test").join("0.10.0"),
1734 "semver-test",
1735 "0.10.0",
1736 );
1737
1738 let found = discover_plugins(Some(&global), None, None);
1739 assert_eq!(found.len(), 1);
1740 assert_eq!(
1741 found[0].manifest.version, "0.10.0",
1742 "should pick 0.10.0 over 0.2.0 with semver comparison"
1743 );
1744 }
1745
1746 #[test]
1749 fn install_creates_lock_file() {
1750 let tmp = tempfile::tempdir().expect("temp dir");
1751 let wasm_path = tmp.path().join("plugin.wasm");
1752 let package_path = tmp.path().join("sample-plugin.tar");
1753 let install_root = tmp.path().join("installed");
1754
1755 let wasm_bytes =
1756 wat::parse_str(r#"(module (func (export "run") (result i32) i32.const 7))"#)
1757 .expect("wat should compile");
1758 fs::write(&wasm_path, wasm_bytes).expect("wasm file should be written");
1759
1760 package_plugin(&wasm_path, sample_manifest(), &package_path)
1761 .expect("packaging should succeed");
1762 install_packaged_plugin(&package_path, &install_root).expect("install should succeed");
1763
1764 let lock_path = install_root.join(super::LOCK_FILE_NAME);
1766 assert!(lock_path.exists(), "lock file should exist after install");
1767 }
1768
1769 #[test]
1770 fn remove_creates_lock_file() {
1771 let tmp = tempfile::tempdir().expect("temp dir");
1772 let wasm_path = tmp.path().join("plugin.wasm");
1773 let package_path = tmp.path().join("sample-plugin.tar");
1774 let install_root = tmp.path().join("installed");
1775
1776 let wasm_bytes =
1777 wat::parse_str(r#"(module (func (export "run") (result i32) i32.const 7))"#)
1778 .expect("wat should compile");
1779 fs::write(&wasm_path, wasm_bytes).expect("wasm file should be written");
1780
1781 package_plugin(&wasm_path, sample_manifest(), &package_path)
1782 .expect("packaging should succeed");
1783 install_packaged_plugin(&package_path, &install_root).expect("install should succeed");
1784
1785 remove_installed_plugin(&install_root, "sample-plugin", Some("1.0.0"))
1786 .expect("remove should succeed");
1787
1788 let lock_path = install_root.join(super::LOCK_FILE_NAME);
1789 assert!(lock_path.exists(), "lock file should exist after remove");
1790 }
1791}