#![deny(missing_docs)]
use std::path::{Path, PathBuf};
use futures::StreamExt;
use nexo_ext_registry::ExtEntry;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
pub mod error;
pub mod extract;
pub mod extract_contract;
pub mod extract_error;
pub mod trusted_keys;
pub mod verify;
pub mod verify_error;
pub use error::InstallError;
pub use extract::{
extract_verified_tarball, ExtractInput, ExtractLimits, ExtractedPlugin, MAX_ENTRIES,
MAX_ENTRY_BYTES, MAX_EXTRACTED_BYTES, MAX_TARBALL_BYTES,
};
pub use extract_contract::{ExtractContract, PluginExtractContract};
pub use extract_error::ExtractError;
pub use trusted_keys::{AuthorPolicy, TrustMode, TrustedKeysConfig};
pub use verify::{discover_cosign_binary, verify_plugin_signature, VerifiedSignature, VerifyInput};
pub use verify_error::VerifyError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoCoords {
pub owner: String,
pub repo: String,
pub tag: String,
}
#[deprecated(
since = "0.2.0",
note = "renamed to `RepoCoords`; the same coords serve plugins and personas now"
)]
pub type PluginCoords = RepoCoords;
impl RepoCoords {
pub fn parse(s: &str) -> Result<Self, InstallError> {
let (coords, tag) = match s.split_once('@') {
Some((c, t)) => (c, t.to_string()),
None => (s, "latest".to_string()),
};
let (owner, repo) = coords
.split_once('/')
.ok_or_else(|| InstallError::CoordsInvalid {
got: s.to_string(),
reason: "expected <owner>/<repo>[@<tag>]",
})?;
if owner.is_empty() || repo.is_empty() || tag.is_empty() {
return Err(InstallError::CoordsInvalid {
got: s.to_string(),
reason: "owner / repo / tag must not be empty",
});
}
for ch in owner.chars().chain(repo.chars()) {
if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.') {
return Err(InstallError::CoordsInvalid {
got: s.to_string(),
reason: "owner/repo may only contain [A-Za-z0-9._-]",
});
}
}
Ok(Self {
owner: owner.to_string(),
repo: repo.to_string(),
tag,
})
}
pub fn release_api_url(&self, api_base: &str) -> String {
if self.tag == "latest" {
format!(
"{}/repos/{}/{}/releases/latest",
api_base.trim_end_matches('/'),
self.owner,
self.repo
)
} else {
format!(
"{}/repos/{}/{}/releases/tags/{}",
api_base.trim_end_matches('/'),
self.owner,
self.repo,
self.tag
)
}
}
}
pub const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com";
#[derive(Debug, Clone, serde::Deserialize)]
struct ReleaseAsset {
name: String,
browser_download_url: String,
#[serde(default)]
size: u64,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct ReleaseResponse {
tag_name: String,
#[serde(default)]
assets: Vec<ReleaseAsset>,
}
#[derive(Debug, Clone)]
pub struct ResolvedInstall {
pub entry: ExtEntry,
pub download_index: usize,
pub sha256_url: String,
}
#[derive(Debug, Clone)]
pub struct InstalledTarball {
pub tarball_path: PathBuf,
pub entry: ExtEntry,
pub size_bytes: u64,
}
#[derive(Debug, Clone)]
pub struct ResolvedReleaseTyped<M> {
pub manifest: M,
pub coords: RepoCoords,
pub version: semver::Version,
pub target: String,
pub tarball_url: String,
pub tarball_size: u64,
pub manifest_url: String,
pub sha256_url: String,
pub signing: Option<nexo_ext_registry::ExtSigning>,
}
async fn fetch_release_raw(
client: &reqwest::Client,
coords: &RepoCoords,
api_base: &str,
) -> Result<ReleaseResponse, InstallError> {
let url = coords.release_api_url(api_base);
let response = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "nexo-ext-installer")
.send()
.await
.map_err(|e| InstallError::Http(format!("fetch release: {e}")))?;
if !response.status().is_success() {
return Err(InstallError::Http(format!(
"fetch release: HTTP {} for {}",
response.status(),
url
)));
}
let json = response
.json::<ReleaseResponse>()
.await
.map_err(|e| InstallError::Http(format!("decode release: {e}")))?;
Ok(json)
}
pub async fn resolve_release_with_contract<C: ExtractContract>(
contract: &C,
client: &reqwest::Client,
coords: &RepoCoords,
target: &str,
api_base: &str,
) -> Result<ResolvedReleaseTyped<C::Manifest>, InstallError> {
let release = fetch_release_raw(client, coords, api_base).await?;
let version_str = release.tag_name.trim_start_matches('v').to_string();
let version = semver::Version::parse(&version_str).map_err(|e| InstallError::ReleaseShape {
owner: coords.owner.clone(),
repo: coords.repo.clone(),
reason: format!(
"release tag `{}` does not parse as semver `vX.Y.Z`: {e}",
release.tag_name
),
})?;
let manifest_asset_name = contract.manifest_asset_name();
let manifest_asset = release
.assets
.iter()
.find(|a| a.name == manifest_asset_name)
.ok_or_else(|| InstallError::ReleaseShape {
owner: coords.owner.clone(),
repo: coords.repo.clone(),
reason: format!(
"release `{}` is missing required asset `{manifest_asset_name}`",
release.tag_name
),
})?;
let manifest_bytes = client
.get(&manifest_asset.browser_download_url)
.header("User-Agent", "nexo-ext-installer")
.send()
.await
.map_err(|e| InstallError::Http(format!("fetch manifest: {e}")))?
.bytes()
.await
.map_err(|e| InstallError::Http(format!("read manifest body: {e}")))?;
let manifest = contract.parse_manifest(&manifest_bytes, coords)?;
let pkg_id = contract.manifest_id(&manifest);
let per_target_name = format!("{pkg_id}-{version_str}-{target}.tar.gz");
let noarch_name = format!("{pkg_id}-{version_str}-noarch.tar.gz");
let (tarball_asset, tarball_name, matched_target) =
match release.assets.iter().find(|a| a.name == per_target_name) {
Some(a) => (a, per_target_name, target.to_string()),
None => match release.assets.iter().find(|a| a.name == noarch_name) {
Some(a) => (a, noarch_name, "noarch".to_string()),
None => {
let available: Vec<String> = release
.assets
.iter()
.filter(|a| a.name.ends_with(".tar.gz"))
.map(|a| a.name.clone())
.collect();
return Err(InstallError::TargetNotFound {
id: pkg_id.clone(),
version: version.clone(),
target: target.to_string(),
available,
});
}
},
};
let sha256_name = format!("{tarball_name}.sha256");
let sha256_asset = release
.assets
.iter()
.find(|a| a.name == sha256_name)
.ok_or_else(|| InstallError::ReleaseShape {
owner: coords.owner.clone(),
repo: coords.repo.clone(),
reason: format!(
"release `{}` is missing required asset `{sha256_name}` for tarball `{tarball_name}`",
release.tag_name
),
})?;
let sig_name = format!("{tarball_name}.sig");
let cert_name = format!("{tarball_name}.cert");
let signing = match (
release.assets.iter().find(|a| a.name == sig_name),
release.assets.iter().find(|a| a.name == cert_name),
) {
(Some(sig), Some(cert)) => Some(nexo_ext_registry::ExtSigning {
cosign_signature_url: sig.browser_download_url.clone(),
cosign_certificate_url: cert.browser_download_url.clone(),
}),
_ => None,
};
Ok(ResolvedReleaseTyped {
manifest,
coords: coords.clone(),
version,
target: matched_target,
tarball_url: tarball_asset.browser_download_url.clone(),
tarball_size: tarball_asset.size,
manifest_url: manifest_asset.browser_download_url.clone(),
sha256_url: sha256_asset.browser_download_url.clone(),
signing,
})
}
pub async fn resolve_release(
client: &reqwest::Client,
coords: &RepoCoords,
target: &str,
api_base: &str,
) -> Result<ResolvedInstall, InstallError> {
let resolved =
resolve_release_with_contract(&PluginExtractContract, client, coords, target, api_base)
.await?;
let entry = ExtEntry {
id: resolved.manifest.plugin.id.clone(),
version: resolved.version,
name: resolved.manifest.plugin.name.clone(),
description: resolved.manifest.plugin.description.clone(),
homepage: format!("https://github.com/{}/{}", coords.owner, coords.repo),
tier: nexo_ext_registry::ExtTier::Community,
min_nexo_version: resolved.manifest.plugin.min_nexo_version.clone(),
downloads: vec![nexo_ext_registry::ExtDownload {
target: target.to_string(),
url: resolved.tarball_url,
sha256: "from_sha256_asset_at_download".to_string(),
size_bytes: resolved.tarball_size,
}],
manifest_url: resolved.manifest_url,
signing: resolved.signing,
authors: Vec::new(),
};
Ok(ResolvedInstall {
entry,
download_index: 0,
sha256_url: resolved.sha256_url,
})
}
pub async fn download_and_verify_url(
client: &reqwest::Client,
tarball_url: &str,
sha256_url: &str,
pkg_id_for_errors: &str,
dest_path: &Path,
) -> Result<u64, InstallError> {
if let Some(parent) = dest_path.parent() {
if !parent.as_os_str().is_empty() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| InstallError::Io(format!("mkdir parent: {e}")))?;
}
}
let expected_sha = client
.get(sha256_url)
.header("User-Agent", "nexo-ext-installer")
.send()
.await
.map_err(|e| InstallError::Http(format!("fetch sha256: {e}")))?
.text()
.await
.map_err(|e| InstallError::Http(format!("read sha256 body: {e}")))?
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
if expected_sha.len() != 64 || !expected_sha.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(InstallError::Sha256Invalid {
id: pkg_id_for_errors.to_string(),
got: expected_sha,
});
}
let response = client
.get(tarball_url)
.header("User-Agent", "nexo-ext-installer")
.send()
.await
.map_err(|e| InstallError::Http(format!("fetch tarball: {e}")))?;
if !response.status().is_success() {
return Err(InstallError::Http(format!(
"fetch tarball: HTTP {}",
response.status()
)));
}
let mut hasher = Sha256::new();
let mut size: u64 = 0;
let mut file = tokio::fs::File::create(dest_path)
.await
.map_err(|e| InstallError::Io(format!("create dest: {e}")))?;
let mut stream = response.bytes_stream();
while let Some(chunk_res) = stream.next().await {
let chunk = match chunk_res {
Ok(c) => c,
Err(e) => {
drop(file);
let _ = tokio::fs::remove_file(dest_path).await;
return Err(InstallError::Http(format!("download chunk: {e}")));
}
};
hasher.update(&chunk);
size += chunk.len() as u64;
if let Err(e) = file.write_all(&chunk).await {
drop(file);
let _ = tokio::fs::remove_file(dest_path).await;
return Err(InstallError::Io(format!("write tarball: {e}")));
}
}
file.flush()
.await
.map_err(|e| InstallError::Io(format!("flush tarball: {e}")))?;
drop(file);
let computed = hex::encode(hasher.finalize());
if computed != expected_sha {
let _ = tokio::fs::remove_file(dest_path).await;
return Err(InstallError::Sha256Mismatch {
id: pkg_id_for_errors.to_string(),
expected: expected_sha,
got: computed,
});
}
Ok(size)
}
pub async fn download_and_verify(
client: &reqwest::Client,
resolved: &ResolvedInstall,
dest_path: &Path,
) -> Result<InstalledTarball, InstallError> {
let download = &resolved.entry.downloads[resolved.download_index];
let size = download_and_verify_url(
client,
&download.url,
&resolved.sha256_url,
&resolved.entry.id,
dest_path,
)
.await?;
Ok(InstalledTarball {
tarball_path: dest_path.to_path_buf(),
entry: resolved.entry.clone(),
size_bytes: size,
})
}
pub async fn install_plugin(
client: &reqwest::Client,
coords: &str,
target: &str,
dest_path: &Path,
api_base: &str,
) -> Result<InstalledTarball, InstallError> {
let coords = RepoCoords::parse(coords)?;
let resolved = resolve_release(client, &coords, target, api_base).await?;
download_and_verify(client, &resolved, dest_path).await
}
pub fn current_target_triple() -> String {
if let Ok(t) = std::env::var("NEXO_INSTALL_TARGET") {
if !t.is_empty() {
return t;
}
}
if cfg!(all(target_arch = "x86_64", target_os = "linux")) {
"x86_64-unknown-linux-gnu".to_string()
} else if cfg!(all(target_arch = "aarch64", target_os = "linux")) {
"aarch64-unknown-linux-gnu".to_string()
} else if cfg!(all(target_arch = "x86_64", target_os = "macos")) {
"x86_64-apple-darwin".to_string()
} else if cfg!(all(target_arch = "aarch64", target_os = "macos")) {
"aarch64-apple-darwin".to_string()
} else {
"unknown-target".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn parse_coords_default_tag_latest() {
let c = RepoCoords::parse("alice/plugin-x").unwrap();
assert_eq!(c.owner, "alice");
assert_eq!(c.repo, "plugin-x");
assert_eq!(c.tag, "latest");
}
#[test]
fn parse_coords_with_tag() {
let c = RepoCoords::parse("alice/plugin-x@v0.2.0").unwrap();
assert_eq!(c.owner, "alice");
assert_eq!(c.repo, "plugin-x");
assert_eq!(c.tag, "v0.2.0");
}
#[test]
fn parse_coords_rejects_bad_shapes() {
assert!(RepoCoords::parse("no-slash").is_err());
assert!(RepoCoords::parse("/empty-owner").is_err());
assert!(RepoCoords::parse("alice/").is_err());
assert!(RepoCoords::parse("alice/plugin@").is_err());
assert!(RepoCoords::parse("alice/plugin space@v1").is_err());
}
#[test]
fn release_api_url_branches_on_tag() {
let c = RepoCoords::parse("alice/x@v0.2.0").unwrap();
assert_eq!(
c.release_api_url("https://api.github.com"),
"https://api.github.com/repos/alice/x/releases/tags/v0.2.0"
);
let c2 = RepoCoords::parse("alice/x").unwrap();
assert_eq!(
c2.release_api_url("https://api.github.com"),
"https://api.github.com/repos/alice/x/releases/latest"
);
}
fn manifest_toml(id: &str, version: &str) -> String {
format!(
r#"[plugin]
id = "{id}"
version = "{version}"
name = "Slack Channel"
description = "Slack bot integration"
min_nexo_version = ">=0.0.1"
[plugin.requires]
nexo_capabilities = ["broker"]
"#
)
}
#[tokio::test]
async fn install_round_trip_with_real_sha() {
let server = MockServer::start().await;
let manifest_body = manifest_toml("slack", "0.2.0");
let tarball_payload = b"fake plugin tarball bytes";
let mut hasher = Sha256::new();
hasher.update(tarball_payload);
let tarball_sha = hex::encode(hasher.finalize());
let sha_body = format!("{tarball_sha}\n");
let manifest_url = format!("{}/manifest", server.uri());
let tarball_url = format!("{}/tarball", server.uri());
let sha_url = format!("{}/sha256", server.uri());
let release = json!({
"tag_name": "v0.2.0",
"assets": [
{
"name": "nexo-plugin.toml",
"browser_download_url": manifest_url,
"size": manifest_body.len()
},
{
"name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz",
"browser_download_url": tarball_url,
"size": tarball_payload.len()
},
{
"name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz.sha256",
"browser_download_url": sha_url,
"size": sha_body.len()
}
]
});
Mock::given(method("GET"))
.and(path("/repos/alice/slack-plugin/releases/tags/v0.2.0"))
.and(header("Accept", "application/vnd.github+json"))
.respond_with(ResponseTemplate::new(200).set_body_json(release))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/manifest"))
.respond_with(ResponseTemplate::new(200).set_body_string(manifest_body.clone()))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/sha256"))
.respond_with(ResponseTemplate::new(200).set_body_string(sha_body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/tarball"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(tarball_payload.as_slice()))
.mount(&server)
.await;
let coords = RepoCoords::parse("alice/slack-plugin@v0.2.0").unwrap();
let client = reqwest::Client::new();
let resolved = resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri())
.await
.expect("resolve");
assert_eq!(resolved.entry.id, "slack");
assert_eq!(resolved.entry.version.to_string(), "0.2.0");
assert_eq!(resolved.entry.tier, nexo_ext_registry::ExtTier::Community);
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("slack-0.2.0.tar.gz");
let installed = download_and_verify(&client, &resolved, &dest)
.await
.expect("download");
assert_eq!(installed.tarball_path, dest);
assert_eq!(installed.size_bytes as usize, tarball_payload.len());
assert!(dest.exists());
}
#[tokio::test]
async fn rejects_release_missing_manifest_asset() {
let server = MockServer::start().await;
let release = json!({
"tag_name": "v0.2.0",
"assets": [
{
"name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz",
"browser_download_url": "https://example.com/tar",
"size": 100
}
]
});
Mock::given(method("GET"))
.and(path("/repos/alice/x/releases/tags/v0.2.0"))
.respond_with(ResponseTemplate::new(200).set_body_json(release))
.mount(&server)
.await;
let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
let client = reqwest::Client::new();
match resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri()).await {
Err(InstallError::ReleaseShape { reason, .. }) => {
assert!(reason.contains("nexo-plugin.toml"));
}
other => panic!("expected ReleaseShape error, got {other:?}"),
}
}
#[tokio::test]
async fn rejects_release_missing_target_tarball() {
let server = MockServer::start().await;
let manifest_body = manifest_toml("slack", "0.2.0");
let manifest_url = format!("{}/manifest", server.uri());
let release = json!({
"tag_name": "v0.2.0",
"assets": [
{
"name": "nexo-plugin.toml",
"browser_download_url": manifest_url,
"size": manifest_body.len()
},
{
"name": "slack-0.2.0-aarch64-apple-darwin.tar.gz",
"browser_download_url": "https://example.com/tar",
"size": 100
}
]
});
Mock::given(method("GET"))
.and(path("/repos/alice/x/releases/tags/v0.2.0"))
.respond_with(ResponseTemplate::new(200).set_body_json(release))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/manifest"))
.respond_with(ResponseTemplate::new(200).set_body_string(manifest_body))
.mount(&server)
.await;
let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
let client = reqwest::Client::new();
match resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri()).await {
Err(InstallError::TargetNotFound { available, .. }) => {
assert_eq!(
available,
vec!["slack-0.2.0-aarch64-apple-darwin.tar.gz".to_string()]
);
}
other => panic!("expected TargetNotFound, got {other:?}"),
}
}
#[tokio::test]
async fn resolve_release_falls_back_to_noarch_when_per_target_absent() {
let server = MockServer::start().await;
let manifest_body = manifest_toml("slack", "0.2.0");
let manifest_url = format!("{}/manifest", server.uri());
let release = json!({
"tag_name": "v0.2.0",
"assets": [
{"name": "nexo-plugin.toml", "browser_download_url": manifest_url, "size": manifest_body.len()},
{"name": "slack-0.2.0-noarch.tar.gz", "browser_download_url": "https://example.com/tar", "size": 100},
{"name": "slack-0.2.0-noarch.tar.gz.sha256", "browser_download_url": "https://example.com/sha", "size": 64}
]
});
Mock::given(method("GET"))
.and(path("/repos/alice/x/releases/tags/v0.2.0"))
.respond_with(ResponseTemplate::new(200).set_body_json(release))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/manifest"))
.respond_with(ResponseTemplate::new(200).set_body_string(manifest_body))
.mount(&server)
.await;
let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
let client = reqwest::Client::new();
let resolved = resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri())
.await
.expect("noarch fallback");
assert_eq!(
resolved.entry.downloads[0].url.as_str(),
"https://example.com/tar"
);
assert!(resolved.sha256_url.contains("/sha"));
}
#[tokio::test]
async fn resolve_release_prefers_per_target_over_noarch() {
let server = MockServer::start().await;
let manifest_body = manifest_toml("slack", "0.2.0");
let manifest_url = format!("{}/manifest", server.uri());
let release = json!({
"tag_name": "v0.2.0",
"assets": [
{"name": "nexo-plugin.toml", "browser_download_url": manifest_url, "size": manifest_body.len()},
{"name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz", "browser_download_url": "https://example.com/per-target", "size": 100},
{"name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz.sha256", "browser_download_url": "https://example.com/per-sha", "size": 64},
{"name": "slack-0.2.0-noarch.tar.gz", "browser_download_url": "https://example.com/noarch", "size": 100},
{"name": "slack-0.2.0-noarch.tar.gz.sha256", "browser_download_url": "https://example.com/noarch-sha", "size": 64}
]
});
Mock::given(method("GET"))
.and(path("/repos/alice/x/releases/tags/v0.2.0"))
.respond_with(ResponseTemplate::new(200).set_body_json(release))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/manifest"))
.respond_with(ResponseTemplate::new(200).set_body_string(manifest_body))
.mount(&server)
.await;
let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
let client = reqwest::Client::new();
let resolved = resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri())
.await
.expect("per-target preferred");
assert_eq!(
resolved.entry.downloads[0].url.as_str(),
"https://example.com/per-target",
"per-target tarball must win when both present"
);
}
#[tokio::test]
async fn detects_sha256_mismatch_and_cleans_up() {
let server = MockServer::start().await;
let manifest_body = manifest_toml("slack", "0.2.0");
let tarball_payload = b"actual bytes here";
let advertised_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n";
let manifest_url = format!("{}/manifest", server.uri());
let tarball_url = format!("{}/tarball", server.uri());
let sha_url = format!("{}/sha256", server.uri());
let release = json!({
"tag_name": "v0.2.0",
"assets": [
{"name": "nexo-plugin.toml", "browser_download_url": manifest_url, "size": manifest_body.len()},
{"name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz", "browser_download_url": tarball_url, "size": tarball_payload.len()},
{"name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz.sha256", "browser_download_url": sha_url, "size": advertised_sha.len()}
]
});
Mock::given(method("GET"))
.and(path("/repos/alice/x/releases/tags/v0.2.0"))
.respond_with(ResponseTemplate::new(200).set_body_json(release))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/manifest"))
.respond_with(ResponseTemplate::new(200).set_body_string(manifest_body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/sha256"))
.respond_with(ResponseTemplate::new(200).set_body_string(advertised_sha))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/tarball"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(tarball_payload.as_slice()))
.mount(&server)
.await;
let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
let client = reqwest::Client::new();
let resolved = resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri())
.await
.expect("resolve");
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("tampered.tar.gz");
match download_and_verify(&client, &resolved, &dest).await {
Err(InstallError::Sha256Mismatch { id, .. }) => assert_eq!(id, "slack"),
other => panic!("expected Sha256Mismatch, got {other:?}"),
}
assert!(!dest.exists(), "partial file must be removed on mismatch");
}
#[derive(Debug, serde::Deserialize)]
struct TestPersonaManifest {
id: String,
#[allow(dead_code)]
name: String,
}
#[derive(Debug, Default, Clone, Copy)]
struct TestPersonaContract;
impl ExtractContract for TestPersonaContract {
type Manifest = TestPersonaManifest;
fn manifest_asset_name(&self) -> &'static str {
"test-persona.toml"
}
fn parse_manifest(
&self,
bytes: &[u8],
coords: &RepoCoords,
) -> Result<Self::Manifest, InstallError> {
let text = std::str::from_utf8(bytes).map_err(|e| InstallError::ReleaseShape {
owner: coords.owner.clone(),
repo: coords.repo.clone(),
reason: format!("test-persona manifest is not valid UTF-8: {e}"),
})?;
toml::from_str::<Self::Manifest>(text).map_err(|e| InstallError::ReleaseShape {
owner: coords.owner.clone(),
repo: coords.repo.clone(),
reason: format!("test-persona manifest parse failed: {e}"),
})
}
fn manifest_id(&self, m: &Self::Manifest) -> String {
m.id.clone()
}
}
#[tokio::test]
async fn resolve_release_with_contract_serves_custom_manifest_filename() {
let server = MockServer::start().await;
let manifest_body = r#"id = "cody"
name = "Cody Persona"
"#;
let manifest_url = format!("{}/persona-toml", server.uri());
let tarball_url = format!("{}/persona-tar", server.uri());
let sha_url = format!("{}/persona-sha", server.uri());
let release = json!({
"tag_name": "v0.2.0",
"assets": [
{"name": "test-persona.toml", "browser_download_url": manifest_url, "size": manifest_body.len()},
{"name": "cody-0.2.0-noarch.tar.gz", "browser_download_url": tarball_url, "size": 42},
{"name": "cody-0.2.0-noarch.tar.gz.sha256", "browser_download_url": sha_url, "size": 64}
]
});
Mock::given(method("GET"))
.and(path(
"/repos/lordmacu/nexo-persona-cody/releases/tags/v0.2.0",
))
.respond_with(ResponseTemplate::new(200).set_body_json(release))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/persona-toml"))
.respond_with(ResponseTemplate::new(200).set_body_string(manifest_body))
.mount(&server)
.await;
let coords = RepoCoords::parse("lordmacu/nexo-persona-cody@v0.2.0").unwrap();
let client = reqwest::Client::new();
let resolved = resolve_release_with_contract(
&TestPersonaContract,
&client,
&coords,
"x86_64-unknown-linux-gnu",
&server.uri(),
)
.await
.expect("contract resolve");
assert_eq!(resolved.manifest.id, "cody");
assert_eq!(resolved.version.to_string(), "0.2.0");
assert_eq!(
resolved.target, "noarch",
"noarch fallback wins when per-target absent"
);
assert_eq!(resolved.tarball_url.as_str(), tarball_url);
assert_eq!(resolved.sha256_url.as_str(), sha_url);
assert!(resolved.signing.is_none(), "no cosign assets in fixture");
}
#[tokio::test]
async fn resolve_release_with_contract_errors_when_contract_manifest_absent() {
let server = MockServer::start().await;
let release = json!({
"tag_name": "v0.2.0",
"assets": [
{"name": "nexo-plugin.toml", "browser_download_url": "https://example.com/m", "size": 100}
]
});
Mock::given(method("GET"))
.and(path("/repos/alice/x/releases/tags/v0.2.0"))
.respond_with(ResponseTemplate::new(200).set_body_json(release))
.mount(&server)
.await;
let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
let client = reqwest::Client::new();
match resolve_release_with_contract(
&TestPersonaContract,
&client,
&coords,
"x86_64-unknown-linux-gnu",
&server.uri(),
)
.await
{
Err(InstallError::ReleaseShape { reason, .. }) => {
assert!(
reason.contains("test-persona.toml"),
"error must mention contract-supplied filename, got: {reason}"
);
assert!(
!reason.contains("nexo-plugin.toml"),
"error must NOT leak the plugin filename, got: {reason}"
);
}
other => panic!("expected ReleaseShape error, got {other:?}"),
}
}
}