use flate2::read::GzDecoder;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
const MAX_UNPACKED_BYTES: u64 = 256 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq)]
enum Entry {
Dir,
File,
Refused,
}
fn classify(path: &Path) -> Entry {
match fs::symlink_metadata(path).map(|m| m.file_type()).ok() {
Some(t) if t.is_dir() => Entry::Dir,
Some(t) if !t.is_symlink() => Entry::File,
_ => Entry::Refused,
}
}
fn reject_symlinks_with(dir: &Path, classify: fn(&Path) -> Entry) -> anyhow::Result<()> {
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
match classify(&path) {
Entry::Dir => reject_symlinks_with(&path, classify)?,
Entry::File => {}
Entry::Refused => anyhow::bail!(
"Package contains a symlink or unreadable entry ('{}'), which is not \
permitted in an agent bundle",
path.display()
),
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct InstalledAgent {
pub name: String,
pub version: String,
pub path: PathBuf,
pub description: String,
}
pub struct AgentInstaller {
install_dir: PathBuf,
}
impl AgentInstaller {
pub fn new() -> Self {
let install_dir =
leviath_core::paths::agents_dir().expect("could not determine home directory");
Self { install_dir }
}
pub fn with_install_dir(install_dir: PathBuf) -> Self {
Self { install_dir }
}
pub fn install(&self, package_path: &Path) -> anyhow::Result<InstalledAgent> {
tracing::info!(path = %package_path.display(), "Installing agent from package");
let data = fs::read(package_path).map_err(|e| {
anyhow::anyhow!("Failed to read package '{}': {}", package_path.display(), e)
})?;
let name = package_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
self.install_from_bytes(&name, &data)
}
pub fn install_from_bytes(&self, name: &str, data: &[u8]) -> anyhow::Result<InstalledAgent> {
self.install_from_bytes_with(name, data, classify)
}
fn install_from_bytes_with(
&self,
name: &str,
data: &[u8],
classify: fn(&Path) -> Entry,
) -> anyhow::Result<InstalledAgent> {
tracing::info!(name = %name, "Installing agent from bytes");
if !leviath_core::is_safe_path_component(name) {
anyhow::bail!(
"invalid agent name '{name}': names may contain only letters, digits, \
'.', '_' and '-'"
);
}
let agent_dir = self.install_dir.join(name);
fs::create_dir_all(&agent_dir).map_err(|e| {
anyhow::anyhow!(
"Failed to create install directory '{}': {}",
agent_dir.display(),
e
)
})?;
let decoder = GzDecoder::new(data).take(MAX_UNPACKED_BYTES);
let mut archive = tar::Archive::new(decoder);
archive.set_preserve_permissions(false);
archive.set_unpack_xattrs(false);
archive.unpack(&agent_dir).map_err(|e| {
anyhow::anyhow!(
"Failed to extract package: {}. (Bundles are limited to {} MiB \
uncompressed.)",
e,
MAX_UNPACKED_BYTES / (1024 * 1024)
)
})?;
reject_symlinks_with(&agent_dir, classify)?;
let manifest_path = agent_dir.join("agent.leviath");
let (version, description) = if manifest_path.exists() {
let content = fs::read_to_string(&manifest_path).unwrap_or_default();
let parsed: toml::Value =
toml::from_str(&content).unwrap_or(toml::Value::Table(toml::map::Map::new()));
let version = parsed
.get("agent")
.and_then(|a| a.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("0.0.0")
.to_string();
let description = parsed
.get("agent")
.and_then(|a| a.get("description"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(version, description)
} else {
("0.0.0".to_string(), String::new())
};
tracing::info!(
name = %name,
version = %version,
path = %agent_dir.display(),
"Agent installed successfully"
);
Ok(InstalledAgent {
name: name.to_string(),
version,
path: agent_dir,
description,
})
}
pub fn uninstall(&self, agent_name: &str) -> anyhow::Result<()> {
let agent_dir = self.install_dir.join(agent_name);
if !agent_dir.exists() {
anyhow::bail!("Agent '{}' is not installed", agent_name);
}
fs::remove_dir_all(&agent_dir)
.map_err(|e| anyhow::anyhow!("Failed to remove agent '{}': {}", agent_name, e))?;
tracing::info!(name = %agent_name, "Agent uninstalled");
Ok(())
}
pub fn list_installed(&self) -> anyhow::Result<Vec<InstalledAgent>> {
if !self.install_dir.exists() {
return Ok(Vec::new());
}
let mut agents = Vec::new();
for entry in
fs::read_dir(&self.install_dir).expect("install_dir exists - read_dir should not fail")
{
let entry = entry.expect("read_dir entry should not fail");
let path = entry.path();
if path.is_dir() {
let manifest_path = path.join("agent.leviath");
if manifest_path.exists() {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let content = fs::read_to_string(&manifest_path).unwrap_or_default();
let parsed: toml::Value = toml::from_str(&content)
.unwrap_or(toml::Value::Table(toml::map::Map::new()));
let version = parsed
.get("agent")
.and_then(|a| a.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("0.0.0")
.to_string();
let description = parsed
.get("agent")
.and_then(|a| a.get("description"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
agents.push(InstalledAgent {
name,
version,
path,
description,
});
}
}
}
Ok(agents)
}
pub fn get_installed(&self, name: &str) -> anyhow::Result<Option<InstalledAgent>> {
let agent_dir = self.install_dir.join(name);
if !agent_dir.exists() {
return Ok(None);
}
let manifest_path = agent_dir.join("agent.leviath");
if !manifest_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&manifest_path).unwrap_or_default();
let parsed: toml::Value =
toml::from_str(&content).unwrap_or(toml::Value::Table(toml::map::Map::new()));
let version = parsed
.get("agent")
.and_then(|a| a.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("0.0.0")
.to_string();
let description = parsed
.get("agent")
.and_then(|a| a.get("description"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(Some(InstalledAgent {
name: name.to_string(),
version,
path: agent_dir,
description,
}))
}
}
impl Default for AgentInstaller {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::with_tracing;
use flate2::Compression;
use flate2::write::GzEncoder;
fn make_bundle(name: &str, version: &str, description: &str) -> Vec<u8> {
let manifest = format!(
r#"[agent]
name = "{}"
version = "{}"
description = "{}"
"#,
name, version, description
);
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
{
let mut archive = tar::Builder::new(&mut encoder);
let manifest_bytes = manifest.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(manifest_bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
archive
.append_data(&mut header, "agent.leviath", manifest_bytes)
.unwrap();
archive.finish().unwrap();
}
encoder.finish().unwrap()
}
#[test]
fn install_from_bytes_rejects_traversing_names() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let bundle = make_bundle("x", "1.0.0", "d");
for name in ["../escape", "../../tmp/escape", "/tmp/escape", "a/b", ".."] {
let err = installer
.install_from_bytes(name, &bundle)
.expect_err("{name} must be refused");
assert!(err.to_string().contains("invalid agent name"), "{err}");
}
assert!(
!std::path::Path::new("/tmp/escape").exists(),
"nothing may be created outside the install dir"
);
}
#[test]
fn install_from_bytes_refuses_a_decompression_bomb() {
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
{
let mut archive = tar::Builder::new(&mut encoder);
let size = 512 * 1024 * 1024u64;
let mut header = tar::Header::new_gnu();
header.set_size(size);
header.set_mode(0o644);
header.set_cksum();
archive
.append_data(&mut header, "big.bin", std::io::repeat(0).take(size))
.unwrap();
archive.finish().unwrap();
}
let bomb = encoder.finish().unwrap();
let compressed = bomb.len();
assert!(
compressed < 5 * 1024 * 1024,
"precondition: the bomb is small on disk"
);
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let err = installer
.install_from_bytes("bomb", &bomb)
.expect_err("an oversized bundle must be refused");
assert!(err.to_string().contains("Failed to extract"), "{err}");
}
#[test]
fn install_from_bytes_accepts_a_nested_directory() {
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
{
let mut archive = tar::Builder::new(&mut encoder);
for (path, body) in [
(
"agent.leviath",
"[agent]\nname = \"n\"\nversion = \"1.0.0\"\n",
),
("tools/web_fetch.rhai", "// @tool web_fetch\n"),
] {
let bytes = body.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
archive.append_data(&mut header, path, bytes).unwrap();
}
archive.finish().unwrap();
}
let bundle = encoder.finish().unwrap();
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let installed = installer.install_from_bytes("nested", &bundle).unwrap();
assert!(installed.path.join("tools/web_fetch.rhai").exists());
}
#[cfg(unix)]
#[test]
fn install_from_bytes_refuses_a_nested_symlink_entry() {
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
{
let mut archive = tar::Builder::new(&mut encoder);
let manifest = "[agent]\nname = \"n\"\nversion = \"1.0.0\"\n";
let bytes = manifest.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
archive
.append_data(&mut header, "agent.leviath", bytes)
.unwrap();
let mut link = tar::Header::new_gnu();
link.set_size(0);
link.set_entry_type(tar::EntryType::Symlink);
link.set_mode(0o777);
archive
.append_link(&mut link, "tools/escape", "/etc/passwd")
.unwrap();
archive.finish().unwrap();
}
let bundle = encoder.finish().unwrap();
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let err = installer
.install_from_bytes("nested-link", &bundle)
.expect_err("a nested symlink must be refused");
assert!(err.to_string().contains("symlink"), "{err}");
}
#[test]
fn reject_symlinks_refuses_an_entry_it_cannot_certify() {
fn all_refused(_: &Path) -> Entry {
Entry::Refused
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("thing"), b"x").unwrap();
let err = reject_symlinks_with(dir.path(), all_refused)
.expect_err("an entry that cannot be certified is refused");
assert!(err.to_string().contains("symlink or unreadable"), "{err}");
}
#[test]
fn reject_symlinks_refuses_an_entry_nested_in_a_subdirectory() {
fn refuse_the_leaf(path: &Path) -> Entry {
match path.file_name().and_then(|n| n.to_str()) {
Some("web_fetch.rhai") => Entry::Refused,
_ => classify(path),
}
}
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("tools");
std::fs::create_dir(&nested).unwrap();
std::fs::write(nested.join("web_fetch.rhai"), b"x").unwrap();
let err = reject_symlinks_with(dir.path(), refuse_the_leaf)
.expect_err("a refused entry one level down is still refused");
assert!(err.to_string().contains("web_fetch.rhai"), "{err}");
}
#[test]
fn install_refuses_a_bundle_whose_entries_cannot_be_certified() {
fn all_refused(_: &Path) -> Entry {
Entry::Refused
}
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let bundle = make_bundle("probe", "1.0.0", "a probe");
let err = installer
.install_from_bytes_with("probe", &bundle, all_refused)
.expect_err("an uncertifiable bundle must not install");
assert!(err.to_string().contains("symlink or unreadable"), "{err}");
}
#[test]
fn reject_symlinks_admits_ordinary_files_and_directories() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("tools");
std::fs::create_dir(&nested).unwrap();
std::fs::write(nested.join("web_fetch.rhai"), b"x").unwrap();
std::fs::write(dir.path().join("agent.leviath"), b"x").unwrap();
reject_symlinks_with(dir.path(), classify).expect("an ordinary bundle passes");
assert_eq!(classify(&nested), Entry::Dir);
assert_eq!(classify(&nested.join("web_fetch.rhai")), Entry::File);
assert_eq!(classify(&dir.path().join("no-such-entry")), Entry::Refused);
}
#[cfg(unix)]
#[test]
fn install_from_bytes_refuses_a_symlink_entry() {
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
{
let mut archive = tar::Builder::new(&mut encoder);
let mut header = tar::Header::new_gnu();
header.set_size(0);
header.set_entry_type(tar::EntryType::Symlink);
header.set_mode(0o777);
archive
.append_link(&mut header, "escape", "/etc/passwd")
.unwrap();
archive.finish().unwrap();
}
let bundle = encoder.finish().unwrap();
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let err = installer
.install_from_bytes("linky", &bundle)
.expect_err("a symlink entry must be refused");
assert!(err.to_string().contains("symlink"), "{err}");
}
#[test]
fn with_install_dir_sets_dir() {
let dir = PathBuf::from("/tmp/test-installer");
let installer = AgentInstaller::with_install_dir(dir.clone());
assert_eq!(installer.install_dir, dir);
}
#[test]
fn install_from_bytes_creates_directory() {
with_tracing(|| {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let bundle = make_bundle("test-agent", "1.0.0", "A test agent");
let result = installer.install_from_bytes("test-agent", &bundle).unwrap();
assert_eq!(result.name, "test-agent");
assert_eq!(result.version, "1.0.0");
assert_eq!(result.description, "A test agent");
assert!(result.path.exists());
assert!(result.path.join("agent.leviath").exists());
});
}
#[test]
fn install_from_bytes_no_manifest_defaults() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
{
let mut archive = tar::Builder::new(&mut encoder);
let data = b"hello";
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
archive
.append_data(&mut header, "readme.txt", &data[..])
.unwrap();
archive.finish().unwrap();
}
let bundle = encoder.finish().unwrap();
let result = installer
.install_from_bytes("no-manifest", &bundle)
.unwrap();
assert_eq!(result.version, "0.0.0");
assert_eq!(result.description, "");
}
#[test]
fn uninstall_removes_directory() {
with_tracing(|| {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let bundle = make_bundle("to-remove", "1.0.0", "remove me");
installer.install_from_bytes("to-remove", &bundle).unwrap();
assert!(dir.path().join("to-remove").exists());
installer.uninstall("to-remove").unwrap();
assert!(!dir.path().join("to-remove").exists());
});
}
#[test]
fn uninstall_nonexistent_returns_error() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let err = installer.uninstall("no-such-agent").unwrap_err();
assert!(err.to_string().contains("not installed"));
}
#[test]
fn list_installed_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let agents = installer.list_installed().unwrap();
assert!(agents.is_empty());
}
#[test]
fn list_installed_nonexistent_dir() {
let installer =
AgentInstaller::with_install_dir(PathBuf::from("/tmp/nonexistent-leviath-test-dir"));
let agents = installer.list_installed().unwrap();
assert!(agents.is_empty());
}
#[test]
fn list_installed_returns_installed_agents() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let bundle1 = make_bundle("agent-a", "1.0.0", "Agent A");
let bundle2 = make_bundle("agent-b", "2.0.0", "Agent B");
installer.install_from_bytes("agent-a", &bundle1).unwrap();
installer.install_from_bytes("agent-b", &bundle2).unwrap();
let agents = installer.list_installed().unwrap();
assert_eq!(agents.len(), 2);
let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
assert!(names.contains(&"agent-a"));
assert!(names.contains(&"agent-b"));
}
#[test]
fn list_installed_skips_non_directory_entries() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let bundle = make_bundle("good-agent", "1.0.0", "Good");
installer.install_from_bytes("good-agent", &bundle).unwrap();
fs::write(dir.path().join("not-an-agent.txt"), "hello").unwrap();
fs::create_dir_all(dir.path().join("no-manifest-dir")).unwrap();
let agents = installer.list_installed().unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].name, "good-agent");
}
#[test]
fn get_installed_found() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let bundle = make_bundle("findme", "3.2.1", "Find this agent");
installer.install_from_bytes("findme", &bundle).unwrap();
let agent = installer.get_installed("findme").unwrap().unwrap();
assert_eq!(agent.name, "findme");
assert_eq!(agent.version, "3.2.1");
assert_eq!(agent.description, "Find this agent");
}
#[test]
fn get_installed_not_found() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
assert!(installer.get_installed("nope").unwrap().is_none());
}
#[test]
fn get_installed_dir_exists_but_no_manifest() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
fs::create_dir_all(dir.path().join("empty-agent")).unwrap();
assert!(installer.get_installed("empty-agent").unwrap().is_none());
}
#[test]
fn new_derives_install_dir_from_home() {
let installer = AgentInstaller::new();
assert!(installer.install_dir.ends_with(".leviath/agents"));
}
#[test]
fn default_matches_new() {
let installer = AgentInstaller::default();
assert!(installer.install_dir.ends_with(".leviath/agents"));
}
#[test]
fn install_from_file_path_derives_name_from_filename() {
with_tracing(|| {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().join("agents"));
let bundle = make_bundle("file-agent", "1.2.3", "Installed from a file");
let package_path = dir.path().join("file-agent.leviath-bundle");
fs::write(&package_path, &bundle).unwrap();
let result = installer.install(&package_path).unwrap();
assert_eq!(result.name, "file-agent");
assert_eq!(result.version, "1.2.3");
assert_eq!(result.description, "Installed from a file");
assert!(result.path.exists());
});
}
#[test]
fn install_from_file_path_missing_file_returns_error() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let err = installer
.install(&dir.path().join("does-not-exist.leviath-bundle"))
.unwrap_err();
assert!(err.to_string().contains("Failed to read package"));
}
#[test]
fn install_from_bytes_create_dir_failure_returns_error() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
fs::write(&blocker, b"not a directory").unwrap();
let installer = AgentInstaller::with_install_dir(blocker.join("agents"));
let bundle = make_bundle("blocked", "1.0.0", "desc");
let err = installer
.install_from_bytes("blocked", &bundle)
.unwrap_err();
assert!(
err.to_string()
.contains("Failed to create install directory")
);
}
#[test]
fn install_from_bytes_corrupt_tar_after_valid_gzip_returns_extract_error() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
use std::io::Write;
encoder
.write_all(&[b'x'; 600]) .unwrap();
let bundle = encoder.finish().unwrap();
let err = installer
.install_from_bytes("corrupt-tar", &bundle)
.unwrap_err();
assert!(err.to_string().contains("Failed to extract package"));
}
#[test]
fn uninstall_remove_dir_all_failure_returns_error() {
let dir = tempfile::tempdir().unwrap();
let installer = AgentInstaller::with_install_dir(dir.path().to_path_buf());
let agent_path = dir.path().join("not-a-dir");
fs::write(&agent_path, b"i am a file, not a directory").unwrap();
let result = installer.uninstall("not-a-dir");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to remove agent")
);
}
}