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/// What an entry is, as far as the symlink check cares.
17///
18/// A three-way answer rather than a `FileType`, because `FileType` cannot be
19/// constructed without a real file of that kind - and a real symlink is exactly
20/// what a test cannot create on Windows without a privilege CI runners lack.
21#[derive(Debug, Clone, Copy, PartialEq)]
22enum Entry {
23    Dir,
24    File,
25    /// A symlink, or an entry that could not be stat'd at all - the same
26    /// refusal either way, since neither can be certified.
27    Refused,
28}
29
30/// Classify a directory entry by its own metadata, not its target's.
31///
32/// `symlink_metadata` does not follow the link, which is the point.
33fn 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
41/// Refuse a bundle containing any symlink, at any depth, with the entry
42/// classifier injected.
43///
44/// tar-rs blocks entries that *extract* outside the destination, but a symlink
45/// entry lands inside it perfectly legally - and then points wherever it likes.
46/// Since the installed tree is later scanned for `.rhai` tool scripts and read
47/// by the file tools, a link is a way to smuggle content in (or to have a later
48/// write follow it out). Nothing in a legitimate agent bundle needs one.
49///
50/// A `fn` pointer (not `impl Fn`) so there is one monomorphization, matching the
51/// seam idiom used elsewhere in the workspace. The seam exists because the
52/// refusal cannot be reached otherwise on every platform: it needs a real
53/// symlink on disk, and creating one on Windows requires a privilege CI runners
54/// do not have. The `#[cfg(unix)]` tests still prove the real behaviour end to
55/// end through a genuine symlink in a genuine archive.
56fn reject_symlinks_with(dir: &Path, classify: fn(&Path) -> Entry) -> anyhow::Result<()> {
57    // `into_iter().flatten().flatten()` rather than a `match` on `read_dir`: this
58    // directory was created and unpacked into moments ago, so an unreadable one
59    // has no reachable test - and it surfaces anyway on the manifest read that
60    // follows. Collapsing it to "no entries" keeps the semantics with no branch
61    // nothing can exercise.
62    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/// Information about an installed agent.
78#[derive(Debug, Clone)]
79pub struct InstalledAgent {
80    /// Agent name
81    pub name: String,
82    /// Agent version
83    pub version: String,
84    /// Installation path
85    pub path: PathBuf,
86    /// Agent description
87    pub description: String,
88}
89
90/// Installs agents from `.leviath-bundle` packages.
91pub struct AgentInstaller {
92    /// Installation directory (default ~/.leviath/agents/)
93    install_dir: PathBuf,
94}
95
96impl AgentInstaller {
97    /// Create a new installer using the default installation directory.
98    ///
99    /// The install root comes from the shared `LEVIATH_HOME`-aware resolver
100    /// in [`leviath_core::paths`], so this crate installs into exactly the
101    /// tree every other component reads.
102    pub fn new() -> Self {
103        // Panic (rather than silently falling back to ".") when no home
104        // resolves: a system with no home directory is a misconfigured
105        // environment, and failing loudly is better than installing into an
106        // unexpected relative path.
107        let install_dir =
108            leviath_core::paths::agents_dir().expect("could not determine home directory");
109        Self { install_dir }
110    }
111
112    /// Create an installer with a custom installation directory.
113    pub fn with_install_dir(install_dir: PathBuf) -> Self {
114        Self { install_dir }
115    }
116
117    /// Install an agent from a `.leviath-bundle` file.
118    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        // Derive name from filename (strip .leviath-bundle extension)
126        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    /// Install an agent from in-memory bytes.
136    ///
137    /// `name` becomes a directory under the install dir, so it must be a single
138    /// safe path component. `install` derives it from `file_stem()` (which
139    /// already strips directories), but this is `pub` and any future caller
140    /// passing a downloaded or user-supplied name would otherwise get a
141    /// traversal for free - `Path::join` does not normalize, and an absolute
142    /// name replaces the base entirely.
143    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    /// Extract `data` into `dest` and validate what came out.
148    ///
149    /// `Read::take` bounds the *decompressed* stream. Without it a ~1 MB bundle
150    /// could expand to fill the disk - the classic decompression bomb - and
151    /// nothing downstream would notice until the write failed.
152    ///
153    /// `set_preserve_permissions(false)` and `set_unpack_xattrs(false)`:
154    /// otherwise an attacker-authored archive chooses the modes and extended
155    /// attributes of the files it drops into the user's home.
156    ///
157    /// tar-rs already rejects `..` components and validates every entry against
158    /// the destination (including hard links), so classic zip-slip is covered by
159    /// the dependency - which is a reason to keep `cargo audit` watching it, not
160    /// a reason to assume it always will.
161    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    /// Move a validated staging directory over the install destination.
178    ///
179    /// Remove-then-rename rather than rename-over: Windows refuses to rename
180    /// onto an existing directory. That leaves a window where a previous
181    /// install is gone and the new one is not yet in place, so a failure here
182    /// says so plainly rather than reporting a generic install error.
183    fn swap_into_place(staging: &Path, dest: &Path) -> anyhow::Result<()> {
184        // One error arm for both steps: the remedy is the same either way, and
185        // splitting them would mean two messages saying "reinstall the agent".
186        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    /// Core of [`install_from_bytes`](Self::install_from_bytes) with the entry
203    /// classifier injected - see [`reject_symlinks_with`] for why the seam
204    /// exists. A `fn` pointer, so there is one monomorphization.
205    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        // Unpack into a staging directory and swap it in only once the contents
222        // have passed every check.
223        //
224        // Extracting straight into `agent_dir` meant a bundle that failed
225        // validation still left its files there - including the symlinks
226        // `reject_symlinks_with` had just refused, which `discover_blueprints`
227        // would then list as a runnable agent. And because this path
228        // `create_dir_all`s over an existing install, a failed *re-install*
229        // would leave a working agent half-overwritten.
230        //
231        // Staged *beside* the agents directory, not inside it, and still on the
232        // same filesystem so the swap is a rename rather than a copy.
233        //
234        // Inside would be simpler and is wrong. Blueprint discovery scans every
235        // subdirectory of the agents directory, filters on `is_dir()` rather
236        // than skipping dotted names, sorts, and keeps the *first* entry for a
237        // given blueprint name. `.staging-…` sorts before every letter, so a
238        // staging tree declaring `name = "coder"` does not appear alongside the
239        // real `coder` - it **shadows** it. And a crash or SIGKILL between the
240        // unpack and the rename leaves that tree behind permanently, holding
241        // pre-validation content: the symlinks `reject_symlinks_with` was about
242        // to refuse, still discoverable, still shadowing.
243        // Built by suffixing the agents directory's own name rather than by
244        // walking to its parent: `<...>/agents.staging-coder-123` is a sibling
245        // of `agents`, so it is outside what discovery scans, and there is no
246        // "what if there is no parent" branch nothing could ever exercise.
247        // `OsString` rather than `format!` on a `Display`, so a non-UTF-8 home
248        // survives the round trip.
249        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        // The agents directory itself may not exist on a first install. The
253        // previous shape created it implicitly by unpacking into it; now that
254        // staging happens beside it, the rename needs it to be there already.
255        // One fallible step and one error arm: staging is a sibling of the
256        // agents directory, so if that directory could be created this one can
257        // too - a second message would describe a failure nothing can reach.
258        let prepare = || -> std::io::Result<()> {
259            fs::create_dir_all(&self.install_dir)?;
260            // A same-pid leftover from a crashed run would otherwise be
261            // unpacked *into*, mixing two bundles.
262            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        // Every early return from here on goes through this, so a refused
273        // bundle leaves nothing behind.
274        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        // Read agent.leviath to get metadata
282        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    /// Uninstall an agent by removing its directory.
320    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    /// List all installed agents.
335    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    /// Get information about a specific installed agent, or `None` if it is not
388    /// installed.
389    ///
390    /// Infallible on purpose, and the signature now says so. A manifest that
391    /// cannot be read or parsed still means *installed* - the directory and the
392    /// file are both there - so it reports the agent with whatever metadata it
393    /// could recover rather than failing. That is the state you would run
394    /// `lev remove` to fix, and an error here would be the one thing standing
395    /// between the user and the fix.
396    ///
397    /// It previously returned `anyhow::Result` and never once returned `Err`,
398    /// which left its only production caller `.unwrap()`-ing an infallible
399    /// result inside a function that returns `Result` - a panic waiting for
400    /// whoever made this propagate.
401    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    /// Create a minimal tar.gz bundle with an agent.leviath manifest.
453    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    /// `install_from_bytes` is `pub` and joins `name` onto the install dir.
480    /// `Path::join` does not normalize `..` and an absolute name replaces the
481    /// base entirely, so an unvalidated name reached anywhere on the filesystem.
482    #[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    /// A gzip bomb: a small archive that expands without bound. `Read::take`
500    /// stops it mid-stream, so the unpack fails instead of filling the disk.
501    #[test]
502    fn install_from_bytes_refuses_a_decompression_bomb() {
503        // 512 MiB of zeros, which gzip compresses to a few hundred KiB - past
504        // the 256 MiB cap, so extraction must fail.
505        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        // The length is bound first: a *call* inside `assert!`'s format
520        // arguments is a region only the failing path reaches.
521        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    /// A bundle with a `tools/` subdirectory - the realistic shape, and the one
536    /// that exercises the recursive descent rather than only the flat case.
537    #[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    /// A symlink hidden one directory down is refused too - the scan descends
567    /// rather than checking only the top level, which is where a bundle would
568    /// naturally put one (`tools/`).
569    #[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    /// tar-rs blocks entries that *extract* outside the destination, but a
605    /// symlink entry lands inside it legally and then points wherever it likes.
606    /// The installed tree is later scanned for `.rhai` tool scripts, so a link
607    /// is a way to smuggle content in.
608    /// The refusal itself, driven through the injected classifier so it runs on
609    /// every platform. The `#[cfg(unix)]` tests below prove the same refusal
610    /// against a genuine symlink in a genuine archive; this one proves the arm
611    /// fires on Windows too, where a test cannot create one.
612    #[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    /// The refusal has to propagate out of a *nested* directory too - a bundle
626    /// plants its `tools/` subdirectory, not its root.
627    #[test]
628    fn reject_symlinks_refuses_an_entry_nested_in_a_subdirectory() {
629        /// Refuses only the leaf, so the recursion has to reach it.
630        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    /// And the refusal fails the *install*, rather than being computed and
648    /// discarded - the bundle must not be left in place.
649    #[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    /// Ordinary files and nested directories pass, so the test above is not
666    /// passing merely because everything is refused.
667    #[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        // And the classifier itself agrees about what it saw.
677        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        // Create a bundle with no agent.leviath
737        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        // Install one real agent
822        let bundle = make_bundle("good-agent", "1.0.0", "Good");
823        installer.install_from_bytes("good-agent", &bundle).unwrap();
824
825        // A regular file (not a dir) - covers the `if path.is_dir()` false branch
826        fs::write(dir.path().join("not-an-agent.txt"), "hello").unwrap();
827
828        // A dir without an agent.leviath manifest - covers the `if manifest_path.exists()` false branch
829        fs::create_dir_all(dir.path().join("no-manifest-dir")).unwrap();
830
831        let agents = installer.list_installed().unwrap();
832        // Only the properly-installed agent is returned; file and bare dir are skipped
833        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        // Create directory but no agent.leviath
864        fs::create_dir_all(dir.path().join("empty-agent")).unwrap();
865        assert!(installer.get_installed("empty-agent").is_none());
866    }
867
868    // ─── AgentInstaller::new / Default ─────────────────────────────────
869
870    #[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    // ─── install() (file-based) ────────────────────────────────────────
883
884    #[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    // ─── install_from_bytes: create_dir_all failure ────────────────────
914
915    #[test]
916    fn install_from_bytes_create_dir_failure_returns_error() {
917        let dir = tempfile::tempdir().unwrap();
918        // Make a plain file where a directory needs to exist, so creating the
919        // agents directory under it fails.
920        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    /// A bundle that fails validation must leave nothing on disk. Extracting
936    /// straight into the destination meant the symlinks `reject_symlinks_with`
937    /// had just refused stayed there, and `discover_blueprints` would list the
938    /// half-extracted tree as a runnable agent.
939    #[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        // Counted rather than named: the assertion is that there is nothing to
950        // name, so a closure building the names would never run.
951        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    /// The swap can fail on its own - a stray *file* sitting where the agent
959    /// directory belongs cannot be removed as a directory. The message has to
960    /// say the install did not happen rather than reporting success.
961    #[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        // And the staging directory is not left behind.
973        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    /// Staging must not land inside the directory blueprint discovery scans.
983    ///
984    /// Discovery filters on `is_dir()` rather than skipping dotted names, sorts,
985    /// and keeps the *first* entry per blueprint name - and `.` sorts before
986    /// every letter. So a staging tree inside the agents directory would not sit
987    /// alongside the real agent, it would shadow it; and a crash between the
988    /// unpack and the rename would leave that tree there permanently, holding
989    /// exactly the pre-validation content the symlink check was about to refuse.
990    #[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        // Only the agent itself is in the scanned directory - nothing dotted,
1001        // nothing that would sort ahead of it.
1002        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        // And a refused install leaves nothing beside it either, so a crash
1011        // window is the only way to strand a staging tree at all.
1012        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    /// A failed re-install must not destroy the agent that was already there.
1026    /// This is why the fix is a staged swap and not a `remove_dir_all` on the
1027    /// error path - that would have introduced exactly this bug.
1028    #[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        // Valid gzip framing wrapping bytes that are NOT a valid tar
1054        // archive - `GzDecoder` decompresses fine, but `Archive::unpack`
1055        // fails on the malformed header, exercising the "Failed to extract
1056        // package" error arm that every other test's well-formed bundle
1057        // never reaches.
1058        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]) // not a valid 512-byte tar header
1065            .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        // The installed "agent" entry is a regular file rather than a
1077        // directory: `exists()` passes the guard, but `remove_dir_all`
1078        // requires a directory and fails on every platform (NotADirectory),
1079        // exercising the "Failed to remove agent" error arm.
1080        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}