use std::fs;
use std::path::{Path, PathBuf};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use crate::AgentProfile;
use crate::muragent::MuragentError;
use crate::muragent::manifest::MuragentManifest;
use crate::muragent::reader::MuragentArchive;
use crate::muragent::validator::{self, ValidationResult};
use crate::trust::rotation::RotationManifest;
use crate::trust::{self, TrustEntry, TrustLevel, TrustStore};
const ENVELOPE_FILES: &[&str] = &["manifest.yaml", "manifest.signed.json", "signatures.json"];
#[derive(Debug)]
pub struct InstallOutcome {
pub manifest: MuragentManifest,
pub trust_level: TrustLevel,
pub fingerprint_hex: String,
pub fingerprint_words: String,
pub was_update: bool,
}
pub fn install(
archive: &MuragentArchive,
mur_home: &Path,
surface: &str,
) -> Result<InstallOutcome, MuragentError> {
let result = validator::validate(archive)?;
let slug = result.manifest.agent.slug.clone();
let display_name = result.manifest.agent.display_name.clone();
crate::validate_agent_name(&slug).map_err(|e| {
MuragentError::Other(format!("invalid agent slug '{slug}' in manifest: {e}"))
})?;
let mut trust_store = TrustStore::load()?;
let author_pubkey_b64 = B64.encode(result.author_pubkey);
let existing_by_pubkey = trust_store.find_by_pubkey(&author_pubkey_b64).cloned();
if existing_by_pubkey.is_none() {
let by_name = trust_store.find_by_display_name(&display_name);
if !by_name.is_empty() {
let old_entry = by_name
.into_iter()
.find(|e| e.trust_level != TrustLevel::Superseded)
.cloned();
match try_apply_rotation(
&mut trust_store,
old_entry.as_ref(),
&author_pubkey_b64,
&display_name,
mur_home,
) {
Ok(()) => {} Err(reason) => {
return Err(MuragentError::TrustRefused(format!(
"agent '{}' has a new signing key but no valid rotation manifest: {}",
display_name, reason
)));
}
}
}
}
let agent_dir = mur_home.join("agents").join(&slug);
let was_update = if agent_dir.exists() {
let existing_profile = agent_dir.join("profile.yaml");
let mut is_same_agent = false;
if existing_profile.exists() {
let existing_yaml = fs::read_to_string(&existing_profile).map_err(MuragentError::Io)?;
if let Ok(existing) = serde_yaml_ng::from_str::<AgentProfile>(&existing_yaml)
&& existing.id == result.manifest.agent.original_uuid
{
is_same_agent = true;
}
}
if !is_same_agent {
return Err(MuragentError::Other(format!(
"agent '{slug}' already exists at {} with a different UUID",
agent_dir.display()
)));
}
clear_except_data(&agent_dir)?;
true
} else {
fs::create_dir_all(&agent_dir).map_err(MuragentError::Io)?;
false
};
extract_payload(archive, &agent_dir)?;
let fingerprint_hex = trust::short_fingerprint(&result.author_pubkey);
let fingerprint_words = trust::word_list_fingerprint(&result.author_pubkey);
let (trust_level, _) = upsert_trust(
&mut trust_store,
&result,
&author_pubkey_b64,
&existing_by_pubkey,
surface,
)?;
trust_store.save()?;
Ok(InstallOutcome {
manifest: result.manifest,
trust_level,
fingerprint_hex,
fingerprint_words,
was_update,
})
}
fn display_name_slug(name: &str) -> String {
name.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn rotation_manifest_path(mur_home: &Path, display_name: &str) -> PathBuf {
mur_home
.join("trust")
.join("rotations")
.join(format!("{}.yaml", display_name_slug(display_name)))
}
fn try_apply_rotation(
trust_store: &mut TrustStore,
old_entry: Option<&TrustEntry>,
new_pubkey_b64: &str,
display_name: &str,
mur_home: &Path,
) -> Result<(), String> {
let manifest_path = rotation_manifest_path(mur_home, display_name);
if !manifest_path.exists() {
return Err(
"no rotation manifest is present (possible impersonation; place \
<display_name>.yaml in ~/.mur/trust/rotations/ if intentional)"
.into(),
);
}
let yaml =
fs::read_to_string(&manifest_path).map_err(|e| format!("read rotation manifest: {e}"))?;
let manifest: RotationManifest =
serde_yaml_ng::from_str(&yaml).map_err(|e| format!("parse rotation manifest: {e}"))?;
if let Some(entry) = old_entry
&& manifest.old_pubkey != entry.public_key
{
return Err("rotation manifest old_pubkey does not match the known trust entry".into());
}
if manifest.new_pubkey != new_pubkey_b64 {
return Err("rotation manifest new_pubkey does not match the package's signing key".into());
}
manifest.verify()?;
if let Some(entry) = old_entry
&& let Some(last_at) = &entry.last_rotation_at
&& manifest.issued_at <= *last_at
{
return Err(format!(
"rotation manifest issued_at ({}) is not newer than last_rotation_at ({})",
manifest.issued_at, last_at
));
}
let now = chrono::Utc::now().to_rfc3339();
if let Some(entry) = old_entry.cloned() {
trust_store.upsert(TrustEntry {
trust_level: TrustLevel::Superseded,
superseded_at: Some(manifest.issued_at.clone()),
last_rotation_at: Some(manifest.issued_at.clone()),
..entry
});
}
trust_store.upsert(TrustEntry {
public_key: new_pubkey_b64.to_string(),
display_name_seen: display_name.to_string(),
first_seen: now.clone(),
last_seen: now,
last_seen_surface: String::new(), trust_level: TrustLevel::Pending,
fingerprint: String::new(), word_list: String::new(), rotated_from: old_entry.map(|e| e.public_key.clone()),
superseded_at: None,
last_rotation_at: Some(manifest.issued_at.clone()),
});
Ok(())
}
fn clear_except_data(dir: &Path) -> Result<(), MuragentError> {
for entry in fs::read_dir(dir).map_err(MuragentError::Io)? {
let entry = entry.map_err(MuragentError::Io)?;
if entry.file_name() == "data" {
continue;
}
let path = entry.path();
if path.is_dir() {
fs::remove_dir_all(&path).map_err(MuragentError::Io)?;
} else {
fs::remove_file(&path).map_err(MuragentError::Io)?;
}
}
Ok(())
}
fn extract_payload(archive: &MuragentArchive, agent_dir: &Path) -> Result<(), MuragentError> {
for (path, data) in &archive.files {
if ENVELOPE_FILES.contains(&path.as_str()) {
continue;
}
let dest = agent_dir.join(path);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(MuragentError::Io)?;
}
fs::write(&dest, data).map_err(MuragentError::Io)?;
}
Ok(())
}
fn upsert_trust(
trust_store: &mut TrustStore,
result: &ValidationResult,
author_pubkey_b64: &str,
existing: &Option<TrustEntry>,
surface: &str,
) -> Result<(TrustLevel, PathBuf), MuragentError> {
let now = chrono::Utc::now().to_rfc3339();
let first_seen = existing
.as_ref()
.map(|e| e.first_seen.clone())
.unwrap_or_else(|| now.clone());
let level = existing
.as_ref()
.map(|e| e.trust_level.clone())
.unwrap_or(TrustLevel::Pending);
trust_store.upsert(TrustEntry {
public_key: author_pubkey_b64.to_string(),
display_name_seen: result.manifest.agent.display_name.clone(),
first_seen,
last_seen: now,
last_seen_surface: surface.to_string(),
trust_level: level.clone(),
fingerprint: trust::short_fingerprint(&result.author_pubkey),
word_list: trust::word_list_fingerprint(&result.author_pubkey),
rotated_from: existing.as_ref().and_then(|e| e.rotated_from.clone()),
superseded_at: existing.as_ref().and_then(|e| e.superseded_at.clone()),
last_rotation_at: existing.as_ref().and_then(|e| e.last_rotation_at.clone()),
});
Ok((level, PathBuf::new()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::AgentIdentity;
use crate::muragent::writer::{MuragentWriter, build_manifest_from_profile};
use tempfile::TempDir;
fn make_test_package(tmp: &TempDir) -> std::path::PathBuf {
let out = tmp.path().join("test.muragent");
let profile = AgentProfile::default_for_tests();
let identity = AgentIdentity::generate();
let manifest = build_manifest_from_profile(&profile, "2.13.0");
let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
let mut writer = MuragentWriter::new(manifest, profile_yaml, identity);
writer.add_icon("icon-512.png", b"fake-png".to_vec());
writer.write(&out).unwrap();
out
}
fn make_test_package_with_identity(
tmp: &TempDir,
identity: &AgentIdentity,
) -> std::path::PathBuf {
let out = tmp
.path()
.join(format!("{}.muragent", &identity.pubkey_text()[..8]));
let profile = AgentProfile::default_for_tests();
let manifest = build_manifest_from_profile(&profile, "2.13.0");
let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
let mut writer = MuragentWriter::new(manifest, profile_yaml, identity.clone());
writer.add_icon("icon-512.png", b"fake-png".to_vec());
writer.write(&out).unwrap();
out
}
#[test]
fn rotation_manifest_missing_still_refuses() {
let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
let tmp = TempDir::new().unwrap();
let mur_home = tmp.path().join("mur");
let prev = std::env::var_os("MUR_HOME");
unsafe { std::env::set_var("MUR_HOME", &mur_home) };
let old_identity = AgentIdentity::generate();
let pkg_old = make_test_package_with_identity(&tmp, &old_identity);
let archive = MuragentArchive::read(&pkg_old).unwrap();
let outcome = install(&archive, &mur_home, "test").unwrap();
let slug = outcome.manifest.agent.slug.clone();
let new_identity = AgentIdentity::generate();
let profile = AgentProfile::default_for_tests();
let out2 = tmp.path().join("new2.muragent");
let manifest2 = build_manifest_from_profile(&profile, "2.14.0");
let profile_yaml2 = serde_yaml_ng::to_string(&profile).unwrap();
let mut writer2 = MuragentWriter::new(manifest2, profile_yaml2, new_identity);
writer2.add_icon("icon-512.png", b"fake-png".to_vec());
writer2.write(&out2).unwrap();
let archive2 = MuragentArchive::read(&out2).unwrap();
let agent_dir = mur_home.join("agents").join(&slug);
fs::remove_dir_all(&agent_dir).unwrap();
let err = install(&archive2, &mur_home, "test").unwrap_err();
assert!(
matches!(err, MuragentError::TrustRefused(_)),
"expected TrustRefused, got: {:?}",
err
);
unsafe {
if let Some(p) = prev {
std::env::set_var("MUR_HOME", p);
} else {
std::env::remove_var("MUR_HOME");
}
}
}
#[test]
fn display_name_slug_roundtrip() {
assert_eq!(display_name_slug("My Agent"), "my-agent");
assert_eq!(display_name_slug("Coach (Beta)"), "coach-beta");
assert_eq!(display_name_slug("test"), "test");
}
#[test]
fn install_then_update_preserves_data() {
let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
let tmp = TempDir::new().unwrap();
let mur_home = tmp.path().join("mur");
let prev = std::env::var_os("MUR_HOME");
unsafe { std::env::set_var("MUR_HOME", &mur_home) };
let pkg = make_test_package(&tmp);
let archive = MuragentArchive::read(&pkg).unwrap();
let outcome = install(&archive, &mur_home, "test").unwrap();
assert!(!outcome.was_update);
let slug = outcome.manifest.agent.slug.clone();
let agent_dir = mur_home.join("agents").join(&slug);
assert!(agent_dir.join("profile.yaml").exists());
let data_dir = agent_dir.join("data");
fs::create_dir_all(&data_dir).unwrap();
fs::write(data_dir.join("history.jsonl"), b"important").unwrap();
let outcome2 = install(&archive, &mur_home, "test").unwrap();
assert!(outcome2.was_update);
let preserved = fs::read(data_dir.join("history.jsonl")).unwrap();
assert_eq!(preserved, b"important");
unsafe {
if let Some(p) = prev {
std::env::set_var("MUR_HOME", p);
} else {
std::env::remove_var("MUR_HOME");
}
}
}
}