1#![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#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct RepoCoords {
66 pub owner: String,
68 pub repo: String,
70 pub tag: String,
73}
74
75#[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 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 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 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
148pub const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com";
151
152#[derive(Debug, Clone, serde::Deserialize)]
156struct ReleaseAsset {
157 name: String,
158 browser_download_url: String,
159 #[serde(default)]
160 size: u64,
161}
162
163#[derive(Debug, Clone, serde::Deserialize)]
166struct ReleaseResponse {
167 tag_name: String,
168 #[serde(default)]
169 assets: Vec<ReleaseAsset>,
170}
171
172#[derive(Debug, Clone)]
175pub struct ResolvedInstall {
176 pub entry: ExtEntry,
178 pub download_index: usize,
180 pub sha256_url: String,
184}
185
186#[derive(Debug, Clone)]
188pub struct InstalledTarball {
189 pub tarball_path: PathBuf,
191 pub entry: ExtEntry,
193 pub size_bytes: u64,
195}
196
197#[derive(Debug, Clone)]
207pub struct ResolvedReleaseTyped<M> {
208 pub manifest: M,
210 pub coords: RepoCoords,
213 pub version: semver::Version,
215 pub target: String,
219 pub tarball_url: String,
221 pub tarball_size: u64,
225 pub manifest_url: String,
228 pub sha256_url: String,
232 pub signing: Option<nexo_ext_registry::ExtSigning>,
234}
235
236async 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
265pub 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 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 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 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 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 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
397pub 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 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 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
463pub 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 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
568pub 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
594pub 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
609pub 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 #[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 ]
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 ]
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 {"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 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 #[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 #[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 #[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 ]
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}