Skip to main content

osdk_core/backend/
github.rs

1//! Generic GitHub-release backend, addressed as `github:owner/repo`.
2//!
3//! Downloads a release asset matching the host platform and installs it. Two
4//! asset shapes are handled: archives (tar.*/zip → extracted) and bare binaries
5//! (installed directly into `bin/`). Version specs map to release tags
6//! (`latest` → the latest release). Mirrors: a CN GitHub proxy fallback.
7
8use 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
27/// A github backend bound to a specific `owner/repo`.
28pub struct GithubBackend {
29    /// The full addressed id, e.g. "github:cli/cli".
30    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    /// Parse a `github:owner/repo` id into a backend. Returns None if the id
98    /// doesn't carry a valid owner/repo.
99    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                    // A mirror can report its own shared GitHub quota for one
224                    // guessed tag. Preserve that diagnostic, but still try the
225                    // remaining concrete tag spellings and transports.
226                    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        // Checksum discovery, strongest first:
554        // 1. a minisign-signed checksums manifest (trusted key);
555        // 2. per-asset sidecar / unsigned shared manifest.
556        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    /// Score how well an asset name matches the host platform. Higher is better;
599    /// None means it clearly doesn't match (wrong os/arch).
600    fn score_asset(&self, name: &str, ctx: &Ctx, rules: Option<&AssetRules>) -> Option<i32> {
601        let n = name.to_ascii_lowercase();
602        // Skip checksums/signatures/source archives.
603        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        // Some assets omit OS (bare binaries); allow but score lower.
628        let mut score = 0;
629        if os_ok {
630            score += 10;
631        } else if mentions_other_os_token(&n, target_os) {
632            return None; // explicitly a different OS
633        }
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        // Prefer archives we can extract; then musl/gnu preferences on linux.
654        if ArchiveKind::from_name(&n).is_ok() {
655            score += 3;
656        }
657        if target_os == "linux" {
658            if n.contains("musl") {
659                score += 1; // static, more portable
660            }
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        // Probing the API is rate-limited; skip (selection falls back to order).
685        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        // API returns newest-first; want oldest-first.
702        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        // For github, an exact tag passes through; otherwise resolve against the
802        // release list (latest/prefix).
803        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        // The shared archive pipeline only keeps its install lock while it is
862        // materializing files. GitHub-specific post-processing and inventory
863        // publication happen afterwards, and the bare-binary path does not use
864        // that lock at all. Keep a separate outer lock across the whole
865        // operation so two direct backend callers cannot race and bind the
866        // winner's bytes to the loser's option identity.
867        let _identity_lock = acquire_github_install_lock(&locator).await?;
868        if validate_complete_install_identity(ctx, self, tv, &locator)? {
869            return Ok(());
870        }
871        // Archive vs bare binary.
872        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                    // Some archives have a top dir, some don't; strip only when a
882                    // single root dir is present (extract handles the no-op).
883                    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                // Treat as a bare executable named after the repo.
910                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        // Archives may put binaries at root or in bin/; expose both.
992        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
1049/// Derive the exact locator for a GitHub request whose locked artifact
1050/// material is already present. This variant is used by lockfile serialization
1051/// where only directory and platform state are available.
1052pub 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
1198/// Validate an already-complete install without ever upgrading or rewriting
1199/// its inventory. Returning `false` means no complete install exists and the
1200/// caller may perform a real installation. Any complete install that cannot
1201/// prove the requested schema-1 install identity is rejected fail-closed.
1202fn 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
1230/// Validate a scanned GitHub candidate without consulting the network. This is
1231/// shared by lifecycle reuse and restart-time config/activation recovery.
1232pub 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
1240/// Validate a persisted GitHub candidate when callers only have storage and
1241/// platform state (for example lockfile serialization after installation).
1242pub 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(&quote @ (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("&amp;", "&")
1592        .replace("&quot;", "\"")
1593        .replace("&#39;", "'")
1594        .replace("&apos;", "'")
1595        .replace("&lt;", "<")
1596        .replace("&gt;", ">")
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, &regex).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&amp;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("&amp;"));
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, &current, 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}