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)]
22enum Entry {
23 Dir,
24 File,
25 Refused,
28}
29
30fn classify(path: &Path) -> Entry {
34 match fs::symlink_metadata(path).map(|m| m.file_type()).ok() {
35 Some(t) if t.is_dir() => Entry::Dir,
36 Some(t) if !t.is_symlink() => Entry::File,
37 _ => Entry::Refused,
38 }
39}
40
41fn reject_symlinks_with(dir: &Path, classify: fn(&Path) -> Entry) -> anyhow::Result<()> {
57 for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
63 let path = entry.path();
64 match classify(&path) {
65 Entry::Dir => reject_symlinks_with(&path, classify)?,
66 Entry::File => {}
67 Entry::Refused => anyhow::bail!(
68 "Package contains a symlink or unreadable entry ('{}'), which is not \
69 permitted in an agent bundle",
70 path.display()
71 ),
72 }
73 }
74 Ok(())
75}
76
77#[derive(Debug, Clone)]
79pub struct InstalledAgent {
80 pub name: String,
82 pub version: String,
84 pub path: PathBuf,
86 pub description: String,
88}
89
90pub struct AgentInstaller {
92 install_dir: PathBuf,
94}
95
96impl AgentInstaller {
97 pub fn new() -> Self {
103 let install_dir =
108 leviath_core::paths::agents_dir().expect("could not determine home directory");
109 Self { install_dir }
110 }
111
112 pub fn with_install_dir(install_dir: PathBuf) -> Self {
114 Self { install_dir }
115 }
116
117 pub fn install(&self, package_path: &Path) -> anyhow::Result<InstalledAgent> {
119 tracing::info!(path = %package_path.display(), "Installing agent from package");
120
121 let data = fs::read(package_path).map_err(|e| {
122 anyhow::anyhow!("Failed to read package '{}': {}", package_path.display(), e)
123 })?;
124
125 let name = package_path
127 .file_stem()
128 .and_then(|s| s.to_str())
129 .unwrap_or("unknown")
130 .to_string();
131
132 self.install_from_bytes(&name, &data)
133 }
134
135 pub fn install_from_bytes(&self, name: &str, data: &[u8]) -> anyhow::Result<InstalledAgent> {
144 self.install_from_bytes_with(name, data, classify)
145 }
146
147 fn unpack_into(dest: &Path, data: &[u8], classify: fn(&Path) -> Entry) -> anyhow::Result<()> {
162 let decoder = GzDecoder::new(data).take(MAX_UNPACKED_BYTES);
163 let mut archive = tar::Archive::new(decoder);
164 archive.set_preserve_permissions(false);
165 archive.set_unpack_xattrs(false);
166 archive.unpack(dest).map_err(|e| {
167 anyhow::anyhow!(
168 "Failed to extract package: {}. (Bundles are limited to {} MiB \
169 uncompressed.)",
170 e,
171 MAX_UNPACKED_BYTES / (1024 * 1024)
172 )
173 })?;
174 reject_symlinks_with(dest, classify)
175 }
176
177 fn swap_into_place(staging: &Path, dest: &Path) -> anyhow::Result<()> {
184 let swap = || -> std::io::Result<()> {
187 if dest.exists() {
188 fs::remove_dir_all(dest)?;
189 }
190 fs::rename(staging, dest)
191 };
192 swap().map_err(|e| {
193 anyhow::anyhow!(
194 "Failed to install into '{}': {}. Any previous install there has been removed - \
195 reinstall the agent.",
196 dest.display(),
197 e
198 )
199 })
200 }
201
202 fn install_from_bytes_with(
206 &self,
207 name: &str,
208 data: &[u8],
209 classify: fn(&Path) -> Entry,
210 ) -> anyhow::Result<InstalledAgent> {
211 tracing::info!(name = %name, "Installing agent from bytes");
212
213 if !leviath_core::is_safe_path_component(name) {
214 anyhow::bail!(
215 "invalid agent name '{name}': names may contain only letters, digits, \
216 '.', '_' and '-'"
217 );
218 }
219 let agent_dir = self.install_dir.join(name);
220
221 let mut staging = self.install_dir.clone().into_os_string();
250 staging.push(format!(".staging-{name}-{}", std::process::id()));
251 let staging = PathBuf::from(staging);
252 let prepare = || -> std::io::Result<()> {
259 fs::create_dir_all(&self.install_dir)?;
260 let _ = fs::remove_dir_all(&staging);
263 fs::create_dir_all(&staging)
264 };
265 prepare().map_err(|e| {
266 anyhow::anyhow!(
267 "Failed to create install directory '{}': {}",
268 self.install_dir.display(),
269 e
270 )
271 })?;
272 let staged = Self::unpack_into(&staging, data, classify);
275 let result = staged.and_then(|()| Self::swap_into_place(&staging, &agent_dir));
276 if let Err(e) = result {
277 let _ = fs::remove_dir_all(&staging);
278 return Err(e);
279 }
280
281 let manifest_path = agent_dir.join("agent.leviath");
283 let (version, description) = if manifest_path.exists() {
284 let content = fs::read_to_string(&manifest_path).unwrap_or_default();
285 let parsed: toml::Value =
286 toml::from_str(&content).unwrap_or(toml::Value::Table(toml::map::Map::new()));
287 let version = parsed
288 .get("agent")
289 .and_then(|a| a.get("version"))
290 .and_then(|v| v.as_str())
291 .unwrap_or("0.0.0")
292 .to_string();
293 let description = parsed
294 .get("agent")
295 .and_then(|a| a.get("description"))
296 .and_then(|v| v.as_str())
297 .unwrap_or("")
298 .to_string();
299 (version, description)
300 } else {
301 ("0.0.0".to_string(), String::new())
302 };
303
304 tracing::info!(
305 name = %name,
306 version = %version,
307 path = %agent_dir.display(),
308 "Agent installed successfully"
309 );
310
311 Ok(InstalledAgent {
312 name: name.to_string(),
313 version,
314 path: agent_dir,
315 description,
316 })
317 }
318
319 pub fn uninstall(&self, agent_name: &str) -> anyhow::Result<()> {
321 let agent_dir = self.install_dir.join(agent_name);
322
323 if !agent_dir.exists() {
324 anyhow::bail!("Agent '{}' is not installed", agent_name);
325 }
326
327 fs::remove_dir_all(&agent_dir)
328 .map_err(|e| anyhow::anyhow!("Failed to remove agent '{}': {}", agent_name, e))?;
329
330 tracing::info!(name = %agent_name, "Agent uninstalled");
331 Ok(())
332 }
333
334 pub fn list_installed(&self) -> anyhow::Result<Vec<InstalledAgent>> {
336 if !self.install_dir.exists() {
337 return Ok(Vec::new());
338 }
339
340 let mut agents = Vec::new();
341
342 for entry in
343 fs::read_dir(&self.install_dir).expect("install_dir exists - read_dir should not fail")
344 {
345 let entry = entry.expect("read_dir entry should not fail");
346 let path = entry.path();
347
348 if path.is_dir() {
349 let manifest_path = path.join("agent.leviath");
350 if manifest_path.exists() {
351 let name = path
352 .file_name()
353 .and_then(|n| n.to_str())
354 .unwrap_or("unknown")
355 .to_string();
356
357 let content = fs::read_to_string(&manifest_path).unwrap_or_default();
358 let parsed: toml::Value = toml::from_str(&content)
359 .unwrap_or(toml::Value::Table(toml::map::Map::new()));
360
361 let version = parsed
362 .get("agent")
363 .and_then(|a| a.get("version"))
364 .and_then(|v| v.as_str())
365 .unwrap_or("0.0.0")
366 .to_string();
367 let description = parsed
368 .get("agent")
369 .and_then(|a| a.get("description"))
370 .and_then(|v| v.as_str())
371 .unwrap_or("")
372 .to_string();
373
374 agents.push(InstalledAgent {
375 name,
376 version,
377 path,
378 description,
379 });
380 }
381 }
382 }
383
384 Ok(agents)
385 }
386
387 pub fn get_installed(&self, name: &str) -> Option<InstalledAgent> {
402 let agent_dir = self.install_dir.join(name);
403
404 if !agent_dir.exists() {
405 return None;
406 }
407
408 let manifest_path = agent_dir.join("agent.leviath");
409 if !manifest_path.exists() {
410 return None;
411 }
412
413 let content = fs::read_to_string(&manifest_path).unwrap_or_default();
414 let parsed: toml::Value =
415 toml::from_str(&content).unwrap_or(toml::Value::Table(toml::map::Map::new()));
416
417 let version = parsed
418 .get("agent")
419 .and_then(|a| a.get("version"))
420 .and_then(|v| v.as_str())
421 .unwrap_or("0.0.0")
422 .to_string();
423 let description = parsed
424 .get("agent")
425 .and_then(|a| a.get("description"))
426 .and_then(|v| v.as_str())
427 .unwrap_or("")
428 .to_string();
429
430 Some(InstalledAgent {
431 name: name.to_string(),
432 version,
433 path: agent_dir,
434 description,
435 })
436 }
437}
438
439impl Default for AgentInstaller {
440 fn default() -> Self {
441 Self::new()
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use crate::test_support::with_tracing;
449 use flate2::Compression;
450 use flate2::write::GzEncoder;
451
452 fn make_bundle(name: &str, version: &str, description: &str) -> Vec<u8> {
454 let manifest = format!(
455 r#"[agent]
456name = "{}"
457version = "{}"
458description = "{}"
459"#,
460 name, version, description
461 );
462
463 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
464 {
465 let mut archive = tar::Builder::new(&mut encoder);
466 let manifest_bytes = manifest.as_bytes();
467 let mut header = tar::Header::new_gnu();
468 header.set_size(manifest_bytes.len() as u64);
469 header.set_mode(0o644);
470 header.set_cksum();
471 archive
472 .append_data(&mut header, "agent.leviath", manifest_bytes)
473 .unwrap();
474 archive.finish().unwrap();
475 }
476 encoder.finish().unwrap()
477 }
478
479 #[test]
483 fn install_from_bytes_rejects_traversing_names() {
484 let dir = tempfile::tempdir().unwrap();
485 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
486 let bundle = make_bundle("x", "1.0.0", "d");
487 for name in ["../escape", "../../tmp/escape", "/tmp/escape", "a/b", ".."] {
488 let err = installer
489 .install_from_bytes(name, &bundle)
490 .expect_err("{name} must be refused");
491 assert!(err.to_string().contains("invalid agent name"), "{err}");
492 }
493 assert!(
494 !std::path::Path::new("/tmp/escape").exists(),
495 "nothing may be created outside the install dir"
496 );
497 }
498
499 #[test]
502 fn install_from_bytes_refuses_a_decompression_bomb() {
503 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
506 {
507 let mut archive = tar::Builder::new(&mut encoder);
508 let size = 512 * 1024 * 1024u64;
509 let mut header = tar::Header::new_gnu();
510 header.set_size(size);
511 header.set_mode(0o644);
512 header.set_cksum();
513 archive
514 .append_data(&mut header, "big.bin", std::io::repeat(0).take(size))
515 .unwrap();
516 archive.finish().unwrap();
517 }
518 let bomb = encoder.finish().unwrap();
519 let compressed = bomb.len();
522 assert!(
523 compressed < 5 * 1024 * 1024,
524 "precondition: the bomb is small on disk"
525 );
526
527 let dir = tempfile::tempdir().unwrap();
528 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
529 let err = installer
530 .install_from_bytes("bomb", &bomb)
531 .expect_err("an oversized bundle must be refused");
532 assert!(err.to_string().contains("Failed to extract"), "{err}");
533 }
534
535 #[test]
538 fn install_from_bytes_accepts_a_nested_directory() {
539 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
540 {
541 let mut archive = tar::Builder::new(&mut encoder);
542 for (path, body) in [
543 (
544 "agent.leviath",
545 "[agent]\nname = \"n\"\nversion = \"1.0.0\"\n",
546 ),
547 ("tools/web_fetch.rhai", "// @tool web_fetch\n"),
548 ] {
549 let bytes = body.as_bytes();
550 let mut header = tar::Header::new_gnu();
551 header.set_size(bytes.len() as u64);
552 header.set_mode(0o644);
553 header.set_cksum();
554 archive.append_data(&mut header, path, bytes).unwrap();
555 }
556 archive.finish().unwrap();
557 }
558 let bundle = encoder.finish().unwrap();
559
560 let dir = tempfile::tempdir().unwrap();
561 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
562 let installed = installer.install_from_bytes("nested", &bundle).unwrap();
563 assert!(installed.path.join("tools/web_fetch.rhai").exists());
564 }
565
566 #[cfg(unix)]
570 #[test]
571 fn install_from_bytes_refuses_a_nested_symlink_entry() {
572 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
573 {
574 let mut archive = tar::Builder::new(&mut encoder);
575 let manifest = "[agent]\nname = \"n\"\nversion = \"1.0.0\"\n";
576 let bytes = manifest.as_bytes();
577 let mut header = tar::Header::new_gnu();
578 header.set_size(bytes.len() as u64);
579 header.set_mode(0o644);
580 header.set_cksum();
581 archive
582 .append_data(&mut header, "agent.leviath", bytes)
583 .unwrap();
584
585 let mut link = tar::Header::new_gnu();
586 link.set_size(0);
587 link.set_entry_type(tar::EntryType::Symlink);
588 link.set_mode(0o777);
589 archive
590 .append_link(&mut link, "tools/escape", "/etc/passwd")
591 .unwrap();
592 archive.finish().unwrap();
593 }
594 let bundle = encoder.finish().unwrap();
595
596 let dir = tempfile::tempdir().unwrap();
597 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
598 let err = installer
599 .install_from_bytes("nested-link", &bundle)
600 .expect_err("a nested symlink must be refused");
601 assert!(err.to_string().contains("symlink"), "{err}");
602 }
603
604 #[test]
613 fn reject_symlinks_refuses_an_entry_it_cannot_certify() {
614 fn all_refused(_: &Path) -> Entry {
615 Entry::Refused
616 }
617 let dir = tempfile::tempdir().unwrap();
618 std::fs::write(dir.path().join("thing"), b"x").unwrap();
619
620 let err = reject_symlinks_with(dir.path(), all_refused)
621 .expect_err("an entry that cannot be certified is refused");
622 assert!(err.to_string().contains("symlink or unreadable"), "{err}");
623 }
624
625 #[test]
628 fn reject_symlinks_refuses_an_entry_nested_in_a_subdirectory() {
629 fn refuse_the_leaf(path: &Path) -> Entry {
631 match path.file_name().and_then(|n| n.to_str()) {
632 Some("web_fetch.rhai") => Entry::Refused,
633 _ => classify(path),
634 }
635 }
636
637 let dir = tempfile::tempdir().unwrap();
638 let nested = dir.path().join("tools");
639 std::fs::create_dir(&nested).unwrap();
640 std::fs::write(nested.join("web_fetch.rhai"), b"x").unwrap();
641
642 let err = reject_symlinks_with(dir.path(), refuse_the_leaf)
643 .expect_err("a refused entry one level down is still refused");
644 assert!(err.to_string().contains("web_fetch.rhai"), "{err}");
645 }
646
647 #[test]
650 fn install_refuses_a_bundle_whose_entries_cannot_be_certified() {
651 fn all_refused(_: &Path) -> Entry {
652 Entry::Refused
653 }
654
655 let dir = tempfile::tempdir().unwrap();
656 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
657 let bundle = make_bundle("probe", "1.0.0", "a probe");
658
659 let err = installer
660 .install_from_bytes_with("probe", &bundle, all_refused)
661 .expect_err("an uncertifiable bundle must not install");
662 assert!(err.to_string().contains("symlink or unreadable"), "{err}");
663 }
664
665 #[test]
668 fn reject_symlinks_admits_ordinary_files_and_directories() {
669 let dir = tempfile::tempdir().unwrap();
670 let nested = dir.path().join("tools");
671 std::fs::create_dir(&nested).unwrap();
672 std::fs::write(nested.join("web_fetch.rhai"), b"x").unwrap();
673 std::fs::write(dir.path().join("agent.leviath"), b"x").unwrap();
674
675 reject_symlinks_with(dir.path(), classify).expect("an ordinary bundle passes");
676 assert_eq!(classify(&nested), Entry::Dir);
678 assert_eq!(classify(&nested.join("web_fetch.rhai")), Entry::File);
679 assert_eq!(classify(&dir.path().join("no-such-entry")), Entry::Refused);
680 }
681
682 #[cfg(unix)]
683 #[test]
684 fn install_from_bytes_refuses_a_symlink_entry() {
685 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
686 {
687 let mut archive = tar::Builder::new(&mut encoder);
688 let mut header = tar::Header::new_gnu();
689 header.set_size(0);
690 header.set_entry_type(tar::EntryType::Symlink);
691 header.set_mode(0o777);
692 archive
693 .append_link(&mut header, "escape", "/etc/passwd")
694 .unwrap();
695 archive.finish().unwrap();
696 }
697 let bundle = encoder.finish().unwrap();
698
699 let dir = tempfile::tempdir().unwrap();
700 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
701 let err = installer
702 .install_from_bytes("linky", &bundle)
703 .expect_err("a symlink entry must be refused");
704 assert!(err.to_string().contains("symlink"), "{err}");
705 }
706
707 #[test]
708 fn with_install_dir_sets_dir() {
709 let dir = PathBuf::from("/tmp/test-installer");
710 let installer = AgentInstaller::with_install_dir(dir.clone());
711 assert_eq!(installer.install_dir, dir);
712 }
713
714 #[test]
715 fn install_from_bytes_creates_directory() {
716 with_tracing(|| {
717 let dir = tempfile::tempdir().unwrap();
718 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
719
720 let bundle = make_bundle("test-agent", "1.0.0", "A test agent");
721 let result = installer.install_from_bytes("test-agent", &bundle).unwrap();
722
723 assert_eq!(result.name, "test-agent");
724 assert_eq!(result.version, "1.0.0");
725 assert_eq!(result.description, "A test agent");
726 assert!(result.path.exists());
727 assert!(result.path.join("agent.leviath").exists());
728 });
729 }
730
731 #[test]
732 fn install_from_bytes_no_manifest_defaults() {
733 let dir = tempfile::tempdir().unwrap();
734 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
735
736 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
738 {
739 let mut archive = tar::Builder::new(&mut encoder);
740 let data = b"hello";
741 let mut header = tar::Header::new_gnu();
742 header.set_size(data.len() as u64);
743 header.set_mode(0o644);
744 header.set_cksum();
745 archive
746 .append_data(&mut header, "readme.txt", &data[..])
747 .unwrap();
748 archive.finish().unwrap();
749 }
750 let bundle = encoder.finish().unwrap();
751
752 let result = installer
753 .install_from_bytes("no-manifest", &bundle)
754 .unwrap();
755 assert_eq!(result.version, "0.0.0");
756 assert_eq!(result.description, "");
757 }
758
759 #[test]
760 fn uninstall_removes_directory() {
761 with_tracing(|| {
762 let dir = tempfile::tempdir().unwrap();
763 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
764
765 let bundle = make_bundle("to-remove", "1.0.0", "remove me");
766 installer.install_from_bytes("to-remove", &bundle).unwrap();
767
768 assert!(dir.path().join("to-remove").exists());
769 installer.uninstall("to-remove").unwrap();
770 assert!(!dir.path().join("to-remove").exists());
771 });
772 }
773
774 #[test]
775 fn uninstall_nonexistent_returns_error() {
776 let dir = tempfile::tempdir().unwrap();
777 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
778
779 let err = installer.uninstall("no-such-agent").unwrap_err();
780 assert!(err.to_string().contains("not installed"));
781 }
782
783 #[test]
784 fn list_installed_empty_dir() {
785 let dir = tempfile::tempdir().unwrap();
786 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
787 let agents = installer.list_installed().unwrap();
788 assert!(agents.is_empty());
789 }
790
791 #[test]
792 fn list_installed_nonexistent_dir() {
793 let installer =
794 AgentInstaller::with_install_dir(PathBuf::from("/tmp/nonexistent-leviath-test-dir"));
795 let agents = installer.list_installed().unwrap();
796 assert!(agents.is_empty());
797 }
798
799 #[test]
800 fn list_installed_returns_installed_agents() {
801 let dir = tempfile::tempdir().unwrap();
802 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
803
804 let bundle1 = make_bundle("agent-a", "1.0.0", "Agent A");
805 let bundle2 = make_bundle("agent-b", "2.0.0", "Agent B");
806 installer.install_from_bytes("agent-a", &bundle1).unwrap();
807 installer.install_from_bytes("agent-b", &bundle2).unwrap();
808
809 let agents = installer.list_installed().unwrap();
810 assert_eq!(agents.len(), 2);
811 let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
812 assert!(names.contains(&"agent-a"));
813 assert!(names.contains(&"agent-b"));
814 }
815
816 #[test]
817 fn list_installed_skips_non_directory_entries() {
818 let dir = tempfile::tempdir().unwrap();
819 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
820
821 let bundle = make_bundle("good-agent", "1.0.0", "Good");
823 installer.install_from_bytes("good-agent", &bundle).unwrap();
824
825 fs::write(dir.path().join("not-an-agent.txt"), "hello").unwrap();
827
828 fs::create_dir_all(dir.path().join("no-manifest-dir")).unwrap();
830
831 let agents = installer.list_installed().unwrap();
832 assert_eq!(agents.len(), 1);
834 assert_eq!(agents[0].name, "good-agent");
835 }
836
837 #[test]
838 fn get_installed_found() {
839 let dir = tempfile::tempdir().unwrap();
840 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
841
842 let bundle = make_bundle("findme", "3.2.1", "Find this agent");
843 installer.install_from_bytes("findme", &bundle).unwrap();
844
845 let agent = installer.get_installed("findme").unwrap();
846 assert_eq!(agent.name, "findme");
847 assert_eq!(agent.version, "3.2.1");
848 assert_eq!(agent.description, "Find this agent");
849 }
850
851 #[test]
852 fn get_installed_not_found() {
853 let dir = tempfile::tempdir().unwrap();
854 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
855 assert!(installer.get_installed("nope").is_none());
856 }
857
858 #[test]
859 fn get_installed_dir_exists_but_no_manifest() {
860 let dir = tempfile::tempdir().unwrap();
861 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
862
863 fs::create_dir_all(dir.path().join("empty-agent")).unwrap();
865 assert!(installer.get_installed("empty-agent").is_none());
866 }
867
868 #[test]
871 fn new_derives_install_dir_from_home() {
872 let installer = AgentInstaller::new();
873 assert!(installer.install_dir.ends_with(".leviath/agents"));
874 }
875
876 #[test]
877 fn default_matches_new() {
878 let installer = AgentInstaller::default();
879 assert!(installer.install_dir.ends_with(".leviath/agents"));
880 }
881
882 #[test]
885 fn install_from_file_path_derives_name_from_filename() {
886 with_tracing(|| {
887 let dir = tempfile::tempdir().unwrap();
888 let installer = AgentInstaller::with_install_dir(dir.path().join("agents"));
889
890 let bundle = make_bundle("file-agent", "1.2.3", "Installed from a file");
891 let package_path = dir.path().join("file-agent.leviath-bundle");
892 fs::write(&package_path, &bundle).unwrap();
893
894 let result = installer.install(&package_path).unwrap();
895 assert_eq!(result.name, "file-agent");
896 assert_eq!(result.version, "1.2.3");
897 assert_eq!(result.description, "Installed from a file");
898 assert!(result.path.exists());
899 });
900 }
901
902 #[test]
903 fn install_from_file_path_missing_file_returns_error() {
904 let dir = tempfile::tempdir().unwrap();
905 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
906
907 let err = installer
908 .install(&dir.path().join("does-not-exist.leviath-bundle"))
909 .unwrap_err();
910 assert!(err.to_string().contains("Failed to read package"));
911 }
912
913 #[test]
916 fn install_from_bytes_create_dir_failure_returns_error() {
917 let dir = tempfile::tempdir().unwrap();
918 let blocker = dir.path().join("blocker");
921 fs::write(&blocker, b"not a directory").unwrap();
922
923 let installer = AgentInstaller::with_install_dir(blocker.join("agents"));
924 let bundle = make_bundle("blocked", "1.0.0", "desc");
925 let err = installer
926 .install_from_bytes("blocked", &bundle)
927 .unwrap_err();
928 assert!(
929 err.to_string()
930 .contains("Failed to create install directory"),
931 "got: {err}"
932 );
933 }
934
935 #[test]
940 fn a_rejected_bundle_leaves_nothing_behind() {
941 let dir = tempfile::tempdir().unwrap();
942 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
943 let bundle = make_bundle("evil", "1.0.0", "desc");
944
945 installer
946 .install_from_bytes_with("evil", &bundle, |_| Entry::Refused)
947 .expect_err("a bundle full of symlinks must be refused");
948
949 let leftovers = fs::read_dir(dir.path()).unwrap().count();
952 assert_eq!(
953 leftovers, 0,
954 "a refused install left {leftovers} entries behind"
955 );
956 }
957
958 #[test]
962 fn a_blocked_destination_reports_a_failed_install() {
963 let dir = tempfile::tempdir().unwrap();
964 fs::write(dir.path().join("blocked"), b"a file, not a directory").unwrap();
965
966 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
967 let err = installer
968 .install_from_bytes("blocked", &make_bundle("blocked", "1.0.0", "desc"))
969 .expect_err("a file in the way must not be silently replaced");
970 assert!(err.to_string().contains("Failed to install into"), "{err}");
971
972 let leftovers: Vec<_> = fs::read_dir(dir.path())
974 .unwrap()
975 .filter_map(Result::ok)
976 .map(|e| e.file_name().to_string_lossy().into_owned())
977 .filter(|n| n.contains(".staging-"))
978 .collect();
979 assert!(leftovers.is_empty(), "left {leftovers:?} behind");
980 }
981
982 #[test]
991 fn staging_never_lands_inside_the_scanned_agents_directory() {
992 let home = tempfile::tempdir().unwrap();
993 let agents = home.path().join("agents");
994 let installer = AgentInstaller::with_install_dir(agents.clone());
995
996 installer
997 .install_from_bytes("coder", &make_bundle("coder", "1.0.0", "real"))
998 .expect("install succeeds");
999
1000 let mut entries: Vec<String> = fs::read_dir(&agents)
1003 .unwrap()
1004 .filter_map(Result::ok)
1005 .map(|e| e.file_name().to_string_lossy().into_owned())
1006 .collect();
1007 entries.sort();
1008 assert_eq!(entries, ["coder"]);
1009
1010 installer
1013 .install_from_bytes_with("coder", &make_bundle("coder", "2.0.0", "evil"), |_| {
1014 Entry::Refused
1015 })
1016 .expect_err("a symlink bundle is refused");
1017 let stranded = fs::read_dir(home.path())
1018 .unwrap()
1019 .filter_map(Result::ok)
1020 .filter(|e| e.file_name().to_string_lossy().contains(".staging-"))
1021 .count();
1022 assert_eq!(stranded, 0, "a refused install stranded a staging tree");
1023 }
1024
1025 #[test]
1029 fn a_failed_reinstall_keeps_the_previous_install() {
1030 let dir = tempfile::tempdir().unwrap();
1031 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
1032
1033 installer
1034 .install_from_bytes("keeper", &make_bundle("keeper", "1.0.0", "original"))
1035 .expect("the first install succeeds");
1036
1037 installer
1038 .install_from_bytes_with("keeper", &make_bundle("keeper", "2.0.0", "evil"), |_| {
1039 Entry::Refused
1040 })
1041 .expect_err("the second install is refused");
1042
1043 let manifest = fs::read_to_string(dir.path().join("keeper").join("agent.leviath"))
1044 .expect("the original install is still readable");
1045 assert!(
1046 manifest.contains("1.0.0"),
1047 "the working install was replaced by a refused one: {manifest}"
1048 );
1049 }
1050
1051 #[test]
1052 fn install_from_bytes_corrupt_tar_after_valid_gzip_returns_extract_error() {
1053 let dir = tempfile::tempdir().unwrap();
1059 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
1060
1061 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
1062 use std::io::Write;
1063 encoder
1064 .write_all(&[b'x'; 600]) .unwrap();
1066 let bundle = encoder.finish().unwrap();
1067
1068 let err = installer
1069 .install_from_bytes("corrupt-tar", &bundle)
1070 .unwrap_err();
1071 assert!(err.to_string().contains("Failed to extract package"));
1072 }
1073
1074 #[test]
1075 fn uninstall_remove_dir_all_failure_returns_error() {
1076 let dir = tempfile::tempdir().unwrap();
1081 let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
1082 let agent_path = dir.path().join("not-a-dir");
1083 fs::write(&agent_path, b"i am a file, not a directory").unwrap();
1084
1085 let result = installer.uninstall("not-a-dir");
1086
1087 assert!(result.is_err());
1088 assert!(
1089 result
1090 .unwrap_err()
1091 .to_string()
1092 .contains("Failed to remove agent")
1093 );
1094 }
1095}