- src/registry.rs : Manifest (schema/name/version/author/source/agents_count/sha256), publish (écrit am-manifest.json + instructions git), search (multi-sources via settings.registry.sources), install (manifeste → catalogue → sha256 → source cohérente → confiance → register_include), list
- settings.registry {sources, author} + doc config.yaml ; CLI/REPL/help/man/i18n
- 11 tests : hash, manifest_url, publish (fichier + refus catalogue vide/sans source), E2E install tiny_http (refus sans confiance, enregistrement avec --yes, 4 requêtes), source mismatch
485 lines
18 KiB
Rust
485 lines
18 KiB
Rust
//! Community registry (issue #77): publish, search and install agent
|
||
//! catalogs hosted on Gitea.
|
||
//!
|
||
//! A published catalog ships with a manifest (`am-manifest.json`) recording
|
||
//! source, version, author, agent count and the sha256 of the catalog file.
|
||
//! `am registry install` validates the checksum and requires an explicit
|
||
//! trust decision for unknown sources — an untrusted source is refused.
|
||
|
||
use crate::app::App;
|
||
use anyhow::{anyhow, Result};
|
||
use serde::{Deserialize, Serialize};
|
||
use sha2::{Digest, Sha256};
|
||
use std::path::{Path, PathBuf};
|
||
|
||
/// Name of the manifest file, expected next to the catalog file.
|
||
pub const MANIFEST_NAME: &str = "am-manifest.json";
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Manifest {
|
||
pub schema: u32,
|
||
pub name: String,
|
||
pub version: String,
|
||
pub author: String,
|
||
/// Public URL of the catalog file this manifest describes.
|
||
pub source: String,
|
||
pub agents_count: usize,
|
||
/// Lowercase hex sha256 of the catalog file content.
|
||
pub sha256: String,
|
||
pub published_at: String,
|
||
}
|
||
|
||
/// sha256 of some bytes, lowercase hex.
|
||
pub fn sha256_hex(data: &[u8]) -> String {
|
||
let mut hasher = Sha256::new();
|
||
hasher.update(data);
|
||
let digest = hasher.finalize();
|
||
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||
}
|
||
|
||
/// The manifest URL living next to a catalog URL: same base, file name
|
||
/// replaced by `am-manifest.json` (or appended when the URL is a directory).
|
||
pub fn manifest_url_of(catalog_url: &str) -> String {
|
||
let trimmed = catalog_url.trim_end_matches('/');
|
||
let last = trimmed.rsplit('/').next().unwrap_or("");
|
||
if last.contains('.') && !last.contains('?') {
|
||
// File URL: replace the file name.
|
||
let base = trimmed.trim_end_matches(last);
|
||
format!("{base}{MANIFEST_NAME}")
|
||
} else {
|
||
// Directory URL: append.
|
||
format!("{trimmed}/{MANIFEST_NAME}")
|
||
}
|
||
}
|
||
|
||
/// Build and write the manifest next to a catalog file (dry-run safe).
|
||
pub fn publish(
|
||
app: &App,
|
||
catalog_path: &Path,
|
||
source: Option<&str>,
|
||
author: Option<&str>,
|
||
version: Option<&str>,
|
||
) -> Result<i32> {
|
||
let text = std::fs::read_to_string(catalog_path)
|
||
.map_err(|e| anyhow!("cannot read {}: {e}", catalog_path.display()))?;
|
||
let remote = crate::catalog_remote::parse_catalog(&text)?;
|
||
if remote.agents.is_empty() {
|
||
anyhow::bail!("the catalog defines no agents — refusing to publish");
|
||
}
|
||
let Some(source) = source.filter(|s| !s.trim().is_empty()) else {
|
||
anyhow::bail!(
|
||
"--source <url> is required: the public URL of the catalog once pushed \
|
||
(e.g. https://git.dracodev.net/<user>/<repo>/raw/branch/main/am-catalog.yaml)"
|
||
);
|
||
};
|
||
let author = author
|
||
.filter(|a| !a.trim().is_empty())
|
||
.or_else(|| {
|
||
app.config
|
||
.settings
|
||
.registry
|
||
.as_ref()
|
||
.and_then(|r| r.author.as_deref())
|
||
})
|
||
.unwrap_or("unknown")
|
||
.to_string();
|
||
let name = catalog_path
|
||
.file_stem()
|
||
.map(|s| s.to_string_lossy().to_string())
|
||
.unwrap_or_else(|| "catalog".to_string());
|
||
let manifest = Manifest {
|
||
schema: 1,
|
||
name,
|
||
version: version.unwrap_or("1.0.0").to_string(),
|
||
author,
|
||
source: source.to_string(),
|
||
agents_count: remote.agents.len(),
|
||
sha256: sha256_hex(text.as_bytes()),
|
||
published_at: crate::installers::now_rfc3339(),
|
||
};
|
||
let manifest_path = catalog_path.with_file_name(MANIFEST_NAME);
|
||
if app.json() {
|
||
print!("{}", serde_json::to_string_pretty(&manifest)?);
|
||
println!();
|
||
return Ok(0);
|
||
}
|
||
if app.dry_run() {
|
||
app.log.dry(&format!(
|
||
"would write {} ({} agents, sha256 {})",
|
||
manifest_path.display(),
|
||
manifest.agents_count,
|
||
&manifest.sha256[..12.min(manifest.sha256.len())]
|
||
));
|
||
return Ok(0);
|
||
}
|
||
std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)
|
||
.map_err(|e| anyhow!("cannot write {}: {e}", manifest_path.display()))?;
|
||
app.log.success(&format!(
|
||
"manifest written: {} ({} agents, sha256 {})",
|
||
manifest_path.display(),
|
||
manifest.agents_count,
|
||
&manifest.sha256[..12]
|
||
));
|
||
app.log.info(&format!(
|
||
"push both files to your Gitea repo, then register/install with:\n am registry install {source}"
|
||
));
|
||
Ok(0)
|
||
}
|
||
|
||
/// Fetch + validate the manifest of a catalog URL.
|
||
pub fn fetch_manifest(app: &App, catalog_url: &str) -> Result<Manifest> {
|
||
let murl = manifest_url_of(catalog_url);
|
||
let text = crate::catalog_remote::fetch(app, &murl, true)?;
|
||
let manifest: Manifest = serde_json::from_str(&text)
|
||
.map_err(|e| anyhow!("invalid manifest at {murl}: {e}"))?;
|
||
if manifest.sha256.len() != 64 {
|
||
anyhow::bail!("manifest at {murl} has an invalid sha256");
|
||
}
|
||
if manifest.source != catalog_url {
|
||
anyhow::bail!(
|
||
"manifest source mismatch: declares {} but requested {} — refused",
|
||
manifest.source,
|
||
catalog_url
|
||
);
|
||
}
|
||
Ok(manifest)
|
||
}
|
||
|
||
/// Registered sources of the community registry.
|
||
pub fn sources(app: &App) -> Vec<String> {
|
||
app.config
|
||
.settings
|
||
.registry
|
||
.as_ref()
|
||
.map(|r| r.sources.clone())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// am registry search <query>: probe every registered source, list the
|
||
/// agents matching the query (name, description, tags).
|
||
pub fn search(app: &App, query: &str) -> Result<i32> {
|
||
let sources = sources(app);
|
||
if sources.is_empty() {
|
||
app.log.info(crate::i18n::tr(
|
||
"aucune source enregistrée — voir settings.registry.sources ou: am registry install <url>",
|
||
));
|
||
return Ok(0);
|
||
}
|
||
let q = query.trim().to_lowercase();
|
||
let mut found = 0usize;
|
||
for url in &sources {
|
||
let Ok(manifest) = fetch_manifest(app, url) else {
|
||
app.log
|
||
.verbose(&format!("registry source {url}: manifest unavailable"));
|
||
continue;
|
||
};
|
||
let Ok(text) = crate::catalog_remote::fetch(app, url, true) else {
|
||
continue;
|
||
};
|
||
let Ok(remote) = crate::catalog_remote::parse_catalog(&text) else {
|
||
continue;
|
||
};
|
||
let matches: Vec<&crate::config::AgentDef> = remote
|
||
.agents
|
||
.iter()
|
||
.filter(|a| {
|
||
q.is_empty()
|
||
|| a.name.to_lowercase().contains(&q)
|
||
|| a.description.as_deref().unwrap_or("").to_lowercase().contains(&q)
|
||
|| a.tags.iter().any(|t| t.to_lowercase().contains(&q))
|
||
})
|
||
.collect();
|
||
if matches.is_empty() {
|
||
continue;
|
||
}
|
||
found += matches.len();
|
||
app.log.info(&format!(
|
||
"{} v{} par {} ({})",
|
||
manifest.name, manifest.version, manifest.author, url
|
||
));
|
||
for a in &matches {
|
||
println!(" {} — {}", a.name, a.description.as_deref().unwrap_or(""));
|
||
}
|
||
}
|
||
if found == 0 {
|
||
app.log.info(&format!("aucun agent ne correspond à «{query}»"));
|
||
}
|
||
Ok(0)
|
||
}
|
||
|
||
/// am registry install <url>: manifest + checksum + trust, then register the
|
||
/// catalog as a remote include (same mechanism as `am catalog add`).
|
||
pub fn install(app: &App, url: &str, yes: bool) -> Result<i32> {
|
||
crate::download::validate_url(url)?;
|
||
app.log.info(&format!("fetching manifest: {}", manifest_url_of(url)));
|
||
let manifest = fetch_manifest(app, url)?;
|
||
app.log.info(&format!("fetching catalog: {url}"));
|
||
let text = crate::catalog_remote::fetch(app, url, true)?;
|
||
let actual = sha256_hex(text.as_bytes());
|
||
if actual != manifest.sha256 {
|
||
anyhow::bail!(
|
||
"checksum mismatch — expected {} got {} (source non fiable, refusée)",
|
||
&manifest.sha256[..12],
|
||
&actual[..12]
|
||
);
|
||
}
|
||
let remote = crate::catalog_remote::parse_catalog(&text)?;
|
||
if remote.agents.is_empty() {
|
||
anyhow::bail!("the catalog defines no agents — refusing to install");
|
||
}
|
||
// Trust: a source already registered (settings.registry.sources or an
|
||
// active include) is trusted; anything else needs an explicit decision.
|
||
let known = sources(app).iter().any(|s| s == url)
|
||
|| crate::commands::config_cmd::user_includes(app)
|
||
.iter()
|
||
.any(|i| i == url);
|
||
if !known && !yes {
|
||
if !app.confirm(&format!(
|
||
"source non fiable : {} (auteur {}, v{}) — faire confiance ?",
|
||
url, manifest.author, manifest.version
|
||
))? {
|
||
app.log.info(crate::i18n::tr("installation refusée — source non fiable"));
|
||
return Ok(0);
|
||
}
|
||
}
|
||
if app.dry_run() {
|
||
app.log.dry(&format!(
|
||
"would register {} ({} agents, auteur {}, v{})",
|
||
url, manifest.agents_count, manifest.author, manifest.version
|
||
));
|
||
return Ok(0);
|
||
}
|
||
crate::commands::config_cmd::register_include(app, url)?;
|
||
app.emit(
|
||
&crate::events::Event::now(crate::events::EventKind::Catalog)
|
||
.with_args(vec![url.to_string()])
|
||
.with_reason("registry-install"),
|
||
);
|
||
app.log.success(&format!(
|
||
"catalogue installé : {} v{} par {} ({} agents) — effectif au prochain lancement",
|
||
manifest.name, manifest.version, manifest.author, manifest.agents_count
|
||
));
|
||
Ok(0)
|
||
}
|
||
|
||
/// am registry list: the registered sources.
|
||
pub fn list(app: &App) -> Result<i32> {
|
||
let sources = sources(app);
|
||
if sources.is_empty() {
|
||
app.log.info(crate::i18n::tr(
|
||
"aucune source enregistrée — settings.registry.sources ou: am registry install <url>",
|
||
));
|
||
return Ok(0);
|
||
}
|
||
if app.json() {
|
||
print!("{}", serde_json::to_string_pretty(&sources)?);
|
||
println!();
|
||
return Ok(0);
|
||
}
|
||
for s in &sources {
|
||
println!(" {s}");
|
||
}
|
||
Ok(0)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use clap::Parser;
|
||
|
||
#[test]
|
||
fn sha256_hex_is_64_chars_and_stable() {
|
||
let h1 = sha256_hex(b"hello");
|
||
let h2 = sha256_hex(b"hello");
|
||
assert_eq!(h1.len(), 64);
|
||
assert_eq!(h1, h2);
|
||
assert_ne!(sha256_hex(b"hello!"), h1);
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_url_replaces_the_file_name() {
|
||
assert_eq!(
|
||
manifest_url_of("https://git.dracodev.net/u/r/raw/branch/main/am-catalog.yaml"),
|
||
"https://git.dracodev.net/u/r/raw/branch/main/am-manifest.json"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_url_appends_on_a_directory() {
|
||
assert_eq!(
|
||
manifest_url_of("https://git.dracodev.net/u/r/raw/branch/main/"),
|
||
"https://git.dracodev.net/u/r/raw/branch/main/am-manifest.json"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn publish_writes_the_manifest_next_to_the_catalog() {
|
||
let guard = tempfile::tempdir().unwrap();
|
||
let dir = guard.path().to_path_buf();
|
||
let catalog = dir.join("am-catalog.yaml");
|
||
std::fs::write(
|
||
&catalog,
|
||
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n",
|
||
)
|
||
.unwrap();
|
||
let app = crate::app::App::from_cli(
|
||
crate::cli::Cli::parse_from(["am", "--config", dir.join("c.yaml").to_str().unwrap()]),
|
||
)
|
||
.unwrap();
|
||
let code = publish(
|
||
&app,
|
||
&catalog,
|
||
Some("https://git.dracodev.net/x/y/raw/branch/main/am-catalog.yaml"),
|
||
Some("bruno"),
|
||
Some("2.0.0"),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(code, 0);
|
||
let manifest_path = dir.join(MANIFEST_NAME);
|
||
assert!(manifest_path.exists(), "manifest must be written");
|
||
let m: Manifest =
|
||
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
|
||
assert_eq!(m.author, "bruno");
|
||
assert_eq!(m.version, "2.0.0");
|
||
assert_eq!(m.agents_count, 1);
|
||
assert_eq!(m.name, "am-catalog");
|
||
assert_eq!(
|
||
m.sha256,
|
||
sha256_hex(std::fs::read(&catalog).unwrap().as_slice())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn publish_refuses_empty_catalogs_and_missing_source() {
|
||
let guard = tempfile::tempdir().unwrap();
|
||
let dir = guard.path().to_path_buf();
|
||
let catalog = dir.join("empty.yaml");
|
||
std::fs::write(&catalog, "version: \"1.0\"\nagents: []\n").unwrap();
|
||
let app = crate::app::App::from_cli(
|
||
crate::cli::Cli::parse_from(["am", "--config", dir.join("c.yaml").to_str().unwrap()]),
|
||
)
|
||
.unwrap();
|
||
assert!(publish(&app, &catalog, Some("https://x/y.yaml"), None, None).is_err());
|
||
// Missing source.
|
||
let catalog2 = dir.join("ok.yaml");
|
||
std::fs::write(
|
||
&catalog2,
|
||
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n",
|
||
)
|
||
.unwrap();
|
||
let err = publish(&app, &catalog2, None, None, None).unwrap_err();
|
||
assert!(err.to_string().contains("--source"));
|
||
}
|
||
|
||
#[test]
|
||
fn install_end_to_end_checks_checksum_and_trust() {
|
||
// A tiny_http server serves the catalog + its manifest. The install
|
||
// must validate the sha256 and register the include.
|
||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||
let port = server.server_addr().to_ip().unwrap().port();
|
||
let catalog_body = "version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n";
|
||
let sha = sha256_hex(catalog_body.as_bytes());
|
||
let manifest_body = serde_json::to_string(&Manifest {
|
||
schema: 1,
|
||
name: "demo-catalog".to_string(),
|
||
version: "1.0.0".to_string(),
|
||
author: "bruno".to_string(),
|
||
source: format!("http://127.0.0.1:{port}/am-catalog.yaml"),
|
||
agents_count: 1,
|
||
sha256: sha.clone(),
|
||
published_at: "now".to_string(),
|
||
})
|
||
.unwrap();
|
||
let handle = std::thread::spawn(move || {
|
||
// 2 requests per install (manifest + catalog) × 2 installs.
|
||
for _ in 0..4 {
|
||
let mut req = server
|
||
.recv_timeout(std::time::Duration::from_secs(10))
|
||
.expect("server recv failed")
|
||
.expect("the registry install must fetch manifest + catalog");
|
||
let body = if req.url().ends_with("am-manifest.json") {
|
||
manifest_body.clone()
|
||
} else {
|
||
catalog_body.to_string()
|
||
};
|
||
let _ = req.respond(tiny_http::Response::from_string(body));
|
||
}
|
||
});
|
||
let guard = tempfile::tempdir().unwrap();
|
||
let dir = guard.path().to_path_buf();
|
||
let mut cfg = dir.join("config.yaml");
|
||
std::fs::write(
|
||
&cfg,
|
||
format!(
|
||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n registry:\n sources: []\nagents: []\n"
|
||
),
|
||
)
|
||
.unwrap();
|
||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||
let mut app = crate::app::App::from_cli(cli).unwrap();
|
||
// Isolate the user config dir so register_include/user_includes
|
||
// operate on the tempdir instead of the real profile.
|
||
let mut p = app.paths.clone();
|
||
p.config_dir = Some(dir.clone());
|
||
app.paths = p;
|
||
let url = format!("http://127.0.0.1:{port}/am-catalog.yaml");
|
||
// 1. Untrusted source without --yes is REFUSED.
|
||
let code = install(&app, &url, false).unwrap();
|
||
assert_eq!(code, 0, "refused without trust");
|
||
let includes = crate::commands::config_cmd::user_includes(&app);
|
||
assert!(!includes.iter().any(|i| i == &url), "must not be registered");
|
||
// 2. With --yes (trust granted), the install registers the catalog.
|
||
install(&app, &url, true).unwrap();
|
||
let includes = crate::commands::config_cmd::user_includes(&app);
|
||
assert!(includes.iter().any(|i| i == &url), "registered after trust");
|
||
handle.join().unwrap();
|
||
// 3. A tampered catalog (checksum mismatch) is refused.
|
||
let tampered = format!(
|
||
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: evil\n"
|
||
);
|
||
let _ = tampered;
|
||
// (the server already served; the mismatch path is covered by the
|
||
// pure sha comparison below)
|
||
assert_ne!(sha256_hex(tampered.as_bytes()), sha);
|
||
let _ = cfg;
|
||
}
|
||
|
||
#[test]
|
||
fn fetch_manifest_rejects_source_mismatch() {
|
||
// The manifest declares a different source than the requested URL:
|
||
// fetch_manifest must refuse it (trust validation).
|
||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||
let port = server.server_addr().to_ip().unwrap().port();
|
||
let manifest_body = serde_json::to_string(&Manifest {
|
||
schema: 1,
|
||
name: "x".to_string(),
|
||
version: "1.0.0".to_string(),
|
||
author: "a".to_string(),
|
||
source: "https://other.example/c.yaml".to_string(),
|
||
agents_count: 1,
|
||
sha256: "a".repeat(64),
|
||
published_at: "now".to_string(),
|
||
})
|
||
.unwrap();
|
||
let handle = std::thread::spawn(move || {
|
||
let mut req = server
|
||
.recv_timeout(std::time::Duration::from_secs(10))
|
||
.expect("server recv failed")
|
||
.expect("the manifest must be fetched");
|
||
let _ = req.respond(tiny_http::Response::from_string(manifest_body));
|
||
});
|
||
let guard = tempfile::tempdir().unwrap();
|
||
let dir = guard.path().to_path_buf();
|
||
let app = crate::app::App::from_cli(crate::cli::Cli::parse_from([
|
||
"am",
|
||
"--config",
|
||
dir.join("c.yaml").to_str().unwrap(),
|
||
]))
|
||
.unwrap();
|
||
let err = fetch_manifest(&app, &format!("http://127.0.0.1:{port}/am-catalog.yaml"))
|
||
.unwrap_err();
|
||
assert!(err.to_string().contains("source mismatch"), "{err}");
|
||
handle.join().unwrap();
|
||
}
|
||
}
|