1use std::path::{Path, PathBuf};
9
10use async_trait::async_trait;
11use serde::Deserialize;
12use serde::Serialize;
13use std::collections::{BTreeMap, HashSet};
14
15use crate::backend::{Backend, Ctx, InstallCtx};
16use crate::dirs::InstallLocator;
17use crate::error::{Error, Result};
18use crate::http;
19use crate::inventory::{DynamicToolBin, DynamicToolManifest};
20use crate::pipeline::{self, ArchiveKind, InstallPlan, PipelineCtx};
21use crate::platform::{Arch, Os};
22use crate::source::Source;
23use crate::tool::{InstallIdentity, InstallScope};
24use crate::verification::GithubAttestation;
25use crate::version::{ToolRequest, ToolVersion, VersionInfo, VersionSpec};
26
27pub struct GithubBackend {
29 id: String,
31 owner: String,
32 repo: String,
33}
34
35#[derive(Debug, Deserialize)]
36struct GhRelease {
37 tag_name: String,
38 #[serde(default)]
39 draft: bool,
40 #[serde(default)]
41 prerelease: bool,
42 #[serde(default)]
43 assets: Vec<GhAsset>,
44}
45
46#[derive(Debug, Deserialize, Clone)]
47struct GhAsset {
48 name: String,
49 browser_download_url: String,
50}
51
52#[derive(Debug, Clone)]
53struct SelectedArtifact {
54 asset: GhAsset,
55 urls: Vec<String>,
56 checksum: Option<pipeline::Checksum>,
57}
58
59#[derive(Debug, Clone, Deserialize, Serialize)]
60struct StaticCatalog {
61 schema: u32,
62 releases: Vec<StaticRelease>,
63}
64
65#[derive(Debug, Clone, Deserialize, Serialize)]
66struct StaticRelease {
67 tag: String,
68 #[serde(default)]
69 prerelease: bool,
70 assets: Vec<StaticAsset>,
71}
72
73#[derive(Debug, Clone, Deserialize, Serialize)]
74struct StaticAsset {
75 name: String,
76 url: String,
77 checksum: String,
78 os: String,
79 arch: String,
80 #[serde(default)]
81 libc: Option<String>,
82}
83
84#[derive(Debug, Clone)]
85struct AssetRules {
86 regex: Option<regex::Regex>,
87 template: Option<String>,
88 bins: Vec<PathBuf>,
89 rename: Option<String>,
90 strip_components: usize,
91 os: Option<String>,
92 arch: Option<String>,
93 libc: Option<String>,
94}
95
96impl GithubBackend {
97 pub fn from_id(id: &str) -> Option<GithubBackend> {
100 let id = crate::inventory::canonical_dynamic_id(id).ok()?;
101 let rest = id.strip_prefix("github:")?;
102 let mut components = rest.split('/');
103 let owner = components.next()?;
104 let repo = components.next()?;
105 if components.next().is_some() {
106 return None;
107 }
108 let owner = owner.trim().to_string();
109 let repo = repo.trim().trim_end_matches(".git").to_string();
110 if !valid_repository_component(&owner) || !valid_repository_component(&repo) {
111 return None;
112 }
113 Some(GithubBackend { id, owner, repo })
114 }
115
116 fn releases_api(&self, page: usize) -> String {
117 format!(
118 "https://api.github.com/repos/{}/{}/releases?per_page=100&page={page}",
119 self.owner, self.repo,
120 )
121 }
122
123 fn release_api(&self, tag: &str) -> Result<String> {
124 let mut url = reqwest::Url::parse(&format!(
125 "https://api.github.com/repos/{}/{}/releases/tags/",
126 self.owner, self.repo
127 ))
128 .map_err(|error| Error::other(format!("invalid GitHub release URL: {error}")))?;
129 url.path_segments_mut()
130 .map_err(|_| Error::other("invalid GitHub release URL"))?
131 .pop_if_empty()
132 .push(tag);
133 Ok(url.into())
134 }
135
136 fn releases_atom(&self) -> String {
137 format!(
138 "https://github.com/{}/{}/releases.atom",
139 self.owner, self.repo
140 )
141 }
142
143 fn expanded_assets(&self, tag: &str) -> Result<String> {
144 let mut url = reqwest::Url::parse(&format!(
145 "https://github.com/{}/{}/releases/expanded_assets/",
146 self.owner, self.repo
147 ))
148 .map_err(|error| Error::other(format!("invalid GitHub assets URL: {error}")))?;
149 url.path_segments_mut()
150 .map_err(|_| Error::other("invalid GitHub assets URL"))?
151 .pop_if_empty()
152 .push(tag);
153 Ok(url.into())
154 }
155
156 fn attestation(&self, ctx: &Ctx, sources: &[Source]) -> Option<GithubAttestation> {
157 let policy = ctx.config.settings.attestations;
158 (policy != crate::config::AttestationPolicy::Off).then(|| GithubAttestation {
159 owner: self.owner.clone(),
160 repo: self.repo.clone(),
161 policy,
162 sources: sources.to_vec(),
163 })
164 }
165
166 async fn api_releases(&self, ctx: &Ctx, sources: &[Source]) -> Result<Vec<GhRelease>> {
167 let mut releases = Vec::new();
168 for page in 1..=10 {
169 let api = self.releases_api(page);
170 let urls = http::github_url_candidates(sources, &api);
171 let page_releases: Vec<GhRelease> =
172 http::get_cached_github_json_from_urls(ctx, &api, &urls).await?;
173 let done = page_releases.len() < 100;
174 releases.extend(page_releases);
175 if done {
176 break;
177 }
178 }
179 Ok(releases)
180 }
181
182 async fn releases(&self, ctx: &Ctx, sources: &[Source]) -> Result<Vec<GhRelease>> {
183 match self.api_releases(ctx, sources).await {
184 Ok(releases) => Ok(releases),
185 Err(api_error) if api_error.is_anonymous_github_rate_limit() => {
186 match self.public_releases(ctx, sources).await {
187 Ok(releases) if !releases.is_empty() => {
188 tracing::warn!(
189 repository = %format!("{}/{}", self.owner, self.repo),
190 "{}",
191 crate::i18n::tr("log.github_public_fallback")
192 );
193 Ok(releases)
194 }
195 Ok(_) => Err(api_error),
196 Err(web_error) => {
197 tracing::debug!(error = %web_error, "public GitHub releases fallback failed");
198 Err(api_error)
199 }
200 }
201 }
202 Err(error) => Err(error),
203 }
204 }
205
206 async fn release_for_tag(
207 &self,
208 ctx: &Ctx,
209 sources: &[Source],
210 version: &str,
211 ) -> Result<GhRelease> {
212 let tags = tag_candidates(version);
213 let mut rate_limit_error = None;
214 for tag in &tags {
215 let api = self.release_api(tag)?;
216 let urls = http::github_url_candidates(sources, &api);
217 match http::get_cached_github_json_from_urls(ctx, &api, &urls).await {
218 Ok(release) => return Ok(release),
219 Err(error) if ctx.config.settings.offline => {
220 return self.release_for_tag_from_list_cache(ctx, version, error);
221 }
222 Err(error) if error.is_anonymous_github_rate_limit() => {
223 rate_limit_error = Some(error);
227 continue;
228 }
229 Err(error) if error.status() == Some(404) => continue,
230 Err(error) => return Err(error),
231 }
232 }
233
234 let Some(api_error) = rate_limit_error else {
235 return Err(Error::VersionResolve {
236 tool: self.id().to_string(),
237 spec: version.to_string(),
238 hint: Some("release tag not found".into()),
239 });
240 };
241 for tag in &tags {
242 match self.public_assets(ctx, sources, tag).await {
243 Ok(assets) if !assets.is_empty() => {
244 tracing::warn!(
245 repository = %format!("{}/{}", self.owner, self.repo),
246 tag,
247 "{}",
248 crate::i18n::tr("log.github_public_fallback")
249 );
250 return Ok(GhRelease {
251 tag_name: tag.clone(),
252 draft: false,
253 prerelease: crate::backend::python::is_prerelease(
254 tag.trim_start_matches('v'),
255 ),
256 assets,
257 });
258 }
259 Ok(_) => {}
260 Err(error) => {
261 tracing::debug!(tag, error = %error, "public GitHub assets fallback failed");
262 }
263 }
264 }
265 Err(api_error)
266 }
267
268 fn release_for_tag_from_list_cache(
269 &self,
270 ctx: &Ctx,
271 version: &str,
272 exact_cache_error: Error,
273 ) -> Result<GhRelease> {
274 for page in 1..=10 {
275 let api = self.releases_api(page);
276 let cache = http::metadata_cache_path(ctx, &api);
277 let Ok(bytes) = std::fs::read(&cache) else {
278 break;
279 };
280 let page_releases: Vec<GhRelease> = serde_json::from_slice(&bytes)?;
281 let done = page_releases.len() < 100;
282 if let Some(release) = page_releases.into_iter().find(|release| {
283 release.tag_name.trim_start_matches('v') == version.trim_start_matches('v')
284 }) {
285 return Ok(release);
286 }
287 if done {
288 break;
289 }
290 }
291 Err(exact_cache_error)
292 }
293
294 async fn public_releases(&self, ctx: &Ctx, sources: &[Source]) -> Result<Vec<GhRelease>> {
295 let canonical = self.releases_atom();
296 let urls = http::github_url_candidates(sources, &canonical);
297 let atom = http::get_cached_text_from_urls(ctx, &canonical, &urls, |text| {
298 !parse_release_tags(text, &self.owner, &self.repo).is_empty()
299 })
300 .await?;
301 let tags = parse_release_tags(&atom, &self.owner, &self.repo);
302 Ok(tags
303 .into_iter()
304 .map(|tag_name| GhRelease {
305 prerelease: crate::backend::python::is_prerelease(tag_name.trim_start_matches('v')),
306 tag_name,
307 draft: false,
308 assets: Vec::new(),
309 })
310 .collect())
311 }
312
313 async fn public_assets(
314 &self,
315 ctx: &Ctx,
316 sources: &[Source],
317 tag: &str,
318 ) -> Result<Vec<GhAsset>> {
319 let canonical = self.expanded_assets(tag)?;
320 let urls = http::github_url_candidates(sources, &canonical);
321 let html = http::get_cached_text_from_urls(ctx, &canonical, &urls, |text| {
322 !parse_release_assets(text, &self.owner, &self.repo, tag).is_empty()
323 })
324 .await?;
325 Ok(parse_release_assets(&html, &self.owner, &self.repo, tag))
326 }
327
328 fn rules(options: &std::collections::BTreeMap<String, String>) -> Result<AssetRules> {
329 if options.contains_key("asset-regex") && options.contains_key("asset-template") {
330 return Err(Error::config(
331 "asset-regex and asset-template are mutually exclusive",
332 ));
333 }
334 if options.contains_key("bin") && options.contains_key("bins") {
335 return Err(Error::config("bin and bins are mutually exclusive"));
336 }
337 let regex = options
338 .get("asset-regex")
339 .map(|value| {
340 regex::Regex::new(value)
341 .map_err(|error| Error::config(format!("invalid asset-regex: {error}")))
342 })
343 .transpose()?;
344 let bins = options
345 .get("bins")
346 .or_else(|| options.get("bin"))
347 .map(|value| {
348 value
349 .split(',')
350 .filter(|item| !item.trim().is_empty())
351 .map(|item| {
352 let path = PathBuf::from(item.trim());
353 if path.is_absolute()
354 || path.components().any(|component| {
355 !matches!(component, std::path::Component::Normal(_))
356 })
357 {
358 return Err(Error::config(format!(
359 "unsafe GitHub bin path `{}`",
360 path.display()
361 )));
362 }
363 Ok(path)
364 })
365 .collect::<Result<Vec<_>>>()
366 })
367 .transpose()?
368 .unwrap_or_default();
369 let strip_components = options
370 .get("strip-components")
371 .map(|value| {
372 value.parse::<usize>().map_err(|error| {
373 Error::config(format!("invalid strip-components `{value}`: {error}"))
374 })
375 })
376 .transpose()?
377 .unwrap_or_default();
378 let os = options.get("os").cloned();
379 let arch = options.get("arch").cloned();
380 let libc = options.get("libc").cloned();
381 if os
382 .as_deref()
383 .is_some_and(|value| !matches!(value, "linux" | "macos" | "darwin" | "windows"))
384 {
385 return Err(Error::config("invalid GitHub target os"));
386 }
387 if arch.as_deref().is_some_and(|value| {
388 !matches!(
389 value,
390 "x64" | "x86_64" | "amd64" | "arm64" | "aarch64" | "x86" | "i686" | "arm" | "armv7"
391 )
392 }) {
393 return Err(Error::config("invalid GitHub target arch"));
394 }
395 if libc
396 .as_deref()
397 .is_some_and(|value| !matches!(value, "gnu" | "musl" | "none"))
398 {
399 return Err(Error::config("invalid GitHub target libc"));
400 }
401 let rename = options.get("rename").cloned();
402 if let Some(name) = rename.as_deref() {
403 pipeline::validate_safe_filename("GitHub executable rename", name)?;
404 }
405 Ok(AssetRules {
406 regex,
407 template: options.get("asset-template").cloned(),
408 bins,
409 rename,
410 strip_components,
411 os,
412 arch,
413 libc,
414 })
415 }
416
417 async fn static_catalog(
418 &self,
419 ctx: &Ctx,
420 options: &std::collections::BTreeMap<String, String>,
421 ) -> Result<Option<StaticCatalog>> {
422 let Some(source) = options.get("catalog-url") else {
423 return Ok(None);
424 };
425 let expected = options
426 .get("catalog-sha256")
427 .ok_or_else(|| Error::config("catalog-sha256 is required with catalog-url"))?;
428 let bytes = if source.starts_with("http://") || source.starts_with("https://") {
429 if ctx.config.settings.offline {
430 let cache = static_catalog_cache(ctx, expected);
431 std::fs::read(&cache).map_err(|_| {
432 Error::other(format!(
433 "offline static GitHub catalog cache miss for {source}"
434 ))
435 })?
436 } else {
437 let bytes = ctx
438 .client
439 .get(source)
440 .send()
441 .await?
442 .error_for_status()?
443 .bytes()
444 .await?
445 .to_vec();
446 verify_catalog_bytes(source, expected, &bytes)?;
447 write_atomic(&static_catalog_cache(ctx, expected), &bytes)?;
448 bytes
449 }
450 } else {
451 let path = source.strip_prefix("file://").unwrap_or(source);
452 std::fs::read(path).map_err(|error| Error::io(path, error))?
453 };
454 verify_catalog_bytes(source, expected, &bytes)?;
455 let catalog: StaticCatalog = serde_json::from_slice(&bytes)?;
456 if catalog.schema != 1 {
457 return Err(Error::config(format!(
458 "unsupported GitHub static catalog schema {}",
459 catalog.schema
460 )));
461 }
462 if catalog.releases.is_empty() {
463 return Err(Error::config("GitHub static catalog contains no releases"));
464 }
465 for release in &catalog.releases {
466 if release.tag.trim().is_empty() || release.assets.is_empty() {
467 return Err(Error::config(
468 "GitHub static catalog release requires a tag and assets",
469 ));
470 }
471 for asset in &release.assets {
472 if asset.name.trim().is_empty()
473 || !static_asset_url_is_persistable(&asset.url)
474 || asset.os.trim().is_empty()
475 || asset.arch.trim().is_empty()
476 {
477 return Err(Error::config(format!(
478 "invalid GitHub static catalog asset in {}",
479 release.tag
480 )));
481 }
482 pipeline::parse_checksum(&asset.checksum)?;
483 }
484 }
485 Ok(Some(catalog))
486 }
487
488 async fn select_install_artifact(
489 &self,
490 ctx: &Ctx,
491 tv: &ToolVersion,
492 rules: &AssetRules,
493 sources: &[Source],
494 ) -> Result<SelectedArtifact> {
495 if let Some(artifact) = pipeline::locked_artifact(tv)? {
496 let checksum = artifact
497 .checksum
498 .as_deref()
499 .map(pipeline::parse_checksum)
500 .transpose()?;
501 return Ok(SelectedArtifact {
502 urls: http::github_url_candidates(sources, &artifact.url),
503 asset: GhAsset {
504 name: artifact.file_name,
505 browser_download_url: artifact.url,
506 },
507 checksum,
508 });
509 }
510
511 let want = tv.version.trim_start_matches('v');
512 let (assets, static_checksums) =
513 if let Some(catalog) = self.static_catalog(ctx, &tv.options).await? {
514 let release = catalog
515 .releases
516 .into_iter()
517 .find(|release| release.tag.trim_start_matches('v') == want)
518 .ok_or_else(|| Error::VersionResolve {
519 tool: self.id().into(),
520 spec: tv.version.clone(),
521 hint: Some("static catalog tag not found".into()),
522 })?;
523 let matching: Vec<_> = release
524 .assets
525 .into_iter()
526 .filter(|asset| static_asset_matches(asset, ctx, rules))
527 .collect();
528 (
529 matching
530 .iter()
531 .map(|asset| GhAsset {
532 name: asset.name.clone(),
533 browser_download_url: asset.url.clone(),
534 })
535 .collect(),
536 matching
537 .into_iter()
538 .map(|asset| (asset.name, asset.checksum))
539 .collect::<BTreeMap<_, _>>(),
540 )
541 } else {
542 let release = self.release_for_tag(ctx, sources, &tv.version).await?;
543 (release.assets, BTreeMap::new())
544 };
545
546 let asset = select_asset(self, &assets, &tv.version, ctx, rules)?;
547 let urls = http::github_url_candidates(sources, &asset.browser_download_url);
548 let mut checksum = static_checksums
549 .get(&asset.name)
550 .map(|value| pipeline::parse_checksum(value))
551 .transpose()?;
552
553 if ctx.config.settings.verify_signatures && !ctx.config.settings.offline {
557 for url in &urls {
558 let dir = url
559 .rsplit_once('/')
560 .map(|(directory, _)| directory)
561 .unwrap_or("");
562 match pipeline::verify::signed_manifest_checksum(
563 &ctx.client,
564 &self.id,
565 dir,
566 &asset.name,
567 )
568 .await
569 {
570 Ok(Some(found)) => {
571 tracing::info!(source = %self.id, "{}", crate::i18n::tr("log.signature_verified"));
572 checksum = Some(found);
573 break;
574 }
575 Ok(None) => {}
576 Err(error) => return Err(error),
577 }
578 }
579 }
580 if checksum.is_none() && !ctx.config.settings.offline {
581 for url in &urls {
582 if let Some(found) =
583 pipeline::verify::discover_asset_checksum(&ctx.client, url).await
584 {
585 checksum = Some(found);
586 break;
587 }
588 }
589 }
590
591 Ok(SelectedArtifact {
592 asset,
593 urls,
594 checksum,
595 })
596 }
597
598 fn score_asset(&self, name: &str, ctx: &Ctx, rules: Option<&AssetRules>) -> Option<i32> {
601 let n = name.to_ascii_lowercase();
602 if n.ends_with(".sha256")
604 || n.ends_with(".asc")
605 || n.ends_with(".sig")
606 || n.ends_with(".pem")
607 || n.contains("sha256sums")
608 || n.contains("checksums")
609 {
610 return None;
611 }
612
613 let target_os = rules
614 .and_then(|rules| rules.os.as_deref())
615 .unwrap_or_else(|| os_token(ctx));
616 let os_ok = match target_os {
617 "linux" => n.contains("linux"),
618 "macos" | "darwin" => {
619 n.contains("darwin")
620 || n.contains("macos")
621 || n.contains("apple")
622 || n.contains("osx")
623 }
624 "windows" | "win" => n.contains("windows") || n.contains("win") || n.ends_with(".exe"),
625 _ => false,
626 };
627 let mut score = 0;
629 if os_ok {
630 score += 10;
631 } else if mentions_other_os_token(&n, target_os) {
632 return None; }
634
635 let target_arch = rules
636 .and_then(|rules| rules.arch.as_deref())
637 .unwrap_or_else(|| arch_token(ctx));
638 let arch_ok = match target_arch {
639 "x64" | "x86_64" | "amd64" => {
640 n.contains("x86_64") || n.contains("amd64") || n.contains("x64")
641 }
642 "arm64" | "aarch64" => n.contains("aarch64") || n.contains("arm64"),
643 "x86" | "i686" => n.contains("i686") || n.contains("i386") || n.contains("x86"),
644 "arm" | "armv7" => n.contains("armv7") || n.contains("armhf") || n.contains("arm"),
645 _ => false,
646 };
647 if arch_ok {
648 score += 10;
649 } else if mentions_other_arch_token(&n, target_arch) {
650 return None;
651 }
652
653 if ArchiveKind::from_name(&n).is_ok() {
655 score += 3;
656 }
657 if target_os == "linux" {
658 if n.contains("musl") {
659 score += 1; }
661 if n.contains("gnu") {
662 score += 1;
663 }
664 }
665 Some(score)
666 }
667}
668
669#[async_trait]
670impl Backend for GithubBackend {
671 fn id(&self) -> &str {
672 &self.id
673 }
674
675 fn default_sources(&self) -> Vec<Source> {
676 vec![
677 Source::official("github", "https://github.com/").with_index("https://api.github.com/"),
678 Source::mirror("ghproxy", "https://gh-proxy.com/https://github.com/", 10)
679 .with_index("https://gh-proxy.com/https://api.github.com/"),
680 ]
681 }
682
683 fn probe_url(&self, _ctx: &Ctx, _source: &Source) -> Option<String> {
684 None
686 }
687
688 async fn list_remote_versions(&self, ctx: &Ctx) -> Result<Vec<VersionInfo>> {
689 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
690 let releases = self.releases(ctx, &sources).await?;
691 let mut out: Vec<VersionInfo> = releases
692 .into_iter()
693 .filter(|r| !r.draft)
694 .map(|r| VersionInfo {
695 version: r.tag_name.trim_start_matches('v').to_string(),
696 stable: !r.prerelease,
697 lts: None,
698 })
699 .filter(|v| !v.version.is_empty())
700 .collect();
701 out.reverse();
703 Ok(out)
704 }
705
706 async fn resolve_version(&self, ctx: &Ctx, req: &ToolRequest) -> Result<ToolVersion> {
707 crate::backend::dynamic::validate_options(self.id(), &req.options)?;
708 let prerelease_request = match &req.spec {
709 VersionSpec::Exact(version) => crate::backend::python::is_prerelease(version),
710 VersionSpec::Prefix(channel) => {
711 matches!(channel.as_str(), "canary" | "nightly" | "beta")
712 }
713 _ => false,
714 };
715 if prerelease_request
716 && matches!(
717 ctx.config.settings.prerelease,
718 crate::config::PrereleasePolicy::Never
719 )
720 {
721 return Err(Error::VersionResolve {
722 tool: self.id().into(),
723 spec: req.spec.to_string(),
724 hint: Some("pre-release versions are disabled".into()),
725 });
726 }
727 if req
728 .options
729 .contains_key(pipeline::LOCKED_ARTIFACT_URL_OPTION)
730 {
731 if let VersionSpec::Exact(version) = &req.spec {
732 let mut resolved = ToolVersion::new(self.id(), version);
733 resolved.options = req.options.clone();
734 return Ok(resolved);
735 }
736 }
737 if let Some(catalog) = self.static_catalog(ctx, &req.options).await? {
738 let versions = static_versions(&catalog);
739 let selected = match &req.spec {
740 VersionSpec::Exact(version) => versions
741 .iter()
742 .find(|candidate| candidate.version == *version),
743 VersionSpec::Prefix(channel)
744 if matches!(channel.as_str(), "canary" | "nightly" | "beta") =>
745 {
746 versions.iter().rev().find(|candidate| {
747 !candidate.stable
748 && candidate.version.to_ascii_lowercase().contains(channel)
749 })
750 }
751 _ => crate::version::select_version_with_prerelease(
752 &req.spec,
753 &versions,
754 ctx.config.settings.prerelease,
755 ),
756 }
757 .ok_or_else(|| Error::VersionResolve {
758 tool: self.id().into(),
759 spec: req.spec.to_string(),
760 hint: Some("no matching static catalog release".into()),
761 })?;
762 let mut resolved = ToolVersion::new(self.id(), &selected.version);
763 resolved.options = req.options.clone();
764 let rules = Self::rules(&resolved.options)?;
765 let release = catalog
766 .releases
767 .iter()
768 .find(|release| {
769 release.tag.trim_start_matches('v') == selected.version.trim_start_matches('v')
770 })
771 .expect("selected version originated from catalog");
772 let assets = release
773 .assets
774 .iter()
775 .filter(|asset| static_asset_matches(asset, ctx, &rules))
776 .map(|asset| GhAsset {
777 name: asset.name.clone(),
778 browser_download_url: asset.url.clone(),
779 })
780 .collect::<Vec<_>>();
781 let asset = select_asset(self, &assets, &selected.version, ctx, &rules)?;
782 let static_asset = release
783 .assets
784 .iter()
785 .find(|candidate| candidate.name == asset.name)
786 .expect("selected asset originated from catalog");
787 resolved.options.insert(
788 pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
789 static_asset.url.clone(),
790 );
791 resolved.options.insert(
792 pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
793 static_asset.name.clone(),
794 );
795 resolved.options.insert(
796 pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
797 static_asset.checksum.clone(),
798 );
799 return Ok(resolved);
800 }
801 if let VersionSpec::Exact(v) = &req.spec {
804 if crate::backend::python::is_prerelease(v)
805 && matches!(
806 ctx.config.settings.prerelease,
807 crate::config::PrereleasePolicy::Never
808 )
809 {
810 return Err(Error::VersionResolve {
811 tool: self.id().into(),
812 spec: v.clone(),
813 hint: Some("pre-release versions are disabled".into()),
814 });
815 }
816 let mut tv = ToolVersion::new(self.id(), v.clone());
817 tv.options = req.options.clone();
818 return Ok(tv);
819 }
820 let versions = self.list_remote_versions(ctx).await?;
821 let chosen = match &req.spec {
822 VersionSpec::Prefix(channel)
823 if matches!(channel.as_str(), "canary" | "nightly" | "beta") =>
824 {
825 versions.iter().rev().find(|candidate| {
826 !candidate.stable && candidate.version.to_ascii_lowercase().contains(channel)
827 })
828 }
829 _ => crate::version::select_version_with_prerelease(
830 &req.spec,
831 &versions,
832 ctx.config.settings.prerelease,
833 ),
834 }
835 .ok_or_else(|| Error::VersionResolve {
836 tool: self.id().to_string(),
837 spec: req.spec.to_string(),
838 hint: Some("no matching release under prerelease policy".into()),
839 })?;
840 let mut tv = ToolVersion::new(self.id(), chosen.version.clone());
841 tv.options = req.options.clone();
842 Ok(tv)
843 }
844
845 async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
846 let ctx = ictx.ctx;
847 crate::backend::dynamic::validate_options(self.id(), &tv.options)?;
848 let rules = Self::rules(&tv.options)?;
849 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
850 let attestation = self.attestation(ctx, &sources);
851 let selected = self
852 .select_install_artifact(ctx, tv, &rules, &sources)
853 .await?;
854 let locator = github_install_locator_for_artifact(
855 ctx,
856 self.id(),
857 tv,
858 &selected.asset,
859 selected.checksum.as_ref(),
860 )?;
861 let _identity_lock = acquire_github_install_lock(&locator).await?;
868 if validate_complete_install_identity(ctx, self, tv, &locator)? {
869 return Ok(());
870 }
871 match ArchiveKind::from_name(&selected.asset.name) {
873 Ok(kind) => {
874 let plan = InstallPlan {
875 tool: self.id().to_string(),
876 version: tv.version.clone(),
877 urls: selected.urls,
878 file_name: selected.asset.name,
879 kind,
880 checksum: selected.checksum,
881 strip_root: rules.strip_components == 0,
884 subdir: tv
885 .options
886 .get(pipeline::LOCKED_ARTIFACT_SUBDIR_OPTION)
887 .or_else(|| tv.options.get("catalog-subdir"))
888 .map(PathBuf::from),
889 };
890 let pctx = PipelineCtx {
891 client: &ctx.client,
892 dirs: &ctx.dirs,
893 cas: &ctx.cas,
894 link_mode: ctx.config.settings.link_mode,
895 show_progress: ctx.show_progress,
896 offline: ctx.config.settings.offline,
897 require_checksums: ctx.config.settings.require_checksums,
898 };
899 pipeline::run_with_attestation_unfinalized_at(
900 &plan,
901 &pctx,
902 attestation.as_ref(),
903 &locator,
904 )
905 .await?;
906 postprocess_archive(ctx, &locator, &rules)?;
907 }
908 Err(_) => {
909 let exe_name = normalize_executable_name(
911 rules.rename.as_deref().unwrap_or(&self.repo),
912 ctx.platform.os,
913 );
914 pipeline::install_single_binary_unfinalized_at(
915 &ctx.client,
916 &ctx.dirs,
917 &locator,
918 &selected.urls,
919 exe_name.trim_end_matches(ctx.platform.os.exe_suffix()),
920 &selected.asset.name,
921 ctx.platform.os,
922 selected.checksum.as_ref(),
923 ctx.show_progress,
924 ctx.config.settings.offline,
925 ctx.config.settings.require_checksums,
926 attestation.as_ref(),
927 )
928 .await?;
929 }
930 }
931 finalize_dynamic_install(&locator)?;
932 Ok(())
933 }
934
935 async fn uninstall(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
936 let locator = github_install_locator(ctx, self.id(), tv)?;
937 let install_root = locator.install_root();
938 match std::fs::symlink_metadata(install_root) {
939 Ok(metadata) if metadata.file_type().is_dir() => {
940 std::fs::remove_dir_all(install_root)
941 .map_err(|error| Error::io(install_root, error))?;
942 }
943 Ok(_) => {
944 std::fs::remove_file(install_root)
945 .map_err(|error| Error::io(install_root, error))?;
946 }
947 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
948 Err(error) => return Err(Error::io(install_root, error)),
949 }
950 Ok(())
951 }
952
953 fn list_installed(&self, ctx: &Ctx) -> Result<Vec<String>> {
954 let report = crate::inventory::scan_installs(
955 &ctx.dirs.installs,
956 &crate::inventory::ScanOptions::default(),
957 )?;
958 Ok(report
959 .installs
960 .iter()
961 .filter(|install| {
962 let identity = &install.manifest.identity;
963 identity.tool == self.id
964 && identity.platform == ctx.platform.to_string()
965 && identity.scope == InstallScope::Isolated
966 && github_install_candidate_is_valid(ctx, &install.install_root, identity)
967 .unwrap_or(false)
968 })
969 .map(|install| install.manifest.identity.version.clone())
970 .collect::<std::collections::BTreeSet<_>>()
971 .into_iter()
972 .collect())
973 }
974
975 fn ensure_post_install(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
976 let locator = github_install_locator(ctx, self.id(), tv)?;
977 if !validate_complete_install_identity(ctx, self, tv, &locator)? {
978 return Err(Error::other(format!(
979 "dynamic tool `{}@{}` is not completely installed",
980 self.id(),
981 tv.version
982 )));
983 }
984 Ok(())
985 }
986
987 fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
988 let root = github_install_locator(ctx, self.id(), tv)?
989 .install_root()
990 .to_path_buf();
991 let bin = root.join("bin");
993 if bin.exists() {
994 Ok(vec![bin, root])
995 } else {
996 Ok(vec![root])
997 }
998 }
999
1000 fn bin_names(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>> {
1001 let paths = self.bin_paths(ctx, tv)?;
1002 let discovered = crate::backend::bin_names_in_dirs(&paths);
1003 if discovered.is_empty() {
1004 Ok(vec![self.repo.clone()])
1005 } else {
1006 Ok(discovered)
1007 }
1008 }
1009
1010 fn dynamic_install_identity(
1011 &self,
1012 ctx: &Ctx,
1013 tv: &ToolVersion,
1014 ) -> Result<Option<InstallIdentity>> {
1015 pipeline::locked_artifact(tv)?
1016 .is_some()
1017 .then(|| github_install_locator_for(&ctx.dirs, ctx.platform, self.id(), tv))
1018 .transpose()
1019 .map(|locator| locator.map(|locator| locator.identity().clone()))
1020 }
1021
1022 fn validate_dynamic_install(
1023 &self,
1024 ctx: &Ctx,
1025 _tv: &ToolVersion,
1026 install_root: &Path,
1027 identity: &InstallIdentity,
1028 ) -> Result<bool> {
1029 github_install_candidate_is_valid(ctx, install_root, identity)
1030 }
1031}
1032
1033pub(crate) fn github_install_locator(
1034 ctx: &Ctx,
1035 backend_id: &str,
1036 version: &ToolVersion,
1037) -> Result<InstallLocator> {
1038 if pipeline::locked_artifact(version)?.is_some() {
1039 return github_install_locator_for(&ctx.dirs, ctx.platform, backend_id, version);
1040 }
1041 github_installed_locator(ctx, backend_id, version)?.ok_or_else(|| {
1042 Error::other(format!(
1043 "GitHub install identity for `{backend_id}@{}` has no unique complete artifact candidate; reinstall it or use a lockfile with exact artifact identity",
1044 version.version
1045 ))
1046 })
1047}
1048
1049pub fn github_install_locator_for(
1053 dirs: &crate::dirs::Dirs,
1054 platform: crate::platform::Platform,
1055 backend_id: &str,
1056 version: &ToolVersion,
1057) -> Result<InstallLocator> {
1058 if let Some(artifact) = pipeline::locked_artifact(version)? {
1059 return github_install_locator_for_artifact_parts(
1060 dirs,
1061 platform,
1062 backend_id,
1063 version,
1064 &GhAsset {
1065 name: artifact.file_name,
1066 browser_download_url: artifact.url,
1067 },
1068 artifact
1069 .checksum
1070 .as_deref()
1071 .map(pipeline::parse_checksum)
1072 .transpose()?
1073 .as_ref(),
1074 );
1075 }
1076 Err(Error::other(format!(
1077 "GitHub install identity for `{backend_id}@{}` requires locked artifact material",
1078 version.version
1079 )))
1080}
1081
1082fn github_installed_locator(
1083 ctx: &Ctx,
1084 backend_id: &str,
1085 version: &ToolVersion,
1086) -> Result<Option<InstallLocator>> {
1087 let expected_options = crate::backend::dynamic::identity_options(backend_id, &version.options)?;
1088 let report = crate::inventory::scan_installs(
1089 &ctx.dirs.installs,
1090 &crate::inventory::ScanOptions::default(),
1091 )?;
1092 let mut candidates = report.installs.into_iter().filter(|install| {
1093 let identity = &install.manifest.identity;
1094 identity.tool == backend_id
1095 && identity.version == version.version
1096 && identity.platform == ctx.platform.to_string()
1097 && identity.scope == InstallScope::Isolated
1098 && identity.material_options == expected_options
1099 && github_install_candidate_is_valid(ctx, &install.install_root, identity)
1100 .unwrap_or(false)
1101 });
1102 let Some(first) = candidates.next() else {
1103 return Ok(None);
1104 };
1105 if candidates.next().is_some() {
1106 return Err(Error::other(format!(
1107 "GitHub install identity for `{backend_id}@{}` is ambiguous across multiple artifact variants; use a lockfile with exact artifact identity or uninstall the unwanted variant",
1108 version.version
1109 )));
1110 }
1111 InstallLocator::new(&ctx.dirs, first.manifest.identity).map(Some)
1112}
1113
1114fn github_install_locator_for_artifact(
1115 ctx: &Ctx,
1116 backend_id: &str,
1117 version: &ToolVersion,
1118 asset: &GhAsset,
1119 checksum: Option<&pipeline::Checksum>,
1120) -> Result<InstallLocator> {
1121 github_install_locator_for_artifact_parts(
1122 &ctx.dirs,
1123 ctx.platform,
1124 backend_id,
1125 version,
1126 asset,
1127 checksum,
1128 )
1129}
1130
1131fn github_install_locator_for_artifact_parts(
1132 dirs: &crate::dirs::Dirs,
1133 platform: crate::platform::Platform,
1134 backend_id: &str,
1135 version: &ToolVersion,
1136 asset: &GhAsset,
1137 checksum: Option<&pipeline::Checksum>,
1138) -> Result<InstallLocator> {
1139 let subdir = version
1140 .options
1141 .get(pipeline::LOCKED_ARTIFACT_SUBDIR_OPTION)
1142 .or_else(|| version.options.get("catalog-subdir"));
1143 let materials = github_artifact_materials(asset, checksum, subdir.map(String::as_str));
1144 let identity = InstallIdentity::new(
1145 backend_id,
1146 &version.version,
1147 platform.to_string(),
1148 InstallScope::Isolated,
1149 &version.options,
1150 Vec::new(),
1151 materials,
1152 )?;
1153 InstallLocator::new(dirs, identity)
1154}
1155
1156fn github_artifact_materials(
1157 asset: &GhAsset,
1158 checksum: Option<&pipeline::Checksum>,
1159 subdir: Option<&str>,
1160) -> BTreeMap<String, String> {
1161 let mut materials = BTreeMap::from([("artifact-file".into(), asset.name.clone())]);
1162 if let Some(checksum) = checksum {
1163 materials.insert("artifact-checksum".into(), format_checksum(checksum));
1164 } else {
1165 materials.insert(
1166 "artifact-url-blake3".into(),
1167 github_artifact_url_hash(&asset.browser_download_url),
1168 );
1169 }
1170 if let Some(subdir) = subdir {
1171 materials.insert("artifact-subdir".into(), subdir.to_string());
1172 }
1173 materials
1174}
1175
1176fn github_artifact_url_hash(url: &str) -> String {
1177 let mut hasher = blake3::Hasher::new_derive_key("osdk-github-artifact-url-v1");
1178 hasher.update(url.as_bytes());
1179 hasher.finalize().to_hex().to_string()
1180}
1181
1182fn format_checksum(checksum: &pipeline::Checksum) -> String {
1183 let algorithm = match checksum.algo {
1184 pipeline::HashAlgo::Sha256 => "sha256",
1185 pipeline::HashAlgo::Sha512 => "sha512",
1186 pipeline::HashAlgo::Blake3 => "blake3",
1187 };
1188 format!("{algorithm}:{}", checksum.hex.to_ascii_lowercase())
1189}
1190
1191async fn acquire_github_install_lock(locator: &InstallLocator) -> Result<crate::lock::FileLock> {
1192 let path = locator.lock_path().to_path_buf();
1193 tokio::task::spawn_blocking(move || crate::lock::FileLock::acquire(path))
1194 .await
1195 .map_err(|error| Error::other(format!("GitHub install lock task failed: {error}")))?
1196}
1197
1198fn validate_complete_install_identity(
1203 ctx: &Ctx,
1204 backend: &GithubBackend,
1205 version: &ToolVersion,
1206 locator: &InstallLocator,
1207) -> Result<bool> {
1208 let install_root = locator.install_root().to_path_buf();
1209 if !is_regular_file(&install_root.join(".osdk-complete")) {
1210 return Ok(false);
1211 }
1212 match github_install_candidate_is_valid(ctx, &install_root, locator.identity()) {
1213 Ok(true) => {}
1214 Ok(false) => {
1215 return Err(Error::other(format!(
1216 "refusing to reuse complete dynamic tool `{}@{}` with missing, legacy, or invalid install identity; uninstall and reinstall it",
1217 backend.id(), version.version
1218 )));
1219 }
1220 Err(error) => {
1221 return Err(Error::other(format!(
1222 "refusing to reuse complete dynamic tool `{}@{}` installed with a different identity or invalid receipt; uninstall and reinstall it: {error}",
1223 backend.id(), version.version
1224 )));
1225 }
1226 }
1227 Ok(true)
1228}
1229
1230pub fn github_install_candidate_is_valid(
1233 ctx: &Ctx,
1234 install_root: &Path,
1235 identity: &InstallIdentity,
1236) -> Result<bool> {
1237 github_install_candidate_is_valid_for_dirs(&ctx.dirs, install_root, identity)
1238}
1239
1240pub fn github_install_candidate_is_valid_for_dirs(
1243 dirs: &crate::dirs::Dirs,
1244 install_root: &Path,
1245 identity: &InstallIdentity,
1246) -> Result<bool> {
1247 if identity.scope != InstallScope::Isolated
1248 || !is_regular_file(&install_root.join(".osdk-complete"))
1249 || !is_regular_file(&DynamicToolManifest::manifest_path(install_root))
1250 || !is_regular_file(&install_root.join(".osdk-artifact.json"))
1251 {
1252 return Ok(false);
1253 }
1254 let locator = InstallLocator::new(dirs, identity.clone())?;
1255 if !locator.validates_existing_install_root(install_root) {
1256 return Ok(false);
1257 }
1258 let manifest = match DynamicToolManifest::load(install_root) {
1259 Ok(manifest) => manifest,
1260 Err(_) => return Ok(false),
1261 };
1262 if !manifest.matches_identity(identity) {
1263 return Err(Error::other(format!(
1264 "GitHub install identity mismatch at {}",
1265 DynamicToolManifest::manifest_path(install_root).display()
1266 )));
1267 }
1268 let Some(receipt) = pipeline::artifact_receipt_at(install_root) else {
1269 return Ok(false);
1270 };
1271 if !github_receipt_matches_identity(&receipt, identity) {
1272 return Err(Error::other(format!(
1273 "GitHub artifact receipt does not match install identity at {}",
1274 install_root.display()
1275 )));
1276 }
1277 let canonical_root =
1278 dunce::canonicalize(install_root).map_err(|error| Error::io(install_root, error))?;
1279 for bin in &manifest.bins {
1280 let path = install_root.join(&bin.path);
1281 let canonical = dunce::canonicalize(&path).map_err(|error| Error::io(&path, error))?;
1282 if !canonical.is_file() || !canonical.starts_with(&canonical_root) {
1283 return Err(Error::other(format!(
1284 "GitHub inventory bin `{}` does not resolve inside {}",
1285 bin.name,
1286 install_root.display()
1287 )));
1288 }
1289 }
1290 Ok(true)
1291}
1292
1293fn github_receipt_matches_identity(
1294 receipt: &pipeline::ArtifactReceipt,
1295 identity: &InstallIdentity,
1296) -> bool {
1297 let Some(expected_file) = identity.materials.get("artifact-file") else {
1298 return false;
1299 };
1300 if &receipt.file_name != expected_file {
1301 return false;
1302 }
1303 let subdir_is_consistent = match (
1304 identity.materials.get("artifact-subdir"),
1305 identity.material_options.get("catalog-subdir"),
1306 ) {
1307 (Some(material), Some(option)) => material == option,
1308 (None, None) => true,
1309 _ => false,
1310 };
1311 if !subdir_is_consistent {
1312 return false;
1313 }
1314 match (
1315 identity.materials.get("artifact-checksum"),
1316 identity.materials.get("artifact-url-blake3"),
1317 ) {
1318 (Some(expected), None) => {
1319 matching_locked_checksum(receipt.checksum.as_deref(), Some(expected))
1320 }
1321 (None, Some(expected)) => {
1322 github_artifact_url_hash(&receipt.url) == *expected
1323 && receipt
1324 .checksum
1325 .as_deref()
1326 .is_none_or(|checksum| pipeline::parse_checksum(checksum).is_ok())
1327 }
1328 _ => false,
1329 }
1330}
1331
1332fn is_regular_file(path: &Path) -> bool {
1333 std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file())
1334}
1335
1336fn matching_locked_checksum(actual: Option<&str>, expected: Option<&str>) -> bool {
1337 match (actual, expected) {
1338 (Some(actual), Some(expected)) => {
1339 let Ok(actual) = pipeline::parse_checksum(actual) else {
1340 return false;
1341 };
1342 let Ok(expected) = pipeline::parse_checksum(expected) else {
1343 return false;
1344 };
1345 actual.algo == expected.algo && actual.hex.eq_ignore_ascii_case(&expected.hex)
1346 }
1347 (None, None) => true,
1348 _ => false,
1349 }
1350}
1351
1352#[cfg(test)]
1353fn locked_artifact_url_must_match(actual: &str, expected: &str, checksum: Option<&str>) -> bool {
1354 checksum.is_some() || actual == expected
1355}
1356
1357fn write_dynamic_inventory(locator: &InstallLocator) -> Result<()> {
1358 let install_root = locator.install_root().to_path_buf();
1359 let mut manifest = DynamicToolManifest::from_identity(locator.identity().clone())?;
1360 let bin = install_root.join("bin");
1361 let bin_dirs = if bin.exists() {
1362 vec![bin, install_root.clone()]
1363 } else {
1364 vec![install_root.clone()]
1365 };
1366 for bin_dir in bin_dirs {
1367 for name in crate::backend::bin_names_in_dirs(std::slice::from_ref(&bin_dir)) {
1368 let target = executable_in_dir(&bin_dir, &name).ok_or_else(|| {
1369 Error::other(format!("installed GitHub binary `{name}` disappeared"))
1370 })?;
1371 let canonical_root = dunce::canonicalize(&install_root)
1372 .map_err(|error| Error::io(&install_root, error))?;
1373 let canonical_target =
1374 dunce::canonicalize(&target).map_err(|error| Error::io(&target, error))?;
1375 let relative = canonical_target
1376 .strip_prefix(&canonical_root)
1377 .map_err(|_| {
1378 Error::other(format!(
1379 "installed GitHub binary `{name}` resolves outside {}",
1380 install_root.display()
1381 ))
1382 })?;
1383 manifest.bins.push(DynamicToolBin {
1384 name,
1385 path: relative.to_string_lossy().replace('\\', "/"),
1386 });
1387 }
1388 }
1389 manifest
1390 .bins
1391 .sort_by(|left, right| left.name.cmp(&right.name));
1392 manifest
1393 .bins
1394 .dedup_by(|left, right| left.name == right.name);
1395 manifest.write_atomic(&install_root)
1396}
1397
1398fn finalize_dynamic_install(locator: &InstallLocator) -> Result<()> {
1399 let install_root = locator.install_root().to_path_buf();
1400 let marker = install_root.join(".osdk-complete");
1401 if let Err(error) = write_dynamic_inventory(locator) {
1402 let _ = std::fs::remove_dir_all(&install_root);
1403 return Err(error);
1404 }
1405 std::fs::write(&marker, b"").map_err(|error| {
1406 let _ = std::fs::remove_dir_all(&install_root);
1407 Error::io(&marker, error)
1408 })
1409}
1410
1411fn executable_in_dir(directory: &std::path::Path, name: &str) -> Option<PathBuf> {
1412 #[cfg(windows)]
1413 let candidates = [
1414 format!("{name}.exe"),
1415 format!("{name}.cmd"),
1416 format!("{name}.bat"),
1417 name.to_string(),
1418 ];
1419 #[cfg(not(windows))]
1420 let candidates = [name.to_string()];
1421 candidates
1422 .into_iter()
1423 .map(|candidate| directory.join(candidate))
1424 .find(|candidate| candidate.is_file())
1425}
1426
1427fn tag_candidates(version: &str) -> Vec<String> {
1428 let mut tags = vec![version.to_string()];
1429 if !version.starts_with('v') {
1430 tags.push(format!("v{version}"));
1431 }
1432 tags
1433}
1434
1435fn parse_release_tags(atom: &str, owner: &str, repo: &str) -> Vec<String> {
1436 if !looks_like_atom_feed(atom) {
1437 return Vec::new();
1438 }
1439 let mut seen = HashSet::new();
1440 let mut tags = Vec::new();
1441 for href in markup_attribute_values(atom, "href") {
1442 let href = decode_markup_entities(&href);
1443 let Ok(url) = reqwest::Url::parse(&href) else {
1444 continue;
1445 };
1446 if url.scheme() != "https" || url.host_str() != Some("github.com") {
1447 continue;
1448 }
1449 let Some(encoded_tag) = repository_release_tail(url.path(), owner, repo, "tag") else {
1450 continue;
1451 };
1452 if encoded_tag.is_empty() || encoded_tag.contains('/') {
1453 continue;
1454 }
1455 let Some(tag) = decode_url_segment(encoded_tag) else {
1456 continue;
1457 };
1458 if !tag.is_empty() && seen.insert(tag.clone()) {
1459 tags.push(tag);
1460 }
1461 }
1462 tags
1463}
1464
1465fn parse_release_assets(html: &str, owner: &str, repo: &str, tag: &str) -> Vec<GhAsset> {
1466 if !looks_like_expanded_assets(html) {
1467 return Vec::new();
1468 }
1469 let base = reqwest::Url::parse("https://github.com/").expect("valid GitHub base URL");
1470 let mut seen = HashSet::new();
1471 let mut assets = Vec::new();
1472 for href in markup_attribute_values(html, "href") {
1473 let href = decode_markup_entities(&href);
1474 let Ok(url) = base.join(&href) else {
1475 continue;
1476 };
1477 if url.scheme() != "https"
1478 || url.host_str() != Some("github.com")
1479 || !url.username().is_empty()
1480 || url.password().is_some()
1481 {
1482 continue;
1483 }
1484 let Some(tail) = repository_release_tail(url.path(), owner, repo, "download") else {
1485 continue;
1486 };
1487 let Some((encoded_tag, encoded_name)) = tail.split_once('/') else {
1488 continue;
1489 };
1490 if encoded_name.is_empty() || encoded_name.contains('/') {
1491 continue;
1492 }
1493 let Some(actual_tag) = decode_url_segment(encoded_tag) else {
1494 continue;
1495 };
1496 let Some(name) = decode_url_segment(encoded_name) else {
1497 continue;
1498 };
1499 if actual_tag != tag || name.is_empty() || name.contains(['/', '\\', '\0']) {
1500 continue;
1501 }
1502 let download_url = url.to_string();
1503 if seen.insert(download_url.clone()) {
1504 assets.push(GhAsset {
1505 name,
1506 browser_download_url: download_url,
1507 });
1508 }
1509 }
1510 assets
1511}
1512
1513fn looks_like_atom_feed(markup: &str) -> bool {
1514 let lower = markup.to_ascii_lowercase();
1515 lower.contains("<feed")
1516 && lower.contains("http://www.w3.org/2005/atom")
1517 && lower.contains("<entry")
1518}
1519
1520fn looks_like_expanded_assets(markup: &str) -> bool {
1521 let lower = markup.to_ascii_lowercase();
1522 lower.contains("<ul")
1523 && (lower.contains("list-style-none") || lower.contains("release-entry-list"))
1524}
1525
1526fn repository_release_tail<'a>(
1527 path: &'a str,
1528 owner: &str,
1529 repo: &str,
1530 kind: &str,
1531) -> Option<&'a str> {
1532 let path = path.strip_prefix('/')?;
1533 let (actual_owner, path) = path.split_once('/')?;
1534 let (actual_repo, path) = path.split_once('/')?;
1535 let prefix = format!("releases/{kind}/");
1536 actual_owner
1537 .eq_ignore_ascii_case(owner)
1538 .then_some(())
1539 .and_then(|_| actual_repo.eq_ignore_ascii_case(repo).then_some(()))
1540 .and_then(|_| path.strip_prefix(&prefix))
1541}
1542
1543fn markup_attribute_values(markup: &str, attribute: &str) -> Vec<String> {
1544 let bytes = markup.as_bytes();
1545 let needle = attribute.as_bytes();
1546 let mut values = Vec::new();
1547 let mut cursor = 0;
1548 while cursor + needle.len() < bytes.len() {
1549 let Some(offset) = bytes[cursor..]
1550 .windows(needle.len())
1551 .position(|window| window.eq_ignore_ascii_case(needle))
1552 else {
1553 break;
1554 };
1555 let start = cursor + offset;
1556 let boundary_before =
1557 start == 0 || bytes[start - 1].is_ascii_whitespace() || bytes[start - 1] == b'<';
1558 let mut index = start + needle.len();
1559 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
1560 index += 1;
1561 }
1562 if !boundary_before || bytes.get(index) != Some(&b'=') {
1563 cursor = start + needle.len();
1564 continue;
1565 }
1566 index += 1;
1567 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
1568 index += 1;
1569 }
1570 let Some("e @ (b'\'' | b'"')) = bytes.get(index) else {
1571 cursor = index;
1572 continue;
1573 };
1574 index += 1;
1575 let value_start = index;
1576 while index < bytes.len() && bytes[index] != quote {
1577 index += 1;
1578 }
1579 if index < bytes.len() {
1580 values.push(String::from_utf8_lossy(&bytes[value_start..index]).into_owned());
1581 cursor = index + 1;
1582 } else {
1583 break;
1584 }
1585 }
1586 values
1587}
1588
1589fn decode_markup_entities(value: &str) -> String {
1590 value
1591 .replace("&", "&")
1592 .replace(""", "\"")
1593 .replace("'", "'")
1594 .replace("'", "'")
1595 .replace("<", "<")
1596 .replace(">", ">")
1597}
1598
1599fn decode_url_segment(encoded: &str) -> Option<String> {
1600 let bytes = encoded.as_bytes();
1601 let mut decoded = Vec::with_capacity(bytes.len());
1602 let mut index = 0;
1603 while index < bytes.len() {
1604 if bytes[index] == b'%' {
1605 let high = *bytes.get(index + 1)?;
1606 let low = *bytes.get(index + 2)?;
1607 decoded.push(hex_value(high)? * 16 + hex_value(low)?);
1608 index += 3;
1609 } else {
1610 decoded.push(bytes[index]);
1611 index += 1;
1612 }
1613 }
1614 let decoded = String::from_utf8(decoded).ok()?;
1615 (!decoded.chars().any(char::is_control)).then_some(decoded)
1616}
1617
1618fn hex_value(byte: u8) -> Option<u8> {
1619 match byte {
1620 b'0'..=b'9' => Some(byte - b'0'),
1621 b'a'..=b'f' => Some(byte - b'a' + 10),
1622 b'A'..=b'F' => Some(byte - b'A' + 10),
1623 _ => None,
1624 }
1625}
1626
1627fn valid_repository_component(value: &str) -> bool {
1628 !value.is_empty()
1629 && value != "."
1630 && value != ".."
1631 && value.chars().all(|character| {
1632 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
1633 })
1634}
1635
1636fn select_asset(
1637 backend: &GithubBackend,
1638 assets: &[GhAsset],
1639 version: &str,
1640 ctx: &Ctx,
1641 rules: &AssetRules,
1642) -> Result<GhAsset> {
1643 if let Some(template) = &rules.template {
1644 let rendered = render_asset_template_version(template, version, ctx, rules);
1645 let matching: Vec<_> = assets
1646 .iter()
1647 .filter(|asset| asset.name == rendered)
1648 .cloned()
1649 .collect();
1650 return unique_asset(matching, "asset-template");
1651 }
1652 if let Some(regex) = &rules.regex {
1653 let matching = assets
1654 .iter()
1655 .filter(|asset| regex.is_match(&asset.name))
1656 .cloned()
1657 .collect();
1658 return unique_asset(matching, "asset-regex");
1659 }
1660 let mut scored = assets
1661 .iter()
1662 .filter_map(|asset| {
1663 backend
1664 .score_asset(&asset.name, ctx, Some(rules))
1665 .map(|score| (score, asset.clone()))
1666 })
1667 .collect::<Vec<_>>();
1668 scored.sort_by_key(|item| item.0);
1669 scored.pop().map(|(_, asset)| asset).ok_or_else(|| {
1670 Error::other(format!(
1671 "no release asset for {} matches this platform ({})",
1672 backend.id(),
1673 ctx.platform
1674 ))
1675 })
1676}
1677
1678fn unique_asset(assets: Vec<GhAsset>, rule: &str) -> Result<GhAsset> {
1679 if assets.len() != 1 {
1680 return Err(Error::other(format!(
1681 "{rule} matched {} assets (expected exactly 1)",
1682 assets.len()
1683 )));
1684 }
1685 Ok(assets.into_iter().next().unwrap())
1686}
1687
1688fn render_asset_template(template: &str, ctx: &Ctx, rules: &AssetRules) -> String {
1689 template
1690 .replace("{os}", rules.os.as_deref().unwrap_or_else(|| os_token(ctx)))
1691 .replace(
1692 "{arch}",
1693 rules.arch.as_deref().unwrap_or_else(|| arch_token(ctx)),
1694 )
1695 .replace(
1696 "{libc}",
1697 rules.libc.as_deref().unwrap_or_else(|| libc_token(ctx)),
1698 )
1699}
1700
1701fn render_asset_template_version(
1702 template: &str,
1703 version: &str,
1704 ctx: &Ctx,
1705 rules: &AssetRules,
1706) -> String {
1707 render_asset_template(template, ctx, rules).replace("{version}", version)
1708}
1709
1710fn static_asset_matches(asset: &StaticAsset, ctx: &Ctx, rules: &AssetRules) -> bool {
1711 asset.os == rules.os.as_deref().unwrap_or_else(|| os_token(ctx))
1712 && asset.arch == rules.arch.as_deref().unwrap_or_else(|| arch_token(ctx))
1713 && asset
1714 .libc
1715 .as_deref()
1716 .is_none_or(|libc| libc == rules.libc.as_deref().unwrap_or_else(|| libc_token(ctx)))
1717}
1718
1719fn static_asset_url_is_persistable(value: &str) -> bool {
1720 reqwest::Url::parse(value).ok().is_some_and(|url| {
1721 matches!(url.scheme(), "http" | "https")
1722 && !url.cannot_be_a_base()
1723 && url.host_str().is_some()
1724 && url.username().is_empty()
1725 && url.password().is_none()
1726 && url.query().is_none()
1727 && url.fragment().is_none()
1728 })
1729}
1730
1731fn static_versions(catalog: &StaticCatalog) -> Vec<VersionInfo> {
1732 let mut versions = catalog
1733 .releases
1734 .iter()
1735 .map(|release| VersionInfo {
1736 version: release.tag.trim_start_matches('v').into(),
1737 stable: !release.prerelease,
1738 lts: None,
1739 })
1740 .collect::<Vec<_>>();
1741 versions
1742 .sort_by(|left, right| crate::backend::python::cmp_versions(&left.version, &right.version));
1743 versions
1744}
1745
1746fn os_token(ctx: &Ctx) -> &'static str {
1747 match ctx.platform.os {
1748 Os::Linux => "linux",
1749 Os::Macos => "macos",
1750 Os::Windows => "windows",
1751 }
1752}
1753
1754fn arch_token(ctx: &Ctx) -> &'static str {
1755 match ctx.platform.arch {
1756 Arch::X64 => "x64",
1757 Arch::Arm64 => "arm64",
1758 Arch::X86 => "x86",
1759 Arch::Arm => "arm",
1760 }
1761}
1762
1763fn libc_token(ctx: &Ctx) -> &'static str {
1764 match ctx.platform.libc {
1765 crate::platform::Libc::Glibc => "gnu",
1766 crate::platform::Libc::Musl => "musl",
1767 crate::platform::Libc::None => "none",
1768 }
1769}
1770
1771fn verify_catalog_bytes(source: &str, expected: &str, bytes: &[u8]) -> Result<()> {
1772 let actual = pipeline::verify::hash_bytes(bytes, pipeline::HashAlgo::Sha256);
1773 if actual.eq_ignore_ascii_case(expected.trim()) {
1774 Ok(())
1775 } else {
1776 Err(Error::ChecksumMismatch {
1777 name: source.into(),
1778 expected: expected.into(),
1779 actual,
1780 })
1781 }
1782}
1783
1784fn static_catalog_cache(ctx: &Ctx, digest: &str) -> PathBuf {
1785 ctx.dirs
1786 .remote_cache()
1787 .join(format!("github-static-catalog-{}.json", digest.trim()))
1788}
1789
1790fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
1791 if let Some(parent) = path.parent() {
1792 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
1793 }
1794 let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
1795 std::fs::write(&temporary, bytes).map_err(|error| Error::io(&temporary, error))?;
1796 std::fs::rename(&temporary, path).map_err(|error| Error::io(path, error))
1797}
1798
1799fn postprocess_archive(ctx: &Ctx, locator: &InstallLocator, rules: &AssetRules) -> Result<()> {
1800 if rules.bins.is_empty() && rules.rename.is_none() && rules.strip_components == 0 {
1801 return Ok(());
1802 }
1803 let install = locator.install_root().to_path_buf();
1804 let result = (|| {
1805 let base = strip_components_root(&install, rules.strip_components)?;
1806 let bin_dir = install.join("bin");
1807 crate::dirs::create_dir_all(&bin_dir)?;
1808 let bins = if rules.bins.is_empty() {
1809 Vec::new()
1810 } else {
1811 rules.bins.clone()
1812 };
1813 if rules.rename.is_some() && bins.len() != 1 {
1814 return Err(Error::config(
1815 "rename requires exactly one bin or bins entry",
1816 ));
1817 }
1818 for source in bins {
1819 let source_path = base.join(&source);
1820 if !source_path.is_file() {
1821 return Err(Error::other(format!(
1822 "configured GitHub binary is missing: {}",
1823 source.display()
1824 )));
1825 }
1826 let name = rules
1827 .rename
1828 .as_deref()
1829 .map(str::to_string)
1830 .or_else(|| {
1831 source
1832 .file_name()
1833 .map(|name| name.to_string_lossy().into_owned())
1834 })
1835 .ok_or_else(|| Error::config("configured bin has no filename"))?;
1836 let destination = bin_dir.join(normalize_executable_name(&name, ctx.platform.os));
1837 std::fs::copy(&source_path, &destination)
1838 .map_err(|error| Error::io(&destination, error))?;
1839 #[cfg(unix)]
1840 {
1841 use std::os::unix::fs::PermissionsExt;
1842 std::fs::set_permissions(&destination, std::fs::Permissions::from_mode(0o755))
1843 .map_err(|error| Error::io(&destination, error))?;
1844 }
1845 }
1846 Ok(())
1847 })();
1848 if let Err(error) = result {
1849 let _ = std::fs::remove_dir_all(&install);
1850 return Err(error);
1851 }
1852 Ok(())
1853}
1854
1855fn strip_components_root(root: &std::path::Path, count: usize) -> Result<PathBuf> {
1856 let mut selected = root.to_path_buf();
1857 for _ in 0..count {
1858 let mut children = std::fs::read_dir(&selected)
1859 .map_err(|error| Error::io(&selected, error))?
1860 .filter_map(|entry| entry.ok())
1861 .filter(|entry| !entry.file_name().to_string_lossy().starts_with(".osdk-"))
1862 .collect::<Vec<_>>();
1863 if children.len() != 1 || !children[0].path().is_dir() {
1864 return Err(Error::other(format!(
1865 "strip-components cannot descend through {}",
1866 selected.display()
1867 )));
1868 }
1869 selected = children.remove(0).path();
1870 }
1871 Ok(selected)
1872}
1873
1874fn normalize_executable_name(name: &str, os: Os) -> String {
1875 if os == Os::Windows && !name.to_ascii_lowercase().ends_with(".exe") {
1876 format!("{name}.exe")
1877 } else {
1878 name.to_string()
1879 }
1880}
1881
1882fn mentions_other_os_token(name: &str, os: &str) -> bool {
1883 let others: &[&str] = match os {
1884 "linux" => &["darwin", "apple", "macos", "windows", ".exe"],
1885 "macos" | "darwin" => &["linux", "windows", ".exe"],
1886 "windows" | "win" => &["linux", "darwin", "apple", "macos"],
1887 _ => &[],
1888 };
1889 others.iter().any(|o| name.contains(o))
1890}
1891
1892fn mentions_other_arch_token(name: &str, arch: &str) -> bool {
1893 let others: &[&str] = match arch {
1894 "x64" | "x86_64" | "amd64" => &["aarch64", "arm64"],
1895 "arm64" | "aarch64" => &["x86_64", "amd64"],
1896 "x86" | "i686" => &["aarch64", "arm64", "x86_64", "amd64"],
1897 "arm" | "armv7" => &["aarch64", "x86_64", "amd64"],
1898 _ => &[],
1899 };
1900 others.iter().any(|o| name.contains(o))
1901}
1902
1903#[cfg(test)]
1904mod tests {
1905 use std::io::{Read, Write};
1906 use std::net::TcpListener;
1907 use std::sync::Arc;
1908
1909 use super::*;
1910 use crate::config::{Config, Settings, SourcesConfig, ToolSources};
1911 use crate::dirs::Dirs;
1912 use crate::platform::{Libc, Platform};
1913 use crate::source::{Selection, Source};
1914 use crate::store::Cas;
1915
1916 #[test]
1917 fn parse_id() {
1918 let b = GithubBackend::from_id("github:cli/cli").unwrap();
1919 assert_eq!(b.owner, "cli");
1920 assert_eq!(b.repo, "cli");
1921 assert_eq!(b.id(), "github:cli/cli");
1922 assert_eq!(
1923 GithubBackend::from_id("github:Cli/CLI.git").unwrap().id(),
1924 "github:cli/cli"
1925 );
1926 assert!(GithubBackend::from_id("github:noslash").is_none());
1927 assert!(GithubBackend::from_id("node").is_none());
1928 assert!(GithubBackend::from_id("github:cli/cli/extra").is_none());
1929 assert!(GithubBackend::from_id("github:../cli").is_none());
1930 assert!(GithubBackend::from_id("github:cli/repo?ref=bad").is_none());
1931 }
1932
1933 #[test]
1934 fn default_sources_cover_direct_and_full_ghproxy_routes() {
1935 let backend = GithubBackend::from_id("github:cli/cli").unwrap();
1936 let sources = backend.default_sources();
1937 assert_eq!(sources.len(), 2);
1938 assert_eq!(sources[0].download_url, "https://github.com/");
1939 assert_eq!(
1940 sources[1].download_url,
1941 "https://gh-proxy.com/https://github.com/"
1942 );
1943 assert_eq!(
1944 sources[1].index_url.as_deref(),
1945 Some("https://gh-proxy.com/https://api.github.com/")
1946 );
1947 }
1948
1949 #[test]
1950 fn release_urls_append_one_encoded_tag_segment() {
1951 let backend = GithubBackend::from_id("github:cli/cli").unwrap();
1952 assert_eq!(
1953 backend.release_api("v2.98.0").unwrap(),
1954 "https://api.github.com/repos/cli/cli/releases/tags/v2.98.0"
1955 );
1956 assert_eq!(
1957 backend.expanded_assets("release/2026-08").unwrap(),
1958 "https://github.com/cli/cli/releases/expanded_assets/release%2F2026-08"
1959 );
1960 }
1961
1962 fn test_ctx(root: &std::path::Path) -> Ctx {
1963 let dirs = Dirs::resolve_from(|key| match key {
1964 "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
1965 "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
1966 "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
1967 "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
1968 "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
1969 _ => None,
1970 })
1971 .unwrap();
1972 dirs.ensure().unwrap();
1973 Ctx {
1974 cas: Arc::new(Cas::new(dirs.store.clone())),
1975 dirs,
1976 platform: Platform {
1977 os: Os::Linux,
1978 arch: Arch::X64,
1979 libc: Libc::Glibc,
1980 },
1981 config: Config {
1982 settings: Settings::default(),
1983 sources: SourcesConfig {
1984 selection: Selection::Ordered,
1985 ..Default::default()
1986 },
1987 tools: Default::default(),
1988 tool_configs: Default::default(),
1989 global_tools: Default::default(),
1990 global_tool_configs: Default::default(),
1991 tool_origins: Default::default(),
1992 aliases: Default::default(),
1993 project_config_path: None,
1994 },
1995 client: reqwest::Client::new(),
1996 show_progress: false,
1997 }
1998 }
1999
2000 #[test]
2001 fn explicit_asset_rules_require_exactly_one_match_and_render_targets() {
2002 let temp = tempfile::tempdir().unwrap();
2003 let ctx = test_ctx(temp.path());
2004 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2005 let assets = vec![
2006 GhAsset {
2007 name: "tool-1.2.3-linux-x64.tar.gz".into(),
2008 browser_download_url: "https://example.test/x64".into(),
2009 },
2010 GhAsset {
2011 name: "tool-1.2.3-linux-arm64.tar.gz".into(),
2012 browser_download_url: "https://example.test/arm64".into(),
2013 },
2014 ];
2015 let template = AssetRules {
2016 template: Some("tool-{version}-{os}-{arch}.tar.gz".into()),
2017 regex: None,
2018 bins: Vec::new(),
2019 rename: None,
2020 strip_components: 0,
2021 os: Some("linux".into()),
2022 arch: Some("arm64".into()),
2023 libc: None,
2024 };
2025 assert_eq!(
2026 select_asset(&backend, &assets, "1.2.3", &ctx, &template)
2027 .unwrap()
2028 .name,
2029 "tool-1.2.3-linux-arm64.tar.gz"
2030 );
2031 for expression in ["nomatch", "tool-.*"] {
2032 let regex = AssetRules {
2033 regex: Some(regex::Regex::new(expression).unwrap()),
2034 template: None,
2035 bins: Vec::new(),
2036 rename: None,
2037 strip_components: 0,
2038 os: None,
2039 arch: None,
2040 libc: None,
2041 };
2042 assert!(select_asset(&backend, &assets, "1.2.3", &ctx, ®ex).is_err());
2043 }
2044 }
2045
2046 #[test]
2047 fn executable_rename_must_be_a_single_safe_filename() {
2048 for rename in [
2049 "../../outside",
2050 "/outside",
2051 r"..\outside",
2052 r"C:\outside.exe",
2053 ".",
2054 "..",
2055 ] {
2056 let options = std::collections::BTreeMap::from([("rename".into(), rename.into())]);
2057 assert!(GithubBackend::rules(&options).is_err(), "{rename}");
2058 }
2059
2060 for rename in ["tool", "tool.exe"] {
2061 let options = std::collections::BTreeMap::from([("rename".into(), rename.into())]);
2062 assert_eq!(
2063 GithubBackend::rules(&options).unwrap().rename.as_deref(),
2064 Some(rename)
2065 );
2066 }
2067 }
2068
2069 #[test]
2070 fn public_metadata_parsers_keep_only_repository_scoped_release_links() {
2071 let atom = r#"
2072 <feed xmlns='http://www.w3.org/2005/Atom'>
2073 <entry><link type='text/html' rel='alternate'
2074 href='https://github.com/example/tool/releases/tag/v2.0.0-beta.1'/></entry>
2075 <entry><link href="https://github.com/example/tool/releases/tag/release%2F2026-08"/></entry>
2076 <entry><link href='https://evil.example/example/tool/releases/tag/v9'/></entry>
2077 <entry><link href='https://github.com/example/other/releases/tag/v8'/></entry>
2078 <entry><link href='https://github.com/EXAMPLE/TOOL/releases/tag/v2.0.0-beta.1'/></entry>
2079 </feed>
2080 "#;
2081 assert_eq!(
2082 parse_release_tags(atom, "example", "tool"),
2083 vec!["v2.0.0-beta.1", "release/2026-08"]
2084 );
2085
2086 let html = r#"
2087 <ul class='list-style-none'>
2088 <a data-turbo='false' href='/example/tool/releases/download/v1.2.3/tool-linux-x86_64.tar.gz'>tool</a>
2089 <a href="https://github.com/EXAMPLE/TOOL/releases/download/v1.2.3/tool%20symbols.zip?download=1&x=2">symbols</a>
2090 <a href='/example/tool/archive/refs/tags/v1.2.3.zip'>source</a>
2091 <a href='//evil.example/example/tool/releases/download/v1.2.3/evil'>evil</a>
2092 <a href='/example/tool-malicious/releases/download/v1.2.3/evil'>lookalike</a>
2093 <a href='/example/tool/releases/download/v9/wrong-tag'>wrong tag</a>
2094 </ul>
2095 "#;
2096 let assets = parse_release_assets(html, "example", "tool", "v1.2.3");
2097 assert_eq!(assets.len(), 2);
2098 assert_eq!(assets[0].name, "tool-linux-x86_64.tar.gz");
2099 assert_eq!(assets[1].name, "tool symbols.zip");
2100 assert!(!assets[1].browser_download_url.contains("&"));
2101
2102 assert!(parse_release_tags(
2103 "<html><a href='https://github.com/example/tool/releases/tag/v9'>retry</a></html>",
2104 "example",
2105 "tool",
2106 )
2107 .is_empty());
2108 assert!(parse_release_assets(
2109 "<html><a href='/example/tool/releases/download/v9/tool.tar.gz'>retry</a></html>",
2110 "example",
2111 "tool",
2112 "v9",
2113 )
2114 .is_empty());
2115 }
2116
2117 #[tokio::test]
2118 async fn offline_exact_release_reuses_paginated_release_cache() {
2119 let temp = tempfile::tempdir().unwrap();
2120 let mut ctx = test_ctx(temp.path());
2121 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2122 let api = backend.releases_api(1);
2123 let cache = http::metadata_cache_path(&ctx, &api);
2124 std::fs::create_dir_all(cache.parent().unwrap()).unwrap();
2125 std::fs::write(
2126 cache,
2127 br#"[{"tag_name":"v1.2.3","draft":false,"prerelease":false,"assets":[{"name":"tool-linux-x86_64.tar.gz","browser_download_url":"https://github.com/example/tool/releases/download/v1.2.3/tool-linux-x86_64.tar.gz"}]}]"#,
2128 )
2129 .unwrap();
2130 ctx.config.settings.offline = true;
2131 let release = backend
2132 .release_for_tag(&ctx, &backend.default_sources(), "1.2.3")
2133 .await
2134 .unwrap();
2135 assert_eq!(release.tag_name, "v1.2.3");
2136 assert_eq!(release.assets.len(), 1);
2137 }
2138
2139 #[tokio::test]
2140 async fn anonymous_api_rate_limit_falls_back_to_public_atom() {
2141 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2142 let address = listener.local_addr().unwrap();
2143 let server = std::thread::spawn(move || {
2144 for response in [
2145 (
2146 "403 Forbidden",
2147 "application/json",
2148 "X-RateLimit-Limit: 60\r\nX-RateLimit-Remaining: 0\r\nX-RateLimit-Reset: 1787446800\r\n",
2149 r#"{"message":"API rate limit exceeded"}"#,
2150 ),
2151 (
2152 "200 OK",
2153 "application/atom+xml",
2154 "",
2155 r#"<feed xmlns='http://www.w3.org/2005/Atom'><entry><link href='https://github.com/example/tool/releases/tag/v2.0.0'/></entry><entry><link href='https://github.com/example/tool/releases/tag/v2.1.0-beta.1'/></entry></feed>"#,
2156 ),
2157 ] {
2158 let (mut stream, _) = listener.accept().unwrap();
2159 let mut request = Vec::new();
2160 let mut buffer = [0u8; 2048];
2161 while !request.ends_with(b"\r\n\r\n") {
2162 let size = stream.read(&mut buffer).unwrap();
2163 if size == 0 {
2164 break;
2165 }
2166 request.extend_from_slice(&buffer[..size]);
2167 }
2168 let request = String::from_utf8(request).unwrap();
2169 assert!(!request.to_ascii_lowercase().contains("authorization:"));
2170 write!(
2171 stream,
2172 "HTTP/1.1 {}\r\nContent-Type: {}\r\n{}Content-Length: {}\r\nConnection: close\r\n\r\n{}",
2173 response.0,
2174 response.1,
2175 response.2,
2176 response.3.len(),
2177 response.3,
2178 )
2179 .unwrap();
2180 }
2181 });
2182 let temp = tempfile::tempdir().unwrap();
2183 let mut ctx = test_ctx(temp.path());
2184 ctx.config.sources.per_tool.insert(
2185 "github:example/tool".into(),
2186 ToolSources {
2187 pin: Some("fixture".into()),
2188 disable: vec!["github".into(), "ghproxy".into()],
2189 custom: vec![Source::official("fixture", &format!("http://{address}/"))
2190 .with_index(&format!("http://{address}/"))],
2191 ..Default::default()
2192 },
2193 );
2194 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2195 let versions = backend.list_remote_versions(&ctx).await.unwrap();
2196 assert_eq!(versions.len(), 2);
2197 assert_eq!(versions[0].version, "2.1.0-beta.1");
2198 assert!(!versions[0].stable);
2199 assert_eq!(versions[1].version, "2.0.0");
2200 server.join().unwrap();
2201 }
2202
2203 #[tokio::test]
2204 async fn exact_release_asset_discovery_falls_back_to_public_html() {
2205 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2206 let address = listener.local_addr().unwrap();
2207 let server = std::thread::spawn(move || {
2208 for (status, headers, body) in [
2209 (
2210 "403 Forbidden",
2211 "Content-Type: application/json\r\nX-GitHub-Request-Id: fixture-secondary\r\nX-RateLimit-Remaining: 0\r\nRetry-After: 30\r\n",
2212 r#"{"message":"You have exceeded a secondary rate limit."}"#,
2213 ),
2214 (
2215 "404 Not Found",
2216 "Content-Type: application/json\r\n",
2217 r#"{"message":"Not Found"}"#,
2218 ),
2219 (
2220 "200 OK",
2221 "Content-Type: text/html\r\n",
2222 r#"<ul class='list-style-none'><li><a href='/example/tool/releases/download/1.2.3/tool-linux-x86_64.tar.gz'>tool</a></li><li><a href='/example/tool/archive/refs/tags/1.2.3.zip'>source</a></li></ul>"#,
2223 ),
2224 ] {
2225 let (mut stream, _) = listener.accept().unwrap();
2226 let mut request = Vec::new();
2227 let mut buffer = [0u8; 2048];
2228 while !request.ends_with(b"\r\n\r\n") {
2229 let size = stream.read(&mut buffer).unwrap();
2230 if size == 0 {
2231 break;
2232 }
2233 request.extend_from_slice(&buffer[..size]);
2234 }
2235 assert!(
2236 !String::from_utf8(request)
2237 .unwrap()
2238 .to_ascii_lowercase()
2239 .contains("authorization:")
2240 );
2241 write!(
2242 stream,
2243 "HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
2244 body.len(),
2245 )
2246 .unwrap();
2247 }
2248 });
2249 let temp = tempfile::tempdir().unwrap();
2250 let ctx = test_ctx(temp.path());
2251 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2252 let sources = vec![Source::official("fixture", &format!("http://{address}/"))
2253 .with_index(&format!("http://{address}/"))];
2254 let release = backend
2255 .release_for_tag(&ctx, &sources, "1.2.3")
2256 .await
2257 .unwrap();
2258 assert_eq!(release.tag_name, "1.2.3");
2259 assert_eq!(release.assets.len(), 1);
2260 assert_eq!(release.assets[0].name, "tool-linux-x86_64.tar.gz");
2261 server.join().unwrap();
2262 }
2263
2264 #[tokio::test]
2265 async fn rate_limited_unprefixed_tag_still_tries_v_prefixed_tag() {
2266 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2267 let address = listener.local_addr().unwrap();
2268 let server = std::thread::spawn(move || {
2269 for expected_tag in ["1.2.3", "v1.2.3"] {
2270 let (mut stream, _) = listener.accept().unwrap();
2271 let mut request = Vec::new();
2272 let mut buffer = [0u8; 2048];
2273 while !request.ends_with(b"\r\n\r\n") {
2274 let size = stream.read(&mut buffer).unwrap();
2275 if size == 0 {
2276 break;
2277 }
2278 request.extend_from_slice(&buffer[..size]);
2279 }
2280 let request = String::from_utf8(request).unwrap();
2281 assert!(request.contains(&format!("/releases/tags/{expected_tag}")));
2282 if expected_tag == "1.2.3" {
2283 let body = r#"{"message":"API rate limit exceeded"}"#;
2284 write!(
2285 stream,
2286 "HTTP/1.1 403 Forbidden\r\nX-GitHub-Request-Id: fixture\r\nX-RateLimit-Remaining: 0\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2287 body.len()
2288 )
2289 .unwrap();
2290 } else {
2291 let body =
2292 r#"{"tag_name":"v1.2.3","draft":false,"prerelease":false,"assets":[]}"#;
2293 write!(
2294 stream,
2295 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2296 body.len()
2297 )
2298 .unwrap();
2299 }
2300 }
2301 });
2302 let temp = tempfile::tempdir().unwrap();
2303 let ctx = test_ctx(temp.path());
2304 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2305 let sources = vec![Source::official("fixture", &format!("http://{address}/"))
2306 .with_index(&format!("http://{address}/"))];
2307
2308 let release = backend
2309 .release_for_tag(&ctx, &sources, "1.2.3")
2310 .await
2311 .unwrap();
2312 assert_eq!(release.tag_name, "v1.2.3");
2313 server.join().unwrap();
2314 }
2315
2316 #[tokio::test]
2317 async fn release_pagination_finds_versions_on_second_page() {
2318 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2319 let address = listener.local_addr().unwrap();
2320 let server = std::thread::spawn(move || {
2321 for page in 1..=2 {
2322 let (mut stream, _) = listener.accept().unwrap();
2323 let mut request = Vec::new();
2324 let mut buffer = [0u8; 2048];
2325 while !request.ends_with(b"\r\n\r\n") {
2326 let size = stream.read(&mut buffer).unwrap();
2327 if size == 0 {
2328 break;
2329 }
2330 request.extend_from_slice(&buffer[..size]);
2331 }
2332 let request = String::from_utf8(request).unwrap();
2333 assert!(request.contains(&format!("page={page}")));
2334 let body = if page == 1 {
2335 serde_json::to_string(
2336 &(0..100)
2337 .map(|index| {
2338 serde_json::json!({
2339 "tag_name": format!("v1.0.{index}"),
2340 "draft": false,
2341 "prerelease": false,
2342 "assets": []
2343 })
2344 })
2345 .collect::<Vec<_>>(),
2346 )
2347 .unwrap()
2348 } else {
2349 r#"[{"tag_name":"v2.0.0","draft":false,"prerelease":false,"assets":[]}]"#.into()
2350 };
2351 write!(
2352 stream,
2353 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2354 body.len(),
2355 body
2356 )
2357 .unwrap();
2358 }
2359 });
2360 let temp = tempfile::tempdir().unwrap();
2361 let mut ctx = test_ctx(temp.path());
2362 ctx.config.sources.per_tool.insert(
2363 "github:example/tool".into(),
2364 ToolSources {
2365 pin: Some("fixture".into()),
2366 disable: vec!["github".into(), "ghproxy".into()],
2367 custom: vec![Source::official("fixture", &format!("http://{address}/"))
2368 .with_index(&format!("http://{address}/"))],
2369 ..Default::default()
2370 },
2371 );
2372 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2373 let versions = backend.list_remote_versions(&ctx).await.unwrap();
2374 assert!(versions.iter().any(|version| version.version == "2.0.0"));
2375 server.join().unwrap();
2376 }
2377
2378 #[tokio::test]
2379 async fn static_catalog_resolves_without_releases_api_and_locks_artifact() {
2380 let temp = tempfile::tempdir().unwrap();
2381 let catalog_path = temp.path().join("catalog.json");
2382 let catalog = StaticCatalog {
2383 schema: 1,
2384 releases: vec![StaticRelease {
2385 tag: "v1.2.3".into(),
2386 prerelease: false,
2387 assets: vec![StaticAsset {
2388 name: "tool-linux-x64.tar.gz".into(),
2389 url: "https://artifacts.example/tool.tar.gz".into(),
2390 checksum: format!("sha256:{}", "a".repeat(64)),
2391 os: "linux".into(),
2392 arch: "x64".into(),
2393 libc: Some("gnu".into()),
2394 }],
2395 }],
2396 };
2397 let bytes = serde_json::to_vec_pretty(&catalog).unwrap();
2398 std::fs::write(&catalog_path, &bytes).unwrap();
2399 let digest = pipeline::verify::hash_bytes(&bytes, pipeline::HashAlgo::Sha256);
2400 let ctx = test_ctx(temp.path());
2401 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2402 let mut request = ToolRequest::parse("github:example/tool@latest").unwrap();
2403 request
2404 .options
2405 .insert("catalog-url".into(), catalog_path.display().to_string());
2406 request.options.insert("catalog-sha256".into(), digest);
2407 let resolved = backend.resolve_version(&ctx, &request).await.unwrap();
2408 assert_eq!(resolved.version, "1.2.3");
2409 assert_eq!(
2410 resolved.options[pipeline::LOCKED_ARTIFACT_URL_OPTION],
2411 "https://artifacts.example/tool.tar.gz"
2412 );
2413 }
2414
2415 #[test]
2416 fn static_catalog_asset_urls_must_be_safe_to_persist() {
2417 assert!(static_asset_url_is_persistable(
2418 "https://artifacts.example/tool.tar.gz"
2419 ));
2420 for unsafe_url in [
2421 "https://user:secret@artifacts.example/tool.tar.gz",
2422 "https://artifacts.example/tool.tar.gz?token=secret",
2423 "https://artifacts.example/tool.tar.gz#fragment",
2424 ] {
2425 assert!(
2426 !static_asset_url_is_persistable(unsafe_url),
2427 "accepted {unsafe_url}"
2428 );
2429 }
2430 }
2431
2432 #[test]
2433 fn checksumless_signed_url_is_hashed_in_install_manifest_identity() {
2434 let temp = tempfile::tempdir().unwrap();
2435 let ctx = test_ctx(temp.path());
2436 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2437 let secret_url = "https://artifacts.example/tool?token=do-not-persist";
2438 let version = locked_version(&backend, "1.2.3", secret_url, "tool", None);
2439 let locator = github_install_locator(&ctx, backend.id(), &version).unwrap();
2440 let manifest = DynamicToolManifest::from_identity(locator.identity().clone()).unwrap();
2441 let json = serde_json::to_string(&manifest).unwrap();
2442
2443 assert!(!json.contains(secret_url));
2444 assert!(!json.contains("do-not-persist"));
2445 assert!(!manifest.identity.materials.contains_key("artifact-url"));
2446 assert_eq!(
2447 manifest.identity.materials["artifact-url-blake3"],
2448 github_artifact_url_hash(secret_url)
2449 );
2450 }
2451
2452 #[tokio::test]
2453 async fn static_catalog_locked_artifact_installs_offline_with_multiple_bins() {
2454 let temp = tempfile::tempdir().unwrap();
2455 let archive = temp.path().join("tool.tar.gz");
2456 {
2457 let file = std::fs::File::create(&archive).unwrap();
2458 let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
2459 let mut builder = tar::Builder::new(encoder);
2460 for (path, contents) in [
2461 ("release/pkg/a", b"a".as_slice()),
2462 ("release/pkg/b", b"b".as_slice()),
2463 ] {
2464 let mut header = tar::Header::new_gnu();
2465 header.set_size(contents.len() as u64);
2466 header.set_mode(0o644);
2467 header.set_cksum();
2468 builder.append_data(&mut header, path, contents).unwrap();
2469 }
2470 builder.finish().unwrap();
2471 }
2472 let checksum = pipeline::verify::hash_file(&archive, pipeline::HashAlgo::Sha256).unwrap();
2473 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2474 let mut ctx = test_ctx(temp.path());
2475 ctx.config.settings.offline = true;
2476 let file_name = "tool.tar.gz";
2477 let mut version = ToolVersion::new(backend.id(), "1.2.3");
2478 version.options.extend(std::collections::BTreeMap::from([
2479 (
2480 pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
2481 "https://invalid.example/tool.tar.gz".into(),
2482 ),
2483 (
2484 pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
2485 file_name.into(),
2486 ),
2487 (
2488 pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
2489 format!("sha256:{checksum}"),
2490 ),
2491 ("bins".into(), "pkg/a,pkg/b".into()),
2492 ("strip-components".into(), "1".into()),
2493 ]));
2494 let locator = github_install_locator(&ctx, backend.id(), &version).unwrap();
2495 let cached = pipeline::dynamic_artifact_cache_path(&ctx.dirs, &locator, file_name).unwrap();
2496 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
2497 std::fs::copy(&archive, &cached).unwrap();
2498 backend
2499 .install(&InstallCtx { ctx: &ctx }, &version)
2500 .await
2501 .unwrap();
2502 let install = locator.install_root();
2503 assert!(install.join("bin/a").is_file());
2504 assert!(install.join("bin/b").is_file());
2505 assert!(install.join(".osdk-complete").is_file());
2506 let manifest = DynamicToolManifest::load(install).unwrap();
2507 assert_eq!(manifest.schema, 1);
2508 assert!(manifest.matches_identity(locator.identity()));
2509 let mut changed = version.clone();
2510 changed.options.insert("bins".into(), "pkg/a".into());
2511 let changed_locator = github_install_locator(&ctx, backend.id(), &changed).unwrap();
2512 assert_ne!(locator.install_root(), changed_locator.install_root());
2513 assert!(!manifest.matches_identity(changed_locator.identity()));
2514 }
2515
2516 fn complete_install_fixture(
2517 ctx: &Ctx,
2518 backend: &GithubBackend,
2519 version: &ToolVersion,
2520 contents: &[u8],
2521 ) -> InstallLocator {
2522 let locator = github_install_locator(ctx, backend.id(), version).unwrap();
2523 let install = locator.install_root();
2524 std::fs::create_dir_all(install.join("bin")).unwrap();
2525 std::fs::write(install.join("bin/tool"), contents).unwrap();
2526 std::fs::write(install.join(".osdk-complete"), b"").unwrap();
2527 locator
2528 }
2529
2530 fn locked_version(
2531 backend: &GithubBackend,
2532 version: &str,
2533 url: &str,
2534 file_name: &str,
2535 checksum: Option<&str>,
2536 ) -> ToolVersion {
2537 let mut version = ToolVersion::new(backend.id(), version);
2538 version
2539 .options
2540 .insert(pipeline::LOCKED_ARTIFACT_URL_OPTION.into(), url.into());
2541 version.options.insert(
2542 pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
2543 file_name.into(),
2544 );
2545 if let Some(checksum) = checksum {
2546 version.options.insert(
2547 pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
2548 checksum.into(),
2549 );
2550 }
2551 version
2552 }
2553
2554 fn write_complete_install_fixture(
2555 ctx: &Ctx,
2556 backend: &GithubBackend,
2557 version: &ToolVersion,
2558 contents: &[u8],
2559 ) -> InstallLocator {
2560 let locator = complete_install_fixture(ctx, backend, version, contents);
2561 let install = locator.install_root();
2562 let mut manifest = DynamicToolManifest::from_identity(locator.identity().clone()).unwrap();
2563 manifest.bins.push(DynamicToolBin {
2564 name: "tool".into(),
2565 path: "bin/tool".into(),
2566 });
2567 manifest.write_atomic(install).unwrap();
2568 let receipt = pipeline::ArtifactReceipt {
2569 url: version.options[pipeline::LOCKED_ARTIFACT_URL_OPTION].clone(),
2570 file_name: version.options[pipeline::LOCKED_ARTIFACT_FILE_OPTION].clone(),
2571 checksum: version
2572 .options
2573 .get(pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION)
2574 .cloned(),
2575 evidence: Vec::new(),
2576 };
2577 std::fs::write(
2578 install.join(".osdk-artifact.json"),
2579 serde_json::to_vec_pretty(&receipt).unwrap(),
2580 )
2581 .unwrap();
2582 locator
2583 }
2584
2585 #[test]
2586 fn locked_locator_restarts_with_the_same_material_identity() {
2587 let temp = tempfile::tempdir().unwrap();
2588 let ctx = test_ctx(temp.path());
2589 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2590 let checksum = format!("sha256:{}", "a".repeat(64));
2591 let version = locked_version(
2592 &backend,
2593 "1.2.3",
2594 "https://example.test/tool",
2595 "tool",
2596 Some(&checksum),
2597 );
2598 let first = github_install_locator(&ctx, backend.id(), &version).unwrap();
2599 let restarted = github_install_locator(&ctx, backend.id(), &version).unwrap();
2600 assert_eq!(first.identity(), restarted.identity());
2601 assert_eq!(first.install_root(), restarted.install_root());
2602 }
2603
2604 #[test]
2605 fn unlocked_locator_recovers_one_valid_material_identity_and_rejects_ambiguity() {
2606 let temp = tempfile::tempdir().unwrap();
2607 let ctx = test_ctx(temp.path());
2608 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2609 let first = locked_version(
2610 &backend,
2611 "1.2.3",
2612 "https://example.test/tool-a",
2613 "tool",
2614 None,
2615 );
2616 let first_locator =
2617 write_complete_install_fixture(&ctx, &backend, &first, b"first material");
2618 let unlocked = ToolVersion::new(backend.id(), "1.2.3");
2619 assert_eq!(
2620 github_install_locator(&ctx, backend.id(), &unlocked)
2621 .unwrap()
2622 .install_root(),
2623 first_locator.install_root()
2624 );
2625
2626 let second = locked_version(
2627 &backend,
2628 "1.2.3",
2629 "https://example.test/tool-b",
2630 "tool",
2631 None,
2632 );
2633 let second_locator =
2634 write_complete_install_fixture(&ctx, &backend, &second, b"second material");
2635 assert_ne!(first_locator.install_root(), second_locator.install_root());
2636 let error = github_install_locator(&ctx, backend.id(), &unlocked).unwrap_err();
2637 assert!(error.to_string().contains("ambiguous"));
2638 assert!(error.to_string().contains("lockfile"));
2639 }
2640
2641 #[tokio::test]
2642 async fn uninstall_removes_only_the_exact_material_variant() {
2643 let temp = tempfile::tempdir().unwrap();
2644 let ctx = test_ctx(temp.path());
2645 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2646 let first = locked_version(
2647 &backend,
2648 "1.2.3",
2649 "https://example.test/tool-a",
2650 "tool",
2651 None,
2652 );
2653 let second = locked_version(
2654 &backend,
2655 "1.2.3",
2656 "https://example.test/tool-b",
2657 "tool",
2658 None,
2659 );
2660 let first_locator = write_complete_install_fixture(&ctx, &backend, &first, b"first");
2661 let second_locator = write_complete_install_fixture(&ctx, &backend, &second, b"second");
2662 let version_root = first_locator.install_root().parent().unwrap().to_path_buf();
2663
2664 backend.uninstall(&ctx, &first).await.unwrap();
2665
2666 assert!(!first_locator.install_root().exists());
2667 assert!(second_locator.install_root().join("bin/tool").is_file());
2668 assert!(version_root.is_dir());
2669 }
2670
2671 #[test]
2672 fn list_installed_uses_only_valid_current_manifests_and_dedupes_versions() {
2673 let temp = tempfile::tempdir().unwrap();
2674 let ctx = test_ctx(temp.path());
2675 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2676 for url in ["https://example.test/tool-a", "https://example.test/tool-b"] {
2677 let version = locked_version(&backend, "1.2.3", url, "tool", None);
2678 write_complete_install_fixture(&ctx, &backend, &version, url.as_bytes());
2679 }
2680 let current = locked_version(
2681 &backend,
2682 "2.0.0",
2683 "https://example.test/tool-2",
2684 "tool",
2685 None,
2686 );
2687 write_complete_install_fixture(&ctx, &backend, ¤t, b"two");
2688
2689 let invalid = locked_version(
2690 &backend,
2691 "3.0.0",
2692 "https://example.test/tool-3",
2693 "tool",
2694 None,
2695 );
2696 let invalid_locator = write_complete_install_fixture(&ctx, &backend, &invalid, b"invalid");
2697 std::fs::remove_file(invalid_locator.install_root().join(".osdk-artifact.json")).unwrap();
2698
2699 let legacy = ctx.dirs.install_path(backend.id(), "4.0.0");
2700 std::fs::create_dir_all(&legacy).unwrap();
2701 std::fs::write(legacy.join(".osdk-complete"), b"").unwrap();
2702 std::fs::write(legacy.join(".osdk-tool.json"), b"{}").unwrap();
2703
2704 assert_eq!(
2705 backend.list_installed(&ctx).unwrap(),
2706 vec!["1.2.3", "2.0.0"]
2707 );
2708 }
2709
2710 #[tokio::test]
2711 async fn install_refuses_complete_install_without_inventory() {
2712 let temp = tempfile::tempdir().unwrap();
2713 let ctx = test_ctx(temp.path());
2714 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2715 let version = locked_version(&backend, "1.2.3", "https://example.test/tool", "tool", None);
2716 let locator = complete_install_fixture(&ctx, &backend, &version, b"original bytes");
2717 let install = locator.install_root();
2718
2719 let error = backend
2720 .install(&InstallCtx { ctx: &ctx }, &version)
2721 .await
2722 .unwrap_err();
2723
2724 assert!(error.to_string().contains("refusing to reuse complete"));
2725 assert!(error.to_string().contains("missing, legacy, or invalid"));
2726 assert_eq!(
2727 std::fs::read(install.join("bin/tool")).unwrap(),
2728 b"original bytes"
2729 );
2730 assert!(!DynamicToolManifest::manifest_path(install).exists());
2731 }
2732
2733 #[tokio::test]
2734 async fn legacy_inventory_is_never_reused() {
2735 let temp = tempfile::tempdir().unwrap();
2736 let mut ctx = test_ctx(temp.path());
2737 ctx.config.settings.offline = true;
2738 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2739 let file_name = "tool";
2740 let checksum = pipeline::verify::hash_bytes(b"fresh bytes", pipeline::HashAlgo::Sha256);
2741 let mut version = ToolVersion::new(backend.id(), "1.2.3");
2742 version.options.extend(std::collections::BTreeMap::from([
2743 (
2744 pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
2745 "https://invalid.example/tool".into(),
2746 ),
2747 (
2748 pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
2749 file_name.into(),
2750 ),
2751 (
2752 pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
2753 format!("sha256:{checksum}"),
2754 ),
2755 ]));
2756 let locator = github_install_locator(&ctx, backend.id(), &version).unwrap();
2757 let cached = pipeline::dynamic_artifact_cache_path(&ctx.dirs, &locator, file_name).unwrap();
2758 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
2759 std::fs::write(&cached, b"fresh bytes").unwrap();
2760 let legacy_install = locator.legacy_install_root();
2761 std::fs::create_dir_all(legacy_install.join("bin")).unwrap();
2762 std::fs::write(legacy_install.join("bin/tool"), b"legacy bytes").unwrap();
2763 std::fs::write(legacy_install.join(".osdk-complete"), b"").unwrap();
2764 let legacy_inventory = legacy_install.join(".osdk-tool.json");
2765 let legacy_contents = r#"{"schema":1,"id":"github:example/tool","version":"1.2.3","bins":[{"name":"tool","path":"bin/tool"}]}"#;
2766 std::fs::write(&legacy_inventory, legacy_contents).unwrap();
2767 assert!(!locator.install_root().exists());
2768
2769 backend
2770 .install(&InstallCtx { ctx: &ctx }, &version)
2771 .await
2772 .unwrap();
2773
2774 assert_eq!(
2775 std::fs::read(legacy_install.join("bin/tool")).unwrap(),
2776 b"legacy bytes"
2777 );
2778 assert_eq!(
2779 std::fs::read(&legacy_inventory).unwrap(),
2780 legacy_contents.as_bytes()
2781 );
2782 assert!(!DynamicToolManifest::manifest_path(legacy_install).exists());
2783 assert_ne!(locator.install_root(), legacy_install);
2784 assert!(locator.install_root().join(".osdk-complete").is_file());
2785 assert_eq!(
2786 std::fs::read(locator.install_root().join("bin/tool")).unwrap(),
2787 b"fresh bytes"
2788 );
2789 let manifest = DynamicToolManifest::load(locator.install_root()).unwrap();
2790 assert!(manifest.matches_identity(locator.identity()));
2791 }
2792
2793 #[tokio::test]
2794 async fn install_refuses_complete_install_with_mismatched_inventory() {
2795 let temp = tempfile::tempdir().unwrap();
2796 let ctx = test_ctx(temp.path());
2797 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2798 let mut installed =
2799 locked_version(&backend, "1.2.3", "https://example.test/tool", "tool", None);
2800 installed.options.insert("rename".into(), "old-name".into());
2801 let mut requested = installed.clone();
2802 requested.options.insert("rename".into(), "new-name".into());
2803 let locator = complete_install_fixture(&ctx, &backend, &requested, b"original bytes");
2804 let install = locator.install_root();
2805 let installed_locator = github_install_locator(&ctx, backend.id(), &installed).unwrap();
2806 assert_ne!(installed_locator.install_root(), install);
2807 let mut manifest =
2808 DynamicToolManifest::from_identity(installed_locator.identity().clone()).unwrap();
2809 manifest.bins.push(DynamicToolBin {
2810 name: "tool".into(),
2811 path: "bin/tool".into(),
2812 });
2813 manifest.write_atomic(install).unwrap();
2814 let original_inventory =
2815 std::fs::read(DynamicToolManifest::manifest_path(install)).unwrap();
2816
2817 let error = backend
2818 .install(&InstallCtx { ctx: &ctx }, &requested)
2819 .await
2820 .unwrap_err();
2821
2822 assert!(error
2823 .to_string()
2824 .contains("missing, legacy, or invalid install identity"));
2825 assert_eq!(
2826 std::fs::read(install.join("bin/tool")).unwrap(),
2827 b"original bytes"
2828 );
2829 assert_eq!(
2830 std::fs::read(DynamicToolManifest::manifest_path(install)).unwrap(),
2831 original_inventory
2832 );
2833 let persisted = DynamicToolManifest::load(install).unwrap();
2834 assert!(persisted.matches_identity(installed_locator.identity()));
2835 assert!(!persisted.matches_identity(locator.identity()));
2836 }
2837
2838 #[tokio::test]
2839 async fn install_refuses_complete_install_with_different_locked_artifact() {
2840 let temp = tempfile::tempdir().unwrap();
2841 let ctx = test_ctx(temp.path());
2842 let backend = GithubBackend::from_id("github:example/tool").unwrap();
2843 let mut version = ToolVersion::new(backend.id(), "1.2.3");
2844 version.options.extend(std::collections::BTreeMap::from([
2845 (
2846 pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
2847 "https://invalid.example/new-tool.tar.gz".into(),
2848 ),
2849 (
2850 pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
2851 "new-tool.tar.gz".into(),
2852 ),
2853 (
2854 pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
2855 format!("sha256:{}", "b".repeat(64)),
2856 ),
2857 ]));
2858 let locator = complete_install_fixture(&ctx, &backend, &version, b"original bytes");
2859 let install = locator.install_root();
2860 let mut manifest = DynamicToolManifest::from_identity(locator.identity().clone()).unwrap();
2861 manifest.bins.push(DynamicToolBin {
2862 name: "tool".into(),
2863 path: "bin/tool".into(),
2864 });
2865 manifest.write_atomic(install).unwrap();
2866 std::fs::write(
2867 install.join(".osdk-artifact.json"),
2868 serde_json::to_vec_pretty(&pipeline::ArtifactReceipt {
2869 url: "https://invalid.example/old-tool.tar.gz".into(),
2870 file_name: "old-tool.tar.gz".into(),
2871 checksum: Some(format!("sha256:{}", "a".repeat(64))),
2872 evidence: Vec::new(),
2873 })
2874 .unwrap(),
2875 )
2876 .unwrap();
2877
2878 let error = backend
2879 .install(&InstallCtx { ctx: &ctx }, &version)
2880 .await
2881 .unwrap_err();
2882
2883 assert!(error.to_string().contains("invalid receipt"));
2884 assert_eq!(
2885 std::fs::read(install.join("bin/tool")).unwrap(),
2886 b"original bytes"
2887 );
2888 }
2889
2890 #[test]
2891 fn checksumless_locked_artifact_requires_the_same_url() {
2892 assert!(locked_artifact_url_must_match(
2893 "https://example.test/tool",
2894 "https://example.test/tool",
2895 None,
2896 ));
2897 assert!(!locked_artifact_url_must_match(
2898 "https://mirror.example.test/tool",
2899 "https://example.test/tool",
2900 None,
2901 ));
2902 assert!(locked_artifact_url_must_match(
2903 "https://mirror.example.test/tool",
2904 "https://example.test/tool",
2905 Some("sha256:00"),
2906 ));
2907 }
2908
2909 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2910 async fn concurrent_same_version_distinct_option_installs_coexist() {
2911 let temp = tempfile::tempdir().unwrap();
2912 let mut ctx = test_ctx(temp.path());
2913 ctx.config.settings.offline = true;
2914 let ctx = Arc::new(ctx);
2915 let backend = Arc::new(GithubBackend::from_id("github:example/tool").unwrap());
2916 let archive = temp.path().join("tool.tar.gz");
2917 {
2918 let file = std::fs::File::create(&archive).unwrap();
2919 let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
2920 let mut builder = tar::Builder::new(encoder);
2921 for (path, contents) in [
2922 ("release/pkg/a", b"first".as_slice()),
2923 ("release/pkg/b", b"second".as_slice()),
2924 ] {
2925 let mut header = tar::Header::new_gnu();
2926 header.set_size(contents.len() as u64);
2927 header.set_mode(0o755);
2928 header.set_cksum();
2929 builder.append_data(&mut header, path, contents).unwrap();
2930 }
2931 builder.finish().unwrap();
2932 }
2933 let checksum = pipeline::verify::hash_file(&archive, pipeline::HashAlgo::Sha256).unwrap();
2934 let file_name = "tool.tar.gz";
2935 let make_version = |bin: &str| {
2936 let mut version = ToolVersion::new(backend.id(), "1.2.3");
2937 version.options.extend(std::collections::BTreeMap::from([
2938 (
2939 pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
2940 "https://invalid.example/tool.tar.gz".into(),
2941 ),
2942 (
2943 pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
2944 file_name.into(),
2945 ),
2946 (
2947 pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
2948 format!("sha256:{checksum}"),
2949 ),
2950 ("bins".into(), bin.into()),
2951 ("strip-components".into(), "1".into()),
2952 ]));
2953 version
2954 };
2955 let first = make_version("pkg/a");
2956 let second = make_version("pkg/b");
2957 for version in [&first, &second] {
2958 let locator = github_install_locator(&ctx, backend.id(), version).unwrap();
2959 let cached =
2960 pipeline::dynamic_artifact_cache_path(&ctx.dirs, &locator, file_name).unwrap();
2961 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
2962 std::fs::copy(&archive, cached).unwrap();
2963 }
2964 let barrier = Arc::new(tokio::sync::Barrier::new(2));
2965
2966 let first_task = {
2967 let ctx = Arc::clone(&ctx);
2968 let backend = Arc::clone(&backend);
2969 let barrier = Arc::clone(&barrier);
2970 let version = first.clone();
2971 tokio::spawn(async move {
2972 barrier.wait().await;
2973 backend.install(&InstallCtx { ctx: &ctx }, &version).await
2974 })
2975 };
2976 let second_task = {
2977 let ctx = Arc::clone(&ctx);
2978 let backend = Arc::clone(&backend);
2979 let barrier = Arc::clone(&barrier);
2980 let version = second.clone();
2981 tokio::spawn(async move {
2982 barrier.wait().await;
2983 backend.install(&InstallCtx { ctx: &ctx }, &version).await
2984 })
2985 };
2986 let first_result = first_task.await.unwrap();
2987 let second_result = second_task.await.unwrap();
2988 first_result.unwrap();
2989 second_result.unwrap();
2990
2991 let first_locator = github_install_locator(&ctx, backend.id(), &first).unwrap();
2992 let second_locator = github_install_locator(&ctx, backend.id(), &second).unwrap();
2993 assert_ne!(first_locator.install_root(), second_locator.install_root());
2994
2995 let first_install = first_locator.install_root();
2996 let first_manifest = DynamicToolManifest::load(first_install).unwrap();
2997 assert!(first_manifest.matches_identity(first_locator.identity()));
2998 assert!(first_install.join("bin/a").is_file());
2999 assert!(!first_install.join("bin/b").is_file());
3000
3001 let second_install = second_locator.install_root();
3002 let second_manifest = DynamicToolManifest::load(second_install).unwrap();
3003 assert!(second_manifest.matches_identity(second_locator.identity()));
3004 assert!(second_install.join("bin/b").is_file());
3005 assert!(!second_install.join("bin/a").is_file());
3006 }
3007
3008 #[test]
3009 fn postprocess_is_atomic_for_multiple_binaries_and_windows_names() {
3010 let temp = tempfile::tempdir().unwrap();
3011 let ctx = test_ctx(temp.path());
3012 let backend = GithubBackend::from_id("github:example/tool").unwrap();
3013 let version = ToolVersion::new(backend.id(), "1.0.0");
3014 let asset = GhAsset {
3015 name: "tool.tar.gz".into(),
3016 browser_download_url: "https://example.test/tool-1".into(),
3017 };
3018 let locator =
3019 github_install_locator_for_artifact(&ctx, backend.id(), &version, &asset, None)
3020 .unwrap();
3021 let install = locator.install_root();
3022 std::fs::create_dir_all(install.join("release/pkg")).unwrap();
3023 std::fs::write(install.join("release/pkg/a"), b"a").unwrap();
3024 std::fs::write(install.join("release/pkg/b"), b"b").unwrap();
3025 let rules = AssetRules {
3026 regex: None,
3027 template: None,
3028 bins: vec!["pkg/a".into(), "pkg/b".into()],
3029 rename: None,
3030 strip_components: 1,
3031 os: None,
3032 arch: None,
3033 libc: None,
3034 };
3035 postprocess_archive(&ctx, &locator, &rules).unwrap();
3036 assert!(install.join("bin/a").is_file());
3037 assert!(install.join("bin/b").is_file());
3038
3039 let bad_version = ToolVersion::new(backend.id(), "2.0.0");
3040 let bad_locator =
3041 github_install_locator_for_artifact(&ctx, backend.id(), &bad_version, &asset, None)
3042 .unwrap();
3043 let bad_install = bad_locator.install_root();
3044 std::fs::create_dir_all(bad_install.join("release/pkg")).unwrap();
3045 std::fs::write(bad_install.join("release/pkg/a"), b"a").unwrap();
3046 let bad = AssetRules {
3047 bins: vec!["pkg/a".into(), "pkg/missing".into()],
3048 ..rules.clone()
3049 };
3050 assert!(postprocess_archive(&ctx, &bad_locator, &bad).is_err());
3051 assert!(!bad_install.exists());
3052 assert_eq!(normalize_executable_name("tool", Os::Windows), "tool.exe");
3053 assert_eq!(
3054 normalize_executable_name("tool.exe", Os::Windows),
3055 "tool.exe"
3056 );
3057 }
3058}