feat: v1.0 — am registry : registre communautaire de catalogues (publication + recherche sur Gitea), manifeste source/version/auteur/sha256, installation avec validation du checksum et décision de confiance explicite (closes #77)
- 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
This commit is contained in:
+484
@@ -0,0 +1,484 @@
|
||||
//! 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user