Skip to main content

leviath_package/
installer.rs

1//! Agent installation from bundle archives.
2
3use flate2::read::GzDecoder;
4use std::fs;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8/// Largest decompressed size a bundle may unpack to.
9///
10/// Bounds a decompression bomb: gzip reaches ratios well past 1000:1, so a
11/// bundle small enough to look unremarkable could fill the disk. 256 MiB is far
12/// above any real agent bundle (they are manifests, prompts, and a few `.rhai`
13/// files) and far below "fills the disk".
14const MAX_UNPACKED_BYTES: u64 = 256 * 1024 * 1024;
15
16/// Refuse an unpacked bundle that contains symlinks.
17///
18/// tar-rs blocks entries that *extract* outside the destination, but a symlink
19/// entry lands inside it perfectly legally - and then points wherever it likes.
20/// Since the installed tree is later scanned for `.rhai` tool scripts and read
21/// by the file tools, a link is a way to smuggle content in (or to have a later
22/// write follow it out). Nothing in a legitimate agent bundle needs one.
23/// What an entry is, as far as this check cares.
24///
25/// A three-way answer rather than a `FileType`, because `FileType` cannot be
26/// constructed without a real file of that kind - and a real symlink is exactly
27/// what a test cannot create on Windows without a privilege CI runners lack.
28#[derive(Debug, Clone, Copy, PartialEq)]
29enum Entry {
30    Dir,
31    File,
32    /// A symlink, or an entry that could not be stat'd at all - the same
33    /// refusal either way, since neither can be certified.
34    Refused,
35}
36
37/// Classify a directory entry by its own metadata, not its target's.
38///
39/// `symlink_metadata` does not follow the link, which is the point.
40fn 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
48/// Refuse a bundle containing any symlink, at any depth, with the entry
49/// classifier injected.
50///
51/// A `fn` pointer (not `impl Fn`) so there is one monomorphization, matching the
52/// seam idiom used elsewhere in the workspace. The seam exists because the
53/// refusal cannot be reached otherwise on every platform: it needs a real
54/// symlink on disk, and creating one on Windows requires a privilege CI runners
55/// do not have. The `#[cfg(unix)]` tests still prove the real behaviour end to
56/// end through a genuine symlink in a genuine archive.
57fn reject_symlinks_with(dir: &Path, classify: fn(&Path) -> Entry) -> anyhow::Result<()> {
58    // `into_iter().flatten().flatten()` rather than a `match` on `read_dir`: this
59    // directory was created and unpacked into moments ago, so an unreadable one
60    // has no reachable test - and it surfaces anyway on the manifest read that
61    // follows. Collapsing it to "no entries" keeps the semantics with no branch
62    // nothing can exercise.
63    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/// Information about an installed agent.
79#[derive(Debug, Clone)]
80pub struct InstalledAgent {
81    /// Agent name
82    pub name: String,
83    /// Agent version
84    pub version: String,
85    /// Installation path
86    pub path: PathBuf,
87    /// Agent description
88    pub description: String,
89}
90
91/// Installs agents from `.leviath-bundle` packages.
92pub struct AgentInstaller {
93    /// Installation directory (default ~/.leviath/agents/)
94    install_dir: PathBuf,
95}
96
97impl AgentInstaller {
98    /// Create a new installer using the default installation directory.
99    ///
100    /// The install root comes from the shared `LEVIATH_HOME`-aware resolver
101    /// in [`leviath_core::paths`], so this crate installs into exactly the
102    /// tree every other component reads.
103    pub fn new() -> Self {
104        // Panic (rather than silently falling back to ".") when no home
105        // resolves: a system with no home directory is a misconfigured
106        // environment, and failing loudly is better than installing into an
107        // unexpected relative path.
108        let install_dir =
109            leviath_core::paths::agents_dir().expect("could not determine home directory");
110        Self { install_dir }
111    }
112
113    /// Create an installer with a custom installation directory.
114    pub fn with_install_dir(install_dir: PathBuf) -> Self {
115        Self { install_dir }
116    }
117
118    /// Install an agent from a `.leviath-bundle` file.
119    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        // Derive name from filename (strip .leviath-bundle extension)
127        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    /// Install an agent from in-memory bytes.
137    ///
138    /// `name` becomes a directory under the install dir, so it must be a single
139    /// safe path component. `install` derives it from `file_stem()` (which
140    /// already strips directories), but this is `pub` and any future caller
141    /// passing a downloaded or user-supplied name would otherwise get a
142    /// traversal for free - `Path::join` does not normalize, and an absolute
143    /// name replaces the base entirely.
144    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    /// Core of [`install_from_bytes`](Self::install_from_bytes) with the entry
149    /// classifier injected - see [`reject_symlinks_with`] for why the seam
150    /// exists. A `fn` pointer, so there is one monomorphization.
151    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        // Create installation directory
168        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        // Extract tar.gz archive.
177        //
178        // `Read::take` bounds the *decompressed* stream. Without it a ~1 MB
179        // bundle could expand to fill the disk - the classic decompression bomb -
180        // and nothing downstream would notice until the write failed.
181        //
182        // `set_preserve_permissions(false)` and `set_unpack_xattrs(false)`:
183        // otherwise an attacker-authored archive chooses the modes and extended
184        // attributes of the files it drops into the user's home.
185        //
186        // tar-rs already rejects `..` components and validates every entry
187        // against the destination (including hard links), so classic zip-slip is
188        // covered by the dependency - which is a reason to keep `cargo audit`
189        // watching it, not a reason to assume it always will.
190        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        // Read agent.leviath to get metadata
206        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    /// Uninstall an agent by removing its directory.
244    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    /// List all installed agents.
259    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    /// Get information about a specific installed agent.
312    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    /// Create a minimal tar.gz bundle with an agent.leviath manifest.
364    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    /// `install_from_bytes` is `pub` and joins `name` onto the install dir.
391    /// `Path::join` does not normalize `..` and an absolute name replaces the
392    /// base entirely, so an unvalidated name reached anywhere on the filesystem.
393    #[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    /// A gzip bomb: a small archive that expands without bound. `Read::take`
411    /// stops it mid-stream, so the unpack fails instead of filling the disk.
412    #[test]
413    fn install_from_bytes_refuses_a_decompression_bomb() {
414        // 512 MiB of zeros, which gzip compresses to a few hundred KiB - past
415        // the 256 MiB cap, so extraction must fail.
416        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        // The length is bound first: a *call* inside `assert!`'s format
431        // arguments is a region only the failing path reaches.
432        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    /// A bundle with a `tools/` subdirectory - the realistic shape, and the one
447    /// that exercises the recursive descent rather than only the flat case.
448    #[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    /// A symlink hidden one directory down is refused too - the scan descends
478    /// rather than checking only the top level, which is where a bundle would
479    /// naturally put one (`tools/`).
480    #[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    /// tar-rs blocks entries that *extract* outside the destination, but a
516    /// symlink entry lands inside it legally and then points wherever it likes.
517    /// The installed tree is later scanned for `.rhai` tool scripts, so a link
518    /// is a way to smuggle content in.
519    /// The refusal itself, driven through the injected classifier so it runs on
520    /// every platform. The `#[cfg(unix)]` tests below prove the same refusal
521    /// against a genuine symlink in a genuine archive; this one proves the arm
522    /// fires on Windows too, where a test cannot create one.
523    #[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    /// The refusal has to propagate out of a *nested* directory too - a bundle
537    /// plants its `tools/` subdirectory, not its root.
538    #[test]
539    fn reject_symlinks_refuses_an_entry_nested_in_a_subdirectory() {
540        /// Refuses only the leaf, so the recursion has to reach it.
541        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    /// And the refusal fails the *install*, rather than being computed and
559    /// discarded - the bundle must not be left in place.
560    #[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    /// Ordinary files and nested directories pass, so the test above is not
577    /// passing merely because everything is refused.
578    #[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        // And the classifier itself agrees about what it saw.
588        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        // Create a bundle with no agent.leviath
648        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        // Install one real agent
733        let bundle = make_bundle("good-agent", "1.0.0", "Good");
734        installer.install_from_bytes("good-agent", &bundle).unwrap();
735
736        // A regular file (not a dir) - covers the `if path.is_dir()` false branch
737        fs::write(dir.path().join("not-an-agent.txt"), "hello").unwrap();
738
739        // A dir without an agent.leviath manifest - covers the `if manifest_path.exists()` false branch
740        fs::create_dir_all(dir.path().join("no-manifest-dir")).unwrap();
741
742        let agents = installer.list_installed().unwrap();
743        // Only the properly-installed agent is returned; file and bare dir are skipped
744        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        // Create directory but no agent.leviath
775        fs::create_dir_all(dir.path().join("empty-agent")).unwrap();
776        assert!(installer.get_installed("empty-agent").unwrap().is_none());
777    }
778
779    // ─── AgentInstaller::new / Default ─────────────────────────────────
780
781    #[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    // ─── install() (file-based) ────────────────────────────────────────
794
795    #[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    // ─── install_from_bytes: create_dir_all failure ────────────────────
825
826    #[test]
827    fn install_from_bytes_create_dir_failure_returns_error() {
828        let dir = tempfile::tempdir().unwrap();
829        // Make a plain file where a directory needs to exist, so
830        // create_dir_all(install_dir.join(name)) fails.
831        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        // Valid gzip framing wrapping bytes that are NOT a valid tar
848        // archive - `GzDecoder` decompresses fine, but `Archive::unpack`
849        // fails on the malformed header, exercising the "Failed to extract
850        // package" error arm that every other test's well-formed bundle
851        // never reaches.
852        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]) // not a valid 512-byte tar header
859            .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        // The installed "agent" entry is a regular file rather than a
871        // directory: `exists()` passes the guard, but `remove_dir_all`
872        // requires a directory and fails on every platform (NotADirectory),
873        // exercising the "Failed to remove agent" error arm.
874        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}