Skip to main content

nexo_ext_installer/
lib.rs

1//! Decentralized GitHub Releases install — building block for
2//! `nexo plugin install <owner>/<repo>@<tag>`.
3//!
4//! Architecture: there is NO central catalog. Each plugin
5//! author publishes their plugin as a GitHub Release on their
6//! own repo, following the asset naming convention documented
7//! in `nexo-plugin-contract.md`. The install CLI hits the
8//! GitHub Releases API directly to resolve a coords string into
9//! a verified tarball.
10//!
11//! What this crate does:
12//! 1. Parse `<owner>/<repo>@<tag>` coords
13//! 2. Fetch the GitHub release JSON (or `/releases/latest`)
14//! 3. Parse the release into an [`nexo_ext_registry::ExtEntry`]
15//!    using the asset naming convention
16//! 4. Download the tarball matching the daemon's target
17//! 5. Stream-verify the sha256 (read from the `.sha256` asset)
18//!
19//! Cosign signature verification lives in [`verify`], tarball
20//! extraction in [`extract`]; the CLI wires them together.
21//!
22//! # References
23//!
24//! - Internal: `crates/ext-registry/` — entry types.
25//! - GitHub Releases API:
26//!   `https://docs.github.com/en/rest/releases/releases#get-a-release-by-tag-name`
27//! - Real-world: `cargo binstall` + `gh extension install` —
28//!   per-repo binary install via GitHub Releases.
29
30#![deny(missing_docs)]
31
32use std::path::{Path, PathBuf};
33
34use futures::StreamExt;
35use nexo_ext_registry::ExtEntry;
36use sha2::{Digest, Sha256};
37use tokio::io::AsyncWriteExt;
38
39pub mod error;
40pub mod extract;
41pub mod extract_contract;
42pub mod extract_error;
43pub mod trusted_keys;
44pub mod verify;
45pub mod verify_error;
46
47pub use error::InstallError;
48pub use extract::{
49    extract_verified_tarball, ExtractInput, ExtractLimits, ExtractedPlugin, MAX_ENTRIES,
50    MAX_ENTRY_BYTES, MAX_EXTRACTED_BYTES, MAX_TARBALL_BYTES,
51};
52pub use extract_contract::{ExtractContract, PluginExtractContract};
53pub use extract_error::ExtractError;
54pub use trusted_keys::{AuthorPolicy, TrustMode, TrustedKeysConfig};
55pub use verify::{discover_cosign_binary, verify_plugin_signature, VerifiedSignature, VerifyInput};
56pub use verify_error::VerifyError;
57
58/// Parsed `<owner>/<repo>@<tag>` coordinates. `tag` defaults to
59/// `latest` when the user omits it.
60///
61/// Renamed from `PluginCoords` once the same struct started
62/// serving non-plugin artifacts (persona packs). The legacy name
63/// is kept as a deprecated type alias below for backward compatibility.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct RepoCoords {
66    /// GitHub repo owner (user or org).
67    pub owner: String,
68    /// GitHub repo name.
69    pub repo: String,
70    /// Release tag (e.g. `v0.2.0`) or the literal `"latest"`
71    /// to resolve to GitHub's `/releases/latest` endpoint.
72    pub tag: String,
73}
74
75/// Legacy alias preserved so existing callers
76/// (`src/plugin_install.rs`, `src/plugin_admin.rs`) keep
77/// compiling while downstream migrations land. Prefer
78/// [`RepoCoords`] in new code.
79#[deprecated(
80    since = "0.2.0",
81    note = "renamed to `RepoCoords`; the same coords serve plugins and personas now"
82)]
83pub type PluginCoords = RepoCoords;
84
85impl RepoCoords {
86    /// Parse `<owner>/<repo>` or `<owner>/<repo>@<tag>`. Tag
87    /// defaults to `"latest"` when `@<tag>` is absent.
88    pub fn parse(s: &str) -> Result<Self, InstallError> {
89        let (coords, tag) = match s.split_once('@') {
90            Some((c, t)) => (c, t.to_string()),
91            None => (s, "latest".to_string()),
92        };
93        let (owner, repo) = coords
94            .split_once('/')
95            .ok_or_else(|| InstallError::CoordsInvalid {
96                got: s.to_string(),
97                reason: "expected <owner>/<repo>[@<tag>]",
98            })?;
99        if owner.is_empty() || repo.is_empty() || tag.is_empty() {
100            return Err(InstallError::CoordsInvalid {
101                got: s.to_string(),
102                reason: "owner / repo / tag must not be empty",
103            });
104        }
105        // GitHub allows alphanumerics + `-` + `_` + `.` in
106        // owner/repo names. We don't replicate the full GitHub
107        // validator; reject the obviously bad chars (whitespace,
108        // url-meaningful chars) so a typo fails loud.
109        for ch in owner.chars().chain(repo.chars()) {
110            if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.') {
111                return Err(InstallError::CoordsInvalid {
112                    got: s.to_string(),
113                    reason: "owner/repo may only contain [A-Za-z0-9._-]",
114                });
115            }
116        }
117        Ok(Self {
118            owner: owner.to_string(),
119            repo: repo.to_string(),
120            tag,
121        })
122    }
123
124    /// GitHub Releases API URL for this coords. When tag is
125    /// `"latest"`, hits `/releases/latest`; otherwise hits
126    /// `/releases/tags/<tag>`. The base URL is configurable for
127    /// tests (default `https://api.github.com`).
128    pub fn release_api_url(&self, api_base: &str) -> String {
129        if self.tag == "latest" {
130            format!(
131                "{}/repos/{}/{}/releases/latest",
132                api_base.trim_end_matches('/'),
133                self.owner,
134                self.repo
135            )
136        } else {
137            format!(
138                "{}/repos/{}/{}/releases/tags/{}",
139                api_base.trim_end_matches('/'),
140                self.owner,
141                self.repo,
142                self.tag
143            )
144        }
145    }
146}
147
148/// Default GitHub API base URL. Override via the
149/// `NEXO_GITHUB_API_BASE` env or in tests with a wiremock URL.
150pub const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com";
151
152/// One asset entry from the GitHub Releases API response.
153/// Internal-only; the parser maps these into `ExtDownload` /
154/// `ExtSigning`.
155#[derive(Debug, Clone, serde::Deserialize)]
156struct ReleaseAsset {
157    name: String,
158    browser_download_url: String,
159    #[serde(default)]
160    size: u64,
161}
162
163/// Top-level shape of a GitHub Releases API response. Only the
164/// fields we consume.
165#[derive(Debug, Clone, serde::Deserialize)]
166struct ReleaseResponse {
167    tag_name: String,
168    #[serde(default)]
169    assets: Vec<ReleaseAsset>,
170}
171
172/// Successful resolution of a release into an installable
173/// entry. Carries enough info to call [`download_and_verify`].
174#[derive(Debug, Clone)]
175pub struct ResolvedInstall {
176    /// Plugin entry built from the GitHub release.
177    pub entry: ExtEntry,
178    /// Index of the matching download in `entry.downloads`.
179    pub download_index: usize,
180    /// URL of the per-tarball `.sha256` asset (single line of
181    /// hex). Used by `download_and_verify` to obtain the
182    /// expected digest at install time.
183    pub sha256_url: String,
184}
185
186/// Successful install — verified tarball on disk.
187#[derive(Debug, Clone)]
188pub struct InstalledTarball {
189    /// On-disk path of the tarball.
190    pub tarball_path: PathBuf,
191    /// The plugin entry that was installed.
192    pub entry: ExtEntry,
193    /// Bytes downloaded.
194    pub size_bytes: u64,
195}
196
197/// Generic resolve result carrying the typed manifest produced
198/// by an [`ExtractContract`]. Contracts are free to attach any
199/// in-process meaning to `manifest`; the resolver only stores
200/// it. URL/size/signing fields are pre-resolved so the caller
201/// can call [`download_and_verify_url`] without re-querying the
202/// release JSON.
203///
204/// Lets persona-installer share the resolve+download pipeline
205/// without duplicating the GitHub Releases plumbing.
206#[derive(Debug, Clone)]
207pub struct ResolvedReleaseTyped<M> {
208    /// The typed manifest produced by [`ExtractContract::parse_manifest`].
209    pub manifest: M,
210    /// Coords echoed back so callers building entries don't have
211    /// to thread them separately.
212    pub coords: RepoCoords,
213    /// Semver parsed from the release `tag_name` (`v` stripped).
214    pub version: semver::Version,
215    /// Target triple actually matched. May differ from the
216    /// caller's request when the resolver fell back to the
217    /// `noarch` tarball.
218    pub target: String,
219    /// URL of the matched tarball asset.
220    pub tarball_url: String,
221    /// Size of the matched tarball asset in bytes (from the
222    /// release JSON; not authoritative — verify against the
223    /// downloaded body).
224    pub tarball_size: u64,
225    /// URL of the manifest asset (re-exposed so callers can
226    /// echo it into their entry types).
227    pub manifest_url: String,
228    /// URL of the per-tarball `.sha256` asset (single line of
229    /// hex). Used by [`download_and_verify_url`] to obtain the
230    /// expected digest at install time.
231    pub sha256_url: String,
232    /// Optional cosign material (signature verification enforces it).
233    pub signing: Option<nexo_ext_registry::ExtSigning>,
234}
235
236/// Fetch a release JSON from GitHub. Wraps the raw JSON in our
237/// `ReleaseResponse` for the resolver to consume.
238async fn fetch_release_raw(
239    client: &reqwest::Client,
240    coords: &RepoCoords,
241    api_base: &str,
242) -> Result<ReleaseResponse, InstallError> {
243    let url = coords.release_api_url(api_base);
244    let response = client
245        .get(&url)
246        .header("Accept", "application/vnd.github+json")
247        .header("User-Agent", "nexo-ext-installer")
248        .send()
249        .await
250        .map_err(|e| InstallError::Http(format!("fetch release: {e}")))?;
251    if !response.status().is_success() {
252        return Err(InstallError::Http(format!(
253            "fetch release: HTTP {} for {}",
254            response.status(),
255            url
256        )));
257    }
258    let json = response
259        .json::<ReleaseResponse>()
260        .await
261        .map_err(|e| InstallError::Http(format!("decode release: {e}")))?;
262    Ok(json)
263}
264
265/// Resolve a release into a downloadable entry parameterized
266/// by an [`ExtractContract`]. The contract decides which
267/// manifest asset to fetch and how to parse it; everything
268/// else (semver from tag, tarball naming with `<id>-<version>-
269/// <target>.tar.gz` shape, `noarch` fallback, sha256 sibling
270/// lookup, cosign material) is shared across all contracts.
271///
272/// Steps:
273/// 1. Fetch the release JSON.
274/// 2. Parse semver from `tag_name` (strip leading `v`).
275/// 3. Locate the manifest asset by `contract.manifest_asset_name()`.
276/// 4. Download + parse the manifest via `contract.parse_manifest()`.
277/// 5. Extract id via `contract.manifest_id()` for tarball naming.
278/// 6. Find the tarball asset for `target`, fall back to `noarch`.
279/// 7. Find the matching `.sha256` asset.
280/// 8. Locate optional cosign material.
281pub async fn resolve_release_with_contract<C: ExtractContract>(
282    contract: &C,
283    client: &reqwest::Client,
284    coords: &RepoCoords,
285    target: &str,
286    api_base: &str,
287) -> Result<ResolvedReleaseTyped<C::Manifest>, InstallError> {
288    let release = fetch_release_raw(client, coords, api_base).await?;
289    let version_str = release.tag_name.trim_start_matches('v').to_string();
290    let version = semver::Version::parse(&version_str).map_err(|e| InstallError::ReleaseShape {
291        owner: coords.owner.clone(),
292        repo: coords.repo.clone(),
293        reason: format!(
294            "release tag `{}` does not parse as semver `vX.Y.Z`: {e}",
295            release.tag_name
296        ),
297    })?;
298
299    // Locate the manifest asset (filename declared by contract).
300    let manifest_asset_name = contract.manifest_asset_name();
301    let manifest_asset = release
302        .assets
303        .iter()
304        .find(|a| a.name == manifest_asset_name)
305        .ok_or_else(|| InstallError::ReleaseShape {
306            owner: coords.owner.clone(),
307            repo: coords.repo.clone(),
308            reason: format!(
309                "release `{}` is missing required asset `{manifest_asset_name}`",
310                release.tag_name
311            ),
312        })?;
313
314    // Fetch manifest bytes, hand off to contract for typed parse.
315    let manifest_bytes = client
316        .get(&manifest_asset.browser_download_url)
317        .header("User-Agent", "nexo-ext-installer")
318        .send()
319        .await
320        .map_err(|e| InstallError::Http(format!("fetch manifest: {e}")))?
321        .bytes()
322        .await
323        .map_err(|e| InstallError::Http(format!("read manifest body: {e}")))?;
324    let manifest = contract.parse_manifest(&manifest_bytes, coords)?;
325    let pkg_id = contract.manifest_id(&manifest);
326
327    // Find the tarball asset for the requested target. `noarch`
328    // acts as a fallback target name so portable plugins
329    // (Python, TypeScript) can publish a single asset that all
330    // daemons accept.
331    let per_target_name = format!("{pkg_id}-{version_str}-{target}.tar.gz");
332    let noarch_name = format!("{pkg_id}-{version_str}-noarch.tar.gz");
333    let (tarball_asset, tarball_name, matched_target) =
334        match release.assets.iter().find(|a| a.name == per_target_name) {
335            Some(a) => (a, per_target_name, target.to_string()),
336            None => match release.assets.iter().find(|a| a.name == noarch_name) {
337                Some(a) => (a, noarch_name, "noarch".to_string()),
338                None => {
339                    let available: Vec<String> = release
340                        .assets
341                        .iter()
342                        .filter(|a| a.name.ends_with(".tar.gz"))
343                        .map(|a| a.name.clone())
344                        .collect();
345                    return Err(InstallError::TargetNotFound {
346                        id: pkg_id.clone(),
347                        version: version.clone(),
348                        target: target.to_string(),
349                        available,
350                    });
351                }
352            },
353        };
354
355    // Find the matching .sha256 asset.
356    let sha256_name = format!("{tarball_name}.sha256");
357    let sha256_asset = release
358        .assets
359        .iter()
360        .find(|a| a.name == sha256_name)
361        .ok_or_else(|| InstallError::ReleaseShape {
362            owner: coords.owner.clone(),
363            repo: coords.repo.clone(),
364            reason: format!(
365                "release `{}` is missing required asset `{sha256_name}` for tarball `{tarball_name}`",
366                release.tag_name
367            ),
368        })?;
369
370    // Locate optional cosign material (signature verification enforces it).
371    let sig_name = format!("{tarball_name}.sig");
372    let cert_name = format!("{tarball_name}.cert");
373    let signing = match (
374        release.assets.iter().find(|a| a.name == sig_name),
375        release.assets.iter().find(|a| a.name == cert_name),
376    ) {
377        (Some(sig), Some(cert)) => Some(nexo_ext_registry::ExtSigning {
378            cosign_signature_url: sig.browser_download_url.clone(),
379            cosign_certificate_url: cert.browser_download_url.clone(),
380        }),
381        _ => None,
382    };
383
384    Ok(ResolvedReleaseTyped {
385        manifest,
386        coords: coords.clone(),
387        version,
388        target: matched_target,
389        tarball_url: tarball_asset.browser_download_url.clone(),
390        tarball_size: tarball_asset.size,
391        manifest_url: manifest_asset.browser_download_url.clone(),
392        sha256_url: sha256_asset.browser_download_url.clone(),
393        signing,
394    })
395}
396
397/// Resolve a plugin's release into a downloadable entry. Thin
398/// adapter over [`resolve_release_with_contract`] using
399/// [`PluginExtractContract`]; preserved for backward compat
400/// with all existing callers (`src/plugin_install.rs`,
401/// `src/plugin_admin.rs`).
402///
403/// Steps:
404/// 1. Fetch the release JSON.
405/// 2. Locate the `nexo-plugin.toml` asset.
406/// 3. Download + parse the manifest to learn `plugin.id`.
407/// 4. Find the tarball asset matching the requested `target`
408///    using the naming convention
409///    `<id>-<version>-<target>.tar.gz`.
410/// 5. Find the matching `.sha256` asset.
411/// 6. Build an `ExtEntry` with one download.
412pub async fn resolve_release(
413    client: &reqwest::Client,
414    coords: &RepoCoords,
415    target: &str,
416    api_base: &str,
417) -> Result<ResolvedInstall, InstallError> {
418    let resolved =
419        resolve_release_with_contract(&PluginExtractContract, client, coords, target, api_base)
420            .await?;
421
422    // Per the decentralized model, every release defaults
423    // to `tier = community`. Operator's trusted_keys.toml decides
424    // which authors' cosign keys count as "verified" at install
425    // time.
426    //
427    // `downloads[0].target` echoes the *requested* target rather
428    // than the matched asset's flavor (`noarch` vs per-target).
429    // Preserves pre-refactor behavior; the typed `resolved.target`
430    // field exposes the truth to contract-aware callers.
431    let entry = ExtEntry {
432        id: resolved.manifest.plugin.id.clone(),
433        version: resolved.version,
434        name: resolved.manifest.plugin.name.clone(),
435        description: resolved.manifest.plugin.description.clone(),
436        homepage: format!("https://github.com/{}/{}", coords.owner, coords.repo),
437        tier: nexo_ext_registry::ExtTier::Community,
438        min_nexo_version: resolved.manifest.plugin.min_nexo_version.clone(),
439        downloads: vec![nexo_ext_registry::ExtDownload {
440            target: target.to_string(),
441            url: resolved.tarball_url,
442            // Placeholder: actual sha256 hex is read from the
443            // `.sha256` asset at download time. Putting the
444            // GitHub asset URL here would be wrong (URLs aren't
445            // hex). Use a well-known sentinel + the downloader
446            // overrides with the fetched value before the
447            // expected/got compare.
448            sha256: "from_sha256_asset_at_download".to_string(),
449            size_bytes: resolved.tarball_size,
450        }],
451        manifest_url: resolved.manifest_url,
452        signing: resolved.signing,
453        authors: Vec::new(),
454    };
455
456    Ok(ResolvedInstall {
457        entry,
458        download_index: 0,
459        sha256_url: resolved.sha256_url,
460    })
461}
462
463/// URL-based download+verify primitive. Fetches the expected
464/// sha256 from `sha256_url`, streams the tarball from
465/// `tarball_url` to `dest_path`, and rejects on mismatch
466/// (cleaning up the partial file). Returns the byte count.
467///
468/// Decoupled from [`ResolvedInstall`] so `persona-installer`
469/// can drive download without constructing an
470/// `ExtEntry`. Plugin path uses [`download_and_verify`] which
471/// is now a thin wrapper.
472///
473/// `pkg_id_for_errors` is echoed verbatim into
474/// [`InstallError::Sha256Invalid`] / [`InstallError::Sha256Mismatch`]
475/// so CLI output references the package the operator asked
476/// for, not a generic "tarball" string.
477pub async fn download_and_verify_url(
478    client: &reqwest::Client,
479    tarball_url: &str,
480    sha256_url: &str,
481    pkg_id_for_errors: &str,
482    dest_path: &Path,
483) -> Result<u64, InstallError> {
484    if let Some(parent) = dest_path.parent() {
485        if !parent.as_os_str().is_empty() {
486            tokio::fs::create_dir_all(parent)
487                .await
488                .map_err(|e| InstallError::Io(format!("mkdir parent: {e}")))?;
489        }
490    }
491
492    // Fetch expected sha256 from the .sha256 asset. Convention:
493    // single line of lowercase hex (64 chars) with optional
494    // trailing whitespace.
495    let expected_sha = client
496        .get(sha256_url)
497        .header("User-Agent", "nexo-ext-installer")
498        .send()
499        .await
500        .map_err(|e| InstallError::Http(format!("fetch sha256: {e}")))?
501        .text()
502        .await
503        .map_err(|e| InstallError::Http(format!("read sha256 body: {e}")))?
504        .split_whitespace()
505        .next()
506        .unwrap_or("")
507        .to_lowercase();
508    if expected_sha.len() != 64 || !expected_sha.chars().all(|c| c.is_ascii_hexdigit()) {
509        return Err(InstallError::Sha256Invalid {
510            id: pkg_id_for_errors.to_string(),
511            got: expected_sha,
512        });
513    }
514
515    let response = client
516        .get(tarball_url)
517        .header("User-Agent", "nexo-ext-installer")
518        .send()
519        .await
520        .map_err(|e| InstallError::Http(format!("fetch tarball: {e}")))?;
521    if !response.status().is_success() {
522        return Err(InstallError::Http(format!(
523            "fetch tarball: HTTP {}",
524            response.status()
525        )));
526    }
527
528    let mut hasher = Sha256::new();
529    let mut size: u64 = 0;
530    let mut file = tokio::fs::File::create(dest_path)
531        .await
532        .map_err(|e| InstallError::Io(format!("create dest: {e}")))?;
533    let mut stream = response.bytes_stream();
534    while let Some(chunk_res) = stream.next().await {
535        let chunk = match chunk_res {
536            Ok(c) => c,
537            Err(e) => {
538                drop(file);
539                let _ = tokio::fs::remove_file(dest_path).await;
540                return Err(InstallError::Http(format!("download chunk: {e}")));
541            }
542        };
543        hasher.update(&chunk);
544        size += chunk.len() as u64;
545        if let Err(e) = file.write_all(&chunk).await {
546            drop(file);
547            let _ = tokio::fs::remove_file(dest_path).await;
548            return Err(InstallError::Io(format!("write tarball: {e}")));
549        }
550    }
551    file.flush()
552        .await
553        .map_err(|e| InstallError::Io(format!("flush tarball: {e}")))?;
554    drop(file);
555
556    let computed = hex::encode(hasher.finalize());
557    if computed != expected_sha {
558        let _ = tokio::fs::remove_file(dest_path).await;
559        return Err(InstallError::Sha256Mismatch {
560            id: pkg_id_for_errors.to_string(),
561            expected: expected_sha,
562            got: computed,
563        });
564    }
565    Ok(size)
566}
567
568/// Download the resolved tarball, fetch the expected sha256
569/// from its `.sha256` sibling, stream-verify the downloaded
570/// bytes' digest matches. Aborts and removes the partial file
571/// if the digest doesn't match. Thin wrapper over
572/// [`download_and_verify_url`].
573pub async fn download_and_verify(
574    client: &reqwest::Client,
575    resolved: &ResolvedInstall,
576    dest_path: &Path,
577) -> Result<InstalledTarball, InstallError> {
578    let download = &resolved.entry.downloads[resolved.download_index];
579    let size = download_and_verify_url(
580        client,
581        &download.url,
582        &resolved.sha256_url,
583        &resolved.entry.id,
584        dest_path,
585    )
586    .await?;
587    Ok(InstalledTarball {
588        tarball_path: dest_path.to_path_buf(),
589        entry: resolved.entry.clone(),
590        size_bytes: size,
591    })
592}
593
594/// One-shot helper: parse coords, fetch release, resolve,
595/// download, verify. Equivalent to chaining the lower-level
596/// functions but matches typical CLI usage.
597pub async fn install_plugin(
598    client: &reqwest::Client,
599    coords: &str,
600    target: &str,
601    dest_path: &Path,
602    api_base: &str,
603) -> Result<InstalledTarball, InstallError> {
604    let coords = RepoCoords::parse(coords)?;
605    let resolved = resolve_release(client, &coords, target, api_base).await?;
606    download_and_verify(client, &resolved, dest_path).await
607}
608
609/// Detect the running daemon's target triple. Override via
610/// `NEXO_INSTALL_TARGET` env.
611pub fn current_target_triple() -> String {
612    if let Ok(t) = std::env::var("NEXO_INSTALL_TARGET") {
613        if !t.is_empty() {
614            return t;
615        }
616    }
617    if cfg!(all(target_arch = "x86_64", target_os = "linux")) {
618        "x86_64-unknown-linux-gnu".to_string()
619    } else if cfg!(all(target_arch = "aarch64", target_os = "linux")) {
620        "aarch64-unknown-linux-gnu".to_string()
621    } else if cfg!(all(target_arch = "x86_64", target_os = "macos")) {
622        "x86_64-apple-darwin".to_string()
623    } else if cfg!(all(target_arch = "aarch64", target_os = "macos")) {
624        "aarch64-apple-darwin".to_string()
625    } else {
626        "unknown-target".to_string()
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use serde_json::json;
634    use wiremock::matchers::{header, method, path};
635    use wiremock::{Mock, MockServer, ResponseTemplate};
636
637    #[test]
638    fn parse_coords_default_tag_latest() {
639        let c = RepoCoords::parse("alice/plugin-x").unwrap();
640        assert_eq!(c.owner, "alice");
641        assert_eq!(c.repo, "plugin-x");
642        assert_eq!(c.tag, "latest");
643    }
644
645    #[test]
646    fn parse_coords_with_tag() {
647        let c = RepoCoords::parse("alice/plugin-x@v0.2.0").unwrap();
648        assert_eq!(c.owner, "alice");
649        assert_eq!(c.repo, "plugin-x");
650        assert_eq!(c.tag, "v0.2.0");
651    }
652
653    #[test]
654    fn parse_coords_rejects_bad_shapes() {
655        assert!(RepoCoords::parse("no-slash").is_err());
656        assert!(RepoCoords::parse("/empty-owner").is_err());
657        assert!(RepoCoords::parse("alice/").is_err());
658        assert!(RepoCoords::parse("alice/plugin@").is_err());
659        assert!(RepoCoords::parse("alice/plugin space@v1").is_err());
660    }
661
662    #[test]
663    fn release_api_url_branches_on_tag() {
664        let c = RepoCoords::parse("alice/x@v0.2.0").unwrap();
665        assert_eq!(
666            c.release_api_url("https://api.github.com"),
667            "https://api.github.com/repos/alice/x/releases/tags/v0.2.0"
668        );
669        let c2 = RepoCoords::parse("alice/x").unwrap();
670        assert_eq!(
671            c2.release_api_url("https://api.github.com"),
672            "https://api.github.com/repos/alice/x/releases/latest"
673        );
674    }
675
676    fn manifest_toml(id: &str, version: &str) -> String {
677        format!(
678            r#"[plugin]
679id = "{id}"
680version = "{version}"
681name = "Slack Channel"
682description = "Slack bot integration"
683min_nexo_version = ">=0.0.1"
684
685[plugin.requires]
686nexo_capabilities = ["broker"]
687"#
688        )
689    }
690
691    /// Round-trip: fetch release, parse manifest from asset,
692    /// resolve a target's tarball + sha256, download tarball,
693    /// verify sha256 matches.
694    #[tokio::test]
695    async fn install_round_trip_with_real_sha() {
696        let server = MockServer::start().await;
697
698        let manifest_body = manifest_toml("slack", "0.2.0");
699        let tarball_payload = b"fake plugin tarball bytes";
700        let mut hasher = Sha256::new();
701        hasher.update(tarball_payload);
702        let tarball_sha = hex::encode(hasher.finalize());
703        let sha_body = format!("{tarball_sha}\n");
704
705        let manifest_url = format!("{}/manifest", server.uri());
706        let tarball_url = format!("{}/tarball", server.uri());
707        let sha_url = format!("{}/sha256", server.uri());
708
709        let release = json!({
710            "tag_name": "v0.2.0",
711            "assets": [
712                {
713                    "name": "nexo-plugin.toml",
714                    "browser_download_url": manifest_url,
715                    "size": manifest_body.len()
716                },
717                {
718                    "name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz",
719                    "browser_download_url": tarball_url,
720                    "size": tarball_payload.len()
721                },
722                {
723                    "name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz.sha256",
724                    "browser_download_url": sha_url,
725                    "size": sha_body.len()
726                }
727            ]
728        });
729
730        Mock::given(method("GET"))
731            .and(path("/repos/alice/slack-plugin/releases/tags/v0.2.0"))
732            .and(header("Accept", "application/vnd.github+json"))
733            .respond_with(ResponseTemplate::new(200).set_body_json(release))
734            .mount(&server)
735            .await;
736        Mock::given(method("GET"))
737            .and(path("/manifest"))
738            .respond_with(ResponseTemplate::new(200).set_body_string(manifest_body.clone()))
739            .mount(&server)
740            .await;
741        Mock::given(method("GET"))
742            .and(path("/sha256"))
743            .respond_with(ResponseTemplate::new(200).set_body_string(sha_body))
744            .mount(&server)
745            .await;
746        Mock::given(method("GET"))
747            .and(path("/tarball"))
748            .respond_with(ResponseTemplate::new(200).set_body_bytes(tarball_payload.as_slice()))
749            .mount(&server)
750            .await;
751
752        let coords = RepoCoords::parse("alice/slack-plugin@v0.2.0").unwrap();
753        let client = reqwest::Client::new();
754        let resolved = resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri())
755            .await
756            .expect("resolve");
757        assert_eq!(resolved.entry.id, "slack");
758        assert_eq!(resolved.entry.version.to_string(), "0.2.0");
759        assert_eq!(resolved.entry.tier, nexo_ext_registry::ExtTier::Community);
760
761        let tmp = tempfile::tempdir().unwrap();
762        let dest = tmp.path().join("slack-0.2.0.tar.gz");
763        let installed = download_and_verify(&client, &resolved, &dest)
764            .await
765            .expect("download");
766        assert_eq!(installed.tarball_path, dest);
767        assert_eq!(installed.size_bytes as usize, tarball_payload.len());
768        assert!(dest.exists());
769    }
770
771    #[tokio::test]
772    async fn rejects_release_missing_manifest_asset() {
773        let server = MockServer::start().await;
774        let release = json!({
775            "tag_name": "v0.2.0",
776            "assets": [
777                {
778                    "name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz",
779                    "browser_download_url": "https://example.com/tar",
780                    "size": 100
781                }
782                // no nexo-plugin.toml asset
783            ]
784        });
785        Mock::given(method("GET"))
786            .and(path("/repos/alice/x/releases/tags/v0.2.0"))
787            .respond_with(ResponseTemplate::new(200).set_body_json(release))
788            .mount(&server)
789            .await;
790
791        let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
792        let client = reqwest::Client::new();
793        match resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri()).await {
794            Err(InstallError::ReleaseShape { reason, .. }) => {
795                assert!(reason.contains("nexo-plugin.toml"));
796            }
797            other => panic!("expected ReleaseShape error, got {other:?}"),
798        }
799    }
800
801    #[tokio::test]
802    async fn rejects_release_missing_target_tarball() {
803        let server = MockServer::start().await;
804        let manifest_body = manifest_toml("slack", "0.2.0");
805        let manifest_url = format!("{}/manifest", server.uri());
806        let release = json!({
807            "tag_name": "v0.2.0",
808            "assets": [
809                {
810                    "name": "nexo-plugin.toml",
811                    "browser_download_url": manifest_url,
812                    "size": manifest_body.len()
813                },
814                {
815                    "name": "slack-0.2.0-aarch64-apple-darwin.tar.gz",
816                    "browser_download_url": "https://example.com/tar",
817                    "size": 100
818                }
819                // no x86_64 linux tarball
820            ]
821        });
822        Mock::given(method("GET"))
823            .and(path("/repos/alice/x/releases/tags/v0.2.0"))
824            .respond_with(ResponseTemplate::new(200).set_body_json(release))
825            .mount(&server)
826            .await;
827        Mock::given(method("GET"))
828            .and(path("/manifest"))
829            .respond_with(ResponseTemplate::new(200).set_body_string(manifest_body))
830            .mount(&server)
831            .await;
832
833        let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
834        let client = reqwest::Client::new();
835        match resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri()).await {
836            Err(InstallError::TargetNotFound { available, .. }) => {
837                assert_eq!(
838                    available,
839                    vec!["slack-0.2.0-aarch64-apple-darwin.tar.gz".to_string()]
840                );
841            }
842            other => panic!("expected TargetNotFound, got {other:?}"),
843        }
844    }
845
846    #[tokio::test]
847    async fn resolve_release_falls_back_to_noarch_when_per_target_absent() {
848        let server = MockServer::start().await;
849        let manifest_body = manifest_toml("slack", "0.2.0");
850        let manifest_url = format!("{}/manifest", server.uri());
851        let release = json!({
852            "tag_name": "v0.2.0",
853            "assets": [
854                {"name": "nexo-plugin.toml", "browser_download_url": manifest_url, "size": manifest_body.len()},
855                // ONLY noarch — no per-target tarball.
856                {"name": "slack-0.2.0-noarch.tar.gz", "browser_download_url": "https://example.com/tar", "size": 100},
857                {"name": "slack-0.2.0-noarch.tar.gz.sha256", "browser_download_url": "https://example.com/sha", "size": 64}
858            ]
859        });
860        Mock::given(method("GET"))
861            .and(path("/repos/alice/x/releases/tags/v0.2.0"))
862            .respond_with(ResponseTemplate::new(200).set_body_json(release))
863            .mount(&server)
864            .await;
865        Mock::given(method("GET"))
866            .and(path("/manifest"))
867            .respond_with(ResponseTemplate::new(200).set_body_string(manifest_body))
868            .mount(&server)
869            .await;
870
871        let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
872        let client = reqwest::Client::new();
873        let resolved = resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri())
874            .await
875            .expect("noarch fallback");
876        assert_eq!(
877            resolved.entry.downloads[0].url.as_str(),
878            "https://example.com/tar"
879        );
880        assert!(resolved.sha256_url.contains("/sha"));
881    }
882
883    #[tokio::test]
884    async fn resolve_release_prefers_per_target_over_noarch() {
885        let server = MockServer::start().await;
886        let manifest_body = manifest_toml("slack", "0.2.0");
887        let manifest_url = format!("{}/manifest", server.uri());
888        let release = json!({
889            "tag_name": "v0.2.0",
890            "assets": [
891                {"name": "nexo-plugin.toml", "browser_download_url": manifest_url, "size": manifest_body.len()},
892                {"name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz", "browser_download_url": "https://example.com/per-target", "size": 100},
893                {"name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz.sha256", "browser_download_url": "https://example.com/per-sha", "size": 64},
894                {"name": "slack-0.2.0-noarch.tar.gz", "browser_download_url": "https://example.com/noarch", "size": 100},
895                {"name": "slack-0.2.0-noarch.tar.gz.sha256", "browser_download_url": "https://example.com/noarch-sha", "size": 64}
896            ]
897        });
898        Mock::given(method("GET"))
899            .and(path("/repos/alice/x/releases/tags/v0.2.0"))
900            .respond_with(ResponseTemplate::new(200).set_body_json(release))
901            .mount(&server)
902            .await;
903        Mock::given(method("GET"))
904            .and(path("/manifest"))
905            .respond_with(ResponseTemplate::new(200).set_body_string(manifest_body))
906            .mount(&server)
907            .await;
908
909        let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
910        let client = reqwest::Client::new();
911        let resolved = resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri())
912            .await
913            .expect("per-target preferred");
914        assert_eq!(
915            resolved.entry.downloads[0].url.as_str(),
916            "https://example.com/per-target",
917            "per-target tarball must win when both present"
918        );
919    }
920
921    #[tokio::test]
922    async fn detects_sha256_mismatch_and_cleans_up() {
923        let server = MockServer::start().await;
924        let manifest_body = manifest_toml("slack", "0.2.0");
925        let tarball_payload = b"actual bytes here";
926        // ADVERTISE A WRONG sha256 — install must reject + remove.
927        let advertised_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n";
928
929        let manifest_url = format!("{}/manifest", server.uri());
930        let tarball_url = format!("{}/tarball", server.uri());
931        let sha_url = format!("{}/sha256", server.uri());
932
933        let release = json!({
934            "tag_name": "v0.2.0",
935            "assets": [
936                {"name": "nexo-plugin.toml", "browser_download_url": manifest_url, "size": manifest_body.len()},
937                {"name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz", "browser_download_url": tarball_url, "size": tarball_payload.len()},
938                {"name": "slack-0.2.0-x86_64-unknown-linux-gnu.tar.gz.sha256", "browser_download_url": sha_url, "size": advertised_sha.len()}
939            ]
940        });
941        Mock::given(method("GET"))
942            .and(path("/repos/alice/x/releases/tags/v0.2.0"))
943            .respond_with(ResponseTemplate::new(200).set_body_json(release))
944            .mount(&server)
945            .await;
946        Mock::given(method("GET"))
947            .and(path("/manifest"))
948            .respond_with(ResponseTemplate::new(200).set_body_string(manifest_body))
949            .mount(&server)
950            .await;
951        Mock::given(method("GET"))
952            .and(path("/sha256"))
953            .respond_with(ResponseTemplate::new(200).set_body_string(advertised_sha))
954            .mount(&server)
955            .await;
956        Mock::given(method("GET"))
957            .and(path("/tarball"))
958            .respond_with(ResponseTemplate::new(200).set_body_bytes(tarball_payload.as_slice()))
959            .mount(&server)
960            .await;
961
962        let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
963        let client = reqwest::Client::new();
964        let resolved = resolve_release(&client, &coords, "x86_64-unknown-linux-gnu", &server.uri())
965            .await
966            .expect("resolve");
967
968        let tmp = tempfile::tempdir().unwrap();
969        let dest = tmp.path().join("tampered.tar.gz");
970        match download_and_verify(&client, &resolved, &dest).await {
971            Err(InstallError::Sha256Mismatch { id, .. }) => assert_eq!(id, "slack"),
972            other => panic!("expected Sha256Mismatch, got {other:?}"),
973        }
974        assert!(!dest.exists(), "partial file must be removed on mismatch");
975    }
976
977    // ─── ExtractContract abstraction tests ─────────────────────
978    //
979    // Exercise `resolve_release_with_contract` with a synthetic
980    // contract that consumes a non-plugin manifest filename +
981    // schema. Proves the resolver doesn't smuggle the
982    // `nexo-plugin.toml` assumption anywhere — the persona-installer
983    // crate plugs in its own contract analogously.
984
985    #[derive(Debug, serde::Deserialize)]
986    struct TestPersonaManifest {
987        id: String,
988        #[allow(dead_code)]
989        name: String,
990    }
991
992    #[derive(Debug, Default, Clone, Copy)]
993    struct TestPersonaContract;
994
995    impl ExtractContract for TestPersonaContract {
996        type Manifest = TestPersonaManifest;
997
998        fn manifest_asset_name(&self) -> &'static str {
999            "test-persona.toml"
1000        }
1001
1002        fn parse_manifest(
1003            &self,
1004            bytes: &[u8],
1005            coords: &RepoCoords,
1006        ) -> Result<Self::Manifest, InstallError> {
1007            let text = std::str::from_utf8(bytes).map_err(|e| InstallError::ReleaseShape {
1008                owner: coords.owner.clone(),
1009                repo: coords.repo.clone(),
1010                reason: format!("test-persona manifest is not valid UTF-8: {e}"),
1011            })?;
1012            toml::from_str::<Self::Manifest>(text).map_err(|e| InstallError::ReleaseShape {
1013                owner: coords.owner.clone(),
1014                repo: coords.repo.clone(),
1015                reason: format!("test-persona manifest parse failed: {e}"),
1016            })
1017        }
1018
1019        fn manifest_id(&self, m: &Self::Manifest) -> String {
1020            m.id.clone()
1021        }
1022    }
1023
1024    /// Custom contract end-to-end: synthetic `test-persona.toml`
1025    /// asset is located, parsed via the contract, and the
1026    /// matching tarball + sha256 are resolved using the contract-
1027    /// supplied id.
1028    #[tokio::test]
1029    async fn resolve_release_with_contract_serves_custom_manifest_filename() {
1030        let server = MockServer::start().await;
1031
1032        let manifest_body = r#"id = "cody"
1033name = "Cody Persona"
1034"#;
1035        let manifest_url = format!("{}/persona-toml", server.uri());
1036        let tarball_url = format!("{}/persona-tar", server.uri());
1037        let sha_url = format!("{}/persona-sha", server.uri());
1038
1039        let release = json!({
1040            "tag_name": "v0.2.0",
1041            "assets": [
1042                {"name": "test-persona.toml", "browser_download_url": manifest_url, "size": manifest_body.len()},
1043                {"name": "cody-0.2.0-noarch.tar.gz", "browser_download_url": tarball_url, "size": 42},
1044                {"name": "cody-0.2.0-noarch.tar.gz.sha256", "browser_download_url": sha_url, "size": 64}
1045            ]
1046        });
1047        Mock::given(method("GET"))
1048            .and(path(
1049                "/repos/lordmacu/nexo-persona-cody/releases/tags/v0.2.0",
1050            ))
1051            .respond_with(ResponseTemplate::new(200).set_body_json(release))
1052            .mount(&server)
1053            .await;
1054        Mock::given(method("GET"))
1055            .and(path("/persona-toml"))
1056            .respond_with(ResponseTemplate::new(200).set_body_string(manifest_body))
1057            .mount(&server)
1058            .await;
1059
1060        let coords = RepoCoords::parse("lordmacu/nexo-persona-cody@v0.2.0").unwrap();
1061        let client = reqwest::Client::new();
1062        let resolved = resolve_release_with_contract(
1063            &TestPersonaContract,
1064            &client,
1065            &coords,
1066            "x86_64-unknown-linux-gnu",
1067            &server.uri(),
1068        )
1069        .await
1070        .expect("contract resolve");
1071
1072        assert_eq!(resolved.manifest.id, "cody");
1073        assert_eq!(resolved.version.to_string(), "0.2.0");
1074        assert_eq!(
1075            resolved.target, "noarch",
1076            "noarch fallback wins when per-target absent"
1077        );
1078        assert_eq!(resolved.tarball_url.as_str(), tarball_url);
1079        assert_eq!(resolved.sha256_url.as_str(), sha_url);
1080        assert!(resolved.signing.is_none(), "no cosign assets in fixture");
1081    }
1082
1083    /// Contract-driven manifest filename mismatch: release ships
1084    /// `nexo-plugin.toml` but our contract asks for
1085    /// `test-persona.toml`. Resolver must fail with
1086    /// `ReleaseShape` mentioning the *contract's* filename, not
1087    /// the plugin one.
1088    #[tokio::test]
1089    async fn resolve_release_with_contract_errors_when_contract_manifest_absent() {
1090        let server = MockServer::start().await;
1091        let release = json!({
1092            "tag_name": "v0.2.0",
1093            "assets": [
1094                {"name": "nexo-plugin.toml", "browser_download_url": "https://example.com/m", "size": 100}
1095                // no test-persona.toml
1096            ]
1097        });
1098        Mock::given(method("GET"))
1099            .and(path("/repos/alice/x/releases/tags/v0.2.0"))
1100            .respond_with(ResponseTemplate::new(200).set_body_json(release))
1101            .mount(&server)
1102            .await;
1103
1104        let coords = RepoCoords::parse("alice/x@v0.2.0").unwrap();
1105        let client = reqwest::Client::new();
1106        match resolve_release_with_contract(
1107            &TestPersonaContract,
1108            &client,
1109            &coords,
1110            "x86_64-unknown-linux-gnu",
1111            &server.uri(),
1112        )
1113        .await
1114        {
1115            Err(InstallError::ReleaseShape { reason, .. }) => {
1116                assert!(
1117                    reason.contains("test-persona.toml"),
1118                    "error must mention contract-supplied filename, got: {reason}"
1119                );
1120                assert!(
1121                    !reason.contains("nexo-plugin.toml"),
1122                    "error must NOT leak the plugin filename, got: {reason}"
1123                );
1124            }
1125            other => panic!("expected ReleaseShape error, got {other:?}"),
1126        }
1127    }
1128}