1use flate2::read::GzDecoder;
4use std::fs;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8const MAX_UNPACKED_BYTES: u64 = 256 * 1024 * 1024;
15
16#[derive(Debug, Clone, Copy, PartialEq)]
29enum Entry {
30 Dir,
31 File,
32 Refused,
35}
36
37fn classify(path: &Path) -> Entry {
41 match fs::symlink_metadata(path).map(|m| m.file_type()).ok() {
42 Some(t) if t.is_dir() => Entry::Dir,
43 Some(t) if !t.is_symlink() => Entry::File,
44 _ => Entry::Refused,
45 }
46}
47
48fn reject_symlinks_with(dir: &Path, classify: fn(&Path) -> Entry) -> anyhow::Result<()> {
58 for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
64 let path = entry.path();
65 match classify(&path) {
66 Entry::Dir => reject_symlinks_with(&path, classify)?,
67 Entry::File => {}
68 Entry::Refused => anyhow::bail!(
69 "Package contains a symlink or unreadable entry ('{}'), which is not \
70 permitted in an agent bundle",
71 path.display()
72 ),
73 }
74 }
75 Ok(())
76}
77
78#[derive(Debug, Clone)]
80pub struct InstalledAgent {
81 pub name: String,
83 pub version: String,
85 pub path: PathBuf,
87 pub description: String,
89}
90
91pub struct AgentInstaller {
93 install_dir: PathBuf,
95}
96
97impl AgentInstaller {
98 pub fn new() -> Self {
104 let install_dir =
109 leviath_core::paths::agents_dir().expect("could not determine home directory");
110 Self { install_dir }
111 }
112
113 pub fn with_install_dir(install_dir: PathBuf) -> Self {
115 Self { install_dir }
116 }
117
118 pub fn install(&self, package_path: &Path) -> anyhow::Result<InstalledAgent> {
120 tracing::info!(path = %package_path.display(), "Installing agent from package");
121
122 let data = fs::read(package_path).map_err(|e| {
123 anyhow::anyhow!("Failed to read package '{}': {}", package_path.display(), e)
124 })?;
125
126 let name = package_path
128 .file_stem()
129 .and_then(|s| s.to_str())
130 .unwrap_or("unknown")
131 .to_string();
132
133 self.install_from_bytes(&name, &data)
134 }
135
136 pub fn install_from_bytes(&self, name: &str, data: &[u8]) -> anyhow::Result<InstalledAgent> {
145 self.install_from_bytes_with(name, data, classify)
146 }
147
148 fn install_from_bytes_with(
152 &self,
153 name: &str,
154 data: &[u8],
155 classify: fn(&Path) -> Entry,
156 ) -> anyhow::Result<InstalledAgent> {
157 tracing::info!(name = %name, "Installing agent from bytes");
158
159 if !leviath_core::is_safe_path_component(name) {
160 anyhow::bail!(
161 "invalid agent name '{name}': names may contain only letters, digits, \
162 '.', '_' and '-'"
163 );
164 }
165 let agent_dir = self.install_dir.join(name);
166
167 fs::create_dir_all(&agent_dir).map_err(|e| {
169 anyhow::anyhow!(
170 "Failed to create install directory '{}': {}",
171 agent_dir.display(),
172 e
173 )
174 })?;
175
176 let decoder = GzDecoder::new(data).take(MAX_UNPACKED_BYTES);
191 let mut archive = tar::Archive::new(decoder);
192 archive.set_preserve_permissions(false);
193 archive.set_unpack_xattrs(false);
194
195 archive.unpack(&agent_dir).map_err(|e| {
196 anyhow::anyhow!(
197 "Failed to extract package: {}. (Bundles are limited to {} MiB \
198 uncompressed.)",
199 e,
200 MAX_UNPACKED_BYTES / (1024 * 1024)
201 )
202 })?;
203 reject_symlinks_with(&agent_dir, classify)?;
204
205 let manifest_path = agent_dir.join("agent.leviath");
207 let (version, description) = if manifest_path.exists() {
208 let content = fs::read_to_string(&manifest_path).unwrap_or_default();
209 let parsed: toml::Value =
210 toml::from_str(&content).unwrap_or(toml::Value::Table(toml::map::Map::new()));
211 let version = parsed
212 .get("agent")
213 .and_then(|a| a.get("version"))
214 .and_then(|v| v.as_str())
215 .unwrap_or("0.0.0")
216 .to_string();
217 let description = parsed
218 .get("agent")
219 .and_then(|a| a.get("description"))
220 .and_then(|v| v.as_str())
221 .unwrap_or("")
222 .to_string();
223 (version, description)
224 } else {
225 ("0.0.0".to_string(), String::new())
226 };
227
228 tracing::info!(
229 name = %name,
230 version = %version,
231 path = %agent_dir.display(),
232 "Agent installed successfully"
233 );
234
235 Ok(InstalledAgent {
236 name: name.to_string(),
237 version,
238 path: agent_dir,
239 description,
240 })
241 }
242
243 pub fn uninstall(&self, agent_name: &str) -> anyhow::Result<()> {
245 let agent_dir = self.install_dir.join(agent_name);
246
247 if !agent_dir.exists() {
248 anyhow::bail!("Agent '{}' is not installed", agent_name);
249 }
250
251 fs::remove_dir_all(&agent_dir)
252 .map_err(|e| anyhow::anyhow!("Failed to remove agent '{}': {}", agent_name, e))?;
253
254 tracing::info!(name = %agent_name, "Agent uninstalled");
255 Ok(())
256 }
257
258 pub fn list_installed(&self) -> anyhow::Result<Vec<InstalledAgent>> {
260 if !self.install_dir.exists() {
261 return Ok(Vec::new());
262 }
263
264 let mut agents = Vec::new();
265
266 for entry in
267 fs::read_dir(&self.install_dir).expect("install_dir exists - read_dir should not fail")
268 {
269 let entry = entry.expect("read_dir entry should not fail");
270 let path = entry.path();
271
272 if path.is_dir() {
273 let manifest_path = path.join("agent.leviath");
274 if manifest_path.exists() {
275 let name = path
276 .file_name()
277 .and_then(|n| n.to_str())
278 .unwrap_or("unknown")
279 .to_string();
280
281 let content = fs::read_to_string(&manifest_path).unwrap_or_default();
282 let parsed: toml::Value = toml::from_str(&content)
283 .unwrap_or(toml::Value::Table(toml::map::Map::new()));
284
285 let version = parsed
286 .get("agent")
287 .and_then(|a| a.get("version"))
288 .and_then(|v| v.as_str())
289 .unwrap_or("0.0.0")
290 .to_string();
291 let description = parsed
292 .get("agent")
293 .and_then(|a| a.get("description"))
294 .and_then(|v| v.as_str())
295 .unwrap_or("")
296 .to_string();
297
298 agents.push(InstalledAgent {
299 name,
300 version,
301 path,
302 description,
303 });
304 }
305 }
306 }
307
308 Ok(agents)
309 }
310
311 pub fn get_installed(&self, name: &str) -> anyhow::Result<Option<InstalledAgent>> {
313 let agent_dir = self.install_dir.join(name);
314
315 if !agent_dir.exists() {
316 return Ok(None);
317 }
318
319 let manifest_path = agent_dir.join("agent.leviath");
320 if !manifest_path.exists() {
321 return Ok(None);
322 }
323
324 let content = fs::read_to_string(&manifest_path).unwrap_or_default();
325 let parsed: toml::Value =
326 toml::from_str(&content).unwrap_or(toml::Value::Table(toml::map::Map::new()));
327
328 let version = parsed
329 .get("agent")
330 .and_then(|a| a.get("version"))
331 .and_then(|v| v.as_str())
332 .unwrap_or("0.0.0")
333 .to_string();
334 let description = parsed
335 .get("agent")
336 .and_then(|a| a.get("description"))
337 .and_then(|v| v.as_str())
338 .unwrap_or("")
339 .to_string();
340
341 Ok(Some(InstalledAgent {
342 name: name.to_string(),
343 version,
344 path: agent_dir,
345 description,
346 }))
347 }
348}
349
350impl Default for AgentInstaller {
351 fn default() -> Self {
352 Self::new()
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use crate::test_support::with_tracing;
360 use flate2::Compression;
361 use flate2::write::GzEncoder;
362
363 fn make_bundle(name: &str, version: &str, description: &str) -> Vec<u8> {
365 let manifest = format!(
366 r#"[agent]
367name = "{}"
368version = "{}"
369description = "{}"
370"#,
371 name, version, description
372 );
373
374 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
375 {
376 let mut archive = tar::Builder::new(&mut encoder);
377 let manifest_bytes = manifest.as_bytes();
378 let mut header = tar::Header::new_gnu();
379 header.set_size(manifest_bytes.len() as u64);
380 header.set_mode(0o644);
381 header.set_cksum();
382 archive
383 .append_data(&mut header, "agent.leviath", manifest_bytes)
384 .unwrap();
385 archive.finish().unwrap();
386 }
387 encoder.finish().unwrap()
388 }
389
390 #[test]
394 fn install_from_bytes_rejects_traversing_names() {
395 let dir = tempfile::tempdir().unwrap();
396 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
397 let bundle = make_bundle("x", "1.0.0", "d");
398 for name in ["../escape", "../../tmp/escape", "/tmp/escape", "a/b", ".."] {
399 let err = installer
400 .install_from_bytes(name, &bundle)
401 .expect_err("{name} must be refused");
402 assert!(err.to_string().contains("invalid agent name"), "{err}");
403 }
404 assert!(
405 !std::path::Path::new("/tmp/escape").exists(),
406 "nothing may be created outside the install dir"
407 );
408 }
409
410 #[test]
413 fn install_from_bytes_refuses_a_decompression_bomb() {
414 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
417 {
418 let mut archive = tar::Builder::new(&mut encoder);
419 let size = 512 * 1024 * 1024u64;
420 let mut header = tar::Header::new_gnu();
421 header.set_size(size);
422 header.set_mode(0o644);
423 header.set_cksum();
424 archive
425 .append_data(&mut header, "big.bin", std::io::repeat(0).take(size))
426 .unwrap();
427 archive.finish().unwrap();
428 }
429 let bomb = encoder.finish().unwrap();
430 let compressed = bomb.len();
433 assert!(
434 compressed < 5 * 1024 * 1024,
435 "precondition: the bomb is small on disk"
436 );
437
438 let dir = tempfile::tempdir().unwrap();
439 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
440 let err = installer
441 .install_from_bytes("bomb", &bomb)
442 .expect_err("an oversized bundle must be refused");
443 assert!(err.to_string().contains("Failed to extract"), "{err}");
444 }
445
446 #[test]
449 fn install_from_bytes_accepts_a_nested_directory() {
450 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
451 {
452 let mut archive = tar::Builder::new(&mut encoder);
453 for (path, body) in [
454 (
455 "agent.leviath",
456 "[agent]\nname = \"n\"\nversion = \"1.0.0\"\n",
457 ),
458 ("tools/web_fetch.rhai", "// @tool web_fetch\n"),
459 ] {
460 let bytes = body.as_bytes();
461 let mut header = tar::Header::new_gnu();
462 header.set_size(bytes.len() as u64);
463 header.set_mode(0o644);
464 header.set_cksum();
465 archive.append_data(&mut header, path, bytes).unwrap();
466 }
467 archive.finish().unwrap();
468 }
469 let bundle = encoder.finish().unwrap();
470
471 let dir = tempfile::tempdir().unwrap();
472 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
473 let installed = installer.install_from_bytes("nested", &bundle).unwrap();
474 assert!(installed.path.join("tools/web_fetch.rhai").exists());
475 }
476
477 #[cfg(unix)]
481 #[test]
482 fn install_from_bytes_refuses_a_nested_symlink_entry() {
483 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
484 {
485 let mut archive = tar::Builder::new(&mut encoder);
486 let manifest = "[agent]\nname = \"n\"\nversion = \"1.0.0\"\n";
487 let bytes = manifest.as_bytes();
488 let mut header = tar::Header::new_gnu();
489 header.set_size(bytes.len() as u64);
490 header.set_mode(0o644);
491 header.set_cksum();
492 archive
493 .append_data(&mut header, "agent.leviath", bytes)
494 .unwrap();
495
496 let mut link = tar::Header::new_gnu();
497 link.set_size(0);
498 link.set_entry_type(tar::EntryType::Symlink);
499 link.set_mode(0o777);
500 archive
501 .append_link(&mut link, "tools/escape", "/etc/passwd")
502 .unwrap();
503 archive.finish().unwrap();
504 }
505 let bundle = encoder.finish().unwrap();
506
507 let dir = tempfile::tempdir().unwrap();
508 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
509 let err = installer
510 .install_from_bytes("nested-link", &bundle)
511 .expect_err("a nested symlink must be refused");
512 assert!(err.to_string().contains("symlink"), "{err}");
513 }
514
515 #[test]
524 fn reject_symlinks_refuses_an_entry_it_cannot_certify() {
525 fn all_refused(_: &Path) -> Entry {
526 Entry::Refused
527 }
528 let dir = tempfile::tempdir().unwrap();
529 std::fs::write(dir.path().join("thing"), b"x").unwrap();
530
531 let err = reject_symlinks_with(dir.path(), all_refused)
532 .expect_err("an entry that cannot be certified is refused");
533 assert!(err.to_string().contains("symlink or unreadable"), "{err}");
534 }
535
536 #[test]
539 fn reject_symlinks_refuses_an_entry_nested_in_a_subdirectory() {
540 fn refuse_the_leaf(path: &Path) -> Entry {
542 match path.file_name().and_then(|n| n.to_str()) {
543 Some("web_fetch.rhai") => Entry::Refused,
544 _ => classify(path),
545 }
546 }
547
548 let dir = tempfile::tempdir().unwrap();
549 let nested = dir.path().join("tools");
550 std::fs::create_dir(&nested).unwrap();
551 std::fs::write(nested.join("web_fetch.rhai"), b"x").unwrap();
552
553 let err = reject_symlinks_with(dir.path(), refuse_the_leaf)
554 .expect_err("a refused entry one level down is still refused");
555 assert!(err.to_string().contains("web_fetch.rhai"), "{err}");
556 }
557
558 #[test]
561 fn install_refuses_a_bundle_whose_entries_cannot_be_certified() {
562 fn all_refused(_: &Path) -> Entry {
563 Entry::Refused
564 }
565
566 let dir = tempfile::tempdir().unwrap();
567 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
568 let bundle = make_bundle("probe", "1.0.0", "a probe");
569
570 let err = installer
571 .install_from_bytes_with("probe", &bundle, all_refused)
572 .expect_err("an uncertifiable bundle must not install");
573 assert!(err.to_string().contains("symlink or unreadable"), "{err}");
574 }
575
576 #[test]
579 fn reject_symlinks_admits_ordinary_files_and_directories() {
580 let dir = tempfile::tempdir().unwrap();
581 let nested = dir.path().join("tools");
582 std::fs::create_dir(&nested).unwrap();
583 std::fs::write(nested.join("web_fetch.rhai"), b"x").unwrap();
584 std::fs::write(dir.path().join("agent.leviath"), b"x").unwrap();
585
586 reject_symlinks_with(dir.path(), classify).expect("an ordinary bundle passes");
587 assert_eq!(classify(&nested), Entry::Dir);
589 assert_eq!(classify(&nested.join("web_fetch.rhai")), Entry::File);
590 assert_eq!(classify(&dir.path().join("no-such-entry")), Entry::Refused);
591 }
592
593 #[cfg(unix)]
594 #[test]
595 fn install_from_bytes_refuses_a_symlink_entry() {
596 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
597 {
598 let mut archive = tar::Builder::new(&mut encoder);
599 let mut header = tar::Header::new_gnu();
600 header.set_size(0);
601 header.set_entry_type(tar::EntryType::Symlink);
602 header.set_mode(0o777);
603 archive
604 .append_link(&mut header, "escape", "/etc/passwd")
605 .unwrap();
606 archive.finish().unwrap();
607 }
608 let bundle = encoder.finish().unwrap();
609
610 let dir = tempfile::tempdir().unwrap();
611 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
612 let err = installer
613 .install_from_bytes("linky", &bundle)
614 .expect_err("a symlink entry must be refused");
615 assert!(err.to_string().contains("symlink"), "{err}");
616 }
617
618 #[test]
619 fn with_install_dir_sets_dir() {
620 let dir = PathBuf::from("/tmp/test-installer");
621 let installer = AgentInstaller::with_install_dir(dir.clone());
622 assert_eq!(installer.install_dir, dir);
623 }
624
625 #[test]
626 fn install_from_bytes_creates_directory() {
627 with_tracing(|| {
628 let dir = tempfile::tempdir().unwrap();
629 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
630
631 let bundle = make_bundle("test-agent", "1.0.0", "A test agent");
632 let result = installer.install_from_bytes("test-agent", &bundle).unwrap();
633
634 assert_eq!(result.name, "test-agent");
635 assert_eq!(result.version, "1.0.0");
636 assert_eq!(result.description, "A test agent");
637 assert!(result.path.exists());
638 assert!(result.path.join("agent.leviath").exists());
639 });
640 }
641
642 #[test]
643 fn install_from_bytes_no_manifest_defaults() {
644 let dir = tempfile::tempdir().unwrap();
645 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
646
647 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
649 {
650 let mut archive = tar::Builder::new(&mut encoder);
651 let data = b"hello";
652 let mut header = tar::Header::new_gnu();
653 header.set_size(data.len() as u64);
654 header.set_mode(0o644);
655 header.set_cksum();
656 archive
657 .append_data(&mut header, "readme.txt", &data[..])
658 .unwrap();
659 archive.finish().unwrap();
660 }
661 let bundle = encoder.finish().unwrap();
662
663 let result = installer
664 .install_from_bytes("no-manifest", &bundle)
665 .unwrap();
666 assert_eq!(result.version, "0.0.0");
667 assert_eq!(result.description, "");
668 }
669
670 #[test]
671 fn uninstall_removes_directory() {
672 with_tracing(|| {
673 let dir = tempfile::tempdir().unwrap();
674 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
675
676 let bundle = make_bundle("to-remove", "1.0.0", "remove me");
677 installer.install_from_bytes("to-remove", &bundle).unwrap();
678
679 assert!(dir.path().join("to-remove").exists());
680 installer.uninstall("to-remove").unwrap();
681 assert!(!dir.path().join("to-remove").exists());
682 });
683 }
684
685 #[test]
686 fn uninstall_nonexistent_returns_error() {
687 let dir = tempfile::tempdir().unwrap();
688 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
689
690 let err = installer.uninstall("no-such-agent").unwrap_err();
691 assert!(err.to_string().contains("not installed"));
692 }
693
694 #[test]
695 fn list_installed_empty_dir() {
696 let dir = tempfile::tempdir().unwrap();
697 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
698 let agents = installer.list_installed().unwrap();
699 assert!(agents.is_empty());
700 }
701
702 #[test]
703 fn list_installed_nonexistent_dir() {
704 let installer =
705 AgentInstaller::with_install_dir(PathBuf::from("/tmp/nonexistent-leviath-test-dir"));
706 let agents = installer.list_installed().unwrap();
707 assert!(agents.is_empty());
708 }
709
710 #[test]
711 fn list_installed_returns_installed_agents() {
712 let dir = tempfile::tempdir().unwrap();
713 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
714
715 let bundle1 = make_bundle("agent-a", "1.0.0", "Agent A");
716 let bundle2 = make_bundle("agent-b", "2.0.0", "Agent B");
717 installer.install_from_bytes("agent-a", &bundle1).unwrap();
718 installer.install_from_bytes("agent-b", &bundle2).unwrap();
719
720 let agents = installer.list_installed().unwrap();
721 assert_eq!(agents.len(), 2);
722 let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
723 assert!(names.contains(&"agent-a"));
724 assert!(names.contains(&"agent-b"));
725 }
726
727 #[test]
728 fn list_installed_skips_non_directory_entries() {
729 let dir = tempfile::tempdir().unwrap();
730 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
731
732 let bundle = make_bundle("good-agent", "1.0.0", "Good");
734 installer.install_from_bytes("good-agent", &bundle).unwrap();
735
736 fs::write(dir.path().join("not-an-agent.txt"), "hello").unwrap();
738
739 fs::create_dir_all(dir.path().join("no-manifest-dir")).unwrap();
741
742 let agents = installer.list_installed().unwrap();
743 assert_eq!(agents.len(), 1);
745 assert_eq!(agents[0].name, "good-agent");
746 }
747
748 #[test]
749 fn get_installed_found() {
750 let dir = tempfile::tempdir().unwrap();
751 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
752
753 let bundle = make_bundle("findme", "3.2.1", "Find this agent");
754 installer.install_from_bytes("findme", &bundle).unwrap();
755
756 let agent = installer.get_installed("findme").unwrap().unwrap();
757 assert_eq!(agent.name, "findme");
758 assert_eq!(agent.version, "3.2.1");
759 assert_eq!(agent.description, "Find this agent");
760 }
761
762 #[test]
763 fn get_installed_not_found() {
764 let dir = tempfile::tempdir().unwrap();
765 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
766 assert!(installer.get_installed("nope").unwrap().is_none());
767 }
768
769 #[test]
770 fn get_installed_dir_exists_but_no_manifest() {
771 let dir = tempfile::tempdir().unwrap();
772 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
773
774 fs::create_dir_all(dir.path().join("empty-agent")).unwrap();
776 assert!(installer.get_installed("empty-agent").unwrap().is_none());
777 }
778
779 #[test]
782 fn new_derives_install_dir_from_home() {
783 let installer = AgentInstaller::new();
784 assert!(installer.install_dir.ends_with(".leviath/agents"));
785 }
786
787 #[test]
788 fn default_matches_new() {
789 let installer = AgentInstaller::default();
790 assert!(installer.install_dir.ends_with(".leviath/agents"));
791 }
792
793 #[test]
796 fn install_from_file_path_derives_name_from_filename() {
797 with_tracing(|| {
798 let dir = tempfile::tempdir().unwrap();
799 let installer = AgentInstaller::with_install_dir(dir.path().join("agents"));
800
801 let bundle = make_bundle("file-agent", "1.2.3", "Installed from a file");
802 let package_path = dir.path().join("file-agent.leviath-bundle");
803 fs::write(&package_path, &bundle).unwrap();
804
805 let result = installer.install(&package_path).unwrap();
806 assert_eq!(result.name, "file-agent");
807 assert_eq!(result.version, "1.2.3");
808 assert_eq!(result.description, "Installed from a file");
809 assert!(result.path.exists());
810 });
811 }
812
813 #[test]
814 fn install_from_file_path_missing_file_returns_error() {
815 let dir = tempfile::tempdir().unwrap();
816 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
817
818 let err = installer
819 .install(&dir.path().join("does-not-exist.leviath-bundle"))
820 .unwrap_err();
821 assert!(err.to_string().contains("Failed to read package"));
822 }
823
824 #[test]
827 fn install_from_bytes_create_dir_failure_returns_error() {
828 let dir = tempfile::tempdir().unwrap();
829 let blocker = dir.path().join("blocker");
832 fs::write(&blocker, b"not a directory").unwrap();
833
834 let installer = AgentInstaller::with_install_dir(blocker.join("agents"));
835 let bundle = make_bundle("blocked", "1.0.0", "desc");
836 let err = installer
837 .install_from_bytes("blocked", &bundle)
838 .unwrap_err();
839 assert!(
840 err.to_string()
841 .contains("Failed to create install directory")
842 );
843 }
844
845 #[test]
846 fn install_from_bytes_corrupt_tar_after_valid_gzip_returns_extract_error() {
847 let dir = tempfile::tempdir().unwrap();
853 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
854
855 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
856 use std::io::Write;
857 encoder
858 .write_all(&[b'x'; 600]) .unwrap();
860 let bundle = encoder.finish().unwrap();
861
862 let err = installer
863 .install_from_bytes("corrupt-tar", &bundle)
864 .unwrap_err();
865 assert!(err.to_string().contains("Failed to extract package"));
866 }
867
868 #[test]
869 fn uninstall_remove_dir_all_failure_returns_error() {
870 let dir = tempfile::tempdir().unwrap();
875 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
876 let agent_path = dir.path().join("not-a-dir");
877 fs::write(&agent_path, b"i am a file, not a directory").unwrap();
878
879 let result = installer.uninstall("not-a-dir");
880
881 assert!(result.is_err());
882 assert!(
883 result
884 .unwrap_err()
885 .to_string()
886 .contains("Failed to remove agent")
887 );
888 }
889}