Skip to main content

greentic_dev/
install.rs

1use std::fs;
2use std::future::Future;
3use std::io::IsTerminal;
4use std::io::{Cursor, Read};
5use std::path::{Component, Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result, anyhow, bail};
9use async_trait::async_trait;
10use flate2::read::GzDecoder;
11use greentic_distributor_client::oci_packs::{OciPackFetcher, PackFetchOptions, RegistryClient};
12use oci_distribution::Reference;
13use oci_distribution::client::{Client, ClientConfig, ClientProtocol, ImageData};
14use oci_distribution::errors::OciDistributionError;
15use oci_distribution::manifest::{IMAGE_MANIFEST_MEDIA_TYPE, OCI_IMAGE_MEDIA_TYPE};
16use oci_distribution::secrets::RegistryAuth;
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19use tar::Archive;
20use zip::ZipArchive;
21
22use crate::cli::InstallArgs;
23use crate::cmd::tools;
24use crate::i18n;
25
26const CUSTOMERS_TOOLS_REPO: &str = "ghcr.io/greentic-biz/customers-tools";
27const CUSTOMERS_TOOLS_GITHUB_OWNER: &str = "greentic-biz";
28const CUSTOMERS_TOOLS_GITHUB_REPO: &str = "customers-tools";
29const CUSTOMERS_TOOLS_GITHUB_RELEASE_TAG: &str = "latest";
30const OCI_LAYER_JSON_MEDIA_TYPE: &str = "application/json";
31const OAUTH_USER: &str = "oauth2";
32
33pub fn run(args: InstallArgs) -> Result<()> {
34    let locale = i18n::select_locale(args.locale.as_deref());
35
36    let Some(tenant) = args.tenant else {
37        println!("Use `greentic-dev install tools` for development/bootstrap tools.");
38        println!("Use `gtc install` for customer-approved pinned releases.");
39        println!("Pass `--tenant` to install tenant artifacts and docs.");
40        return Ok(());
41    };
42
43    tools::install(false, &locale)?;
44
45    let token = resolve_token(args.token, &locale)
46        .context(i18n::t(&locale, "cli.install.error.tenant_requires_token"))?;
47
48    let env = InstallEnv::detect(args.bin_dir, args.docs_dir, Some(locale))?;
49    let installer = Installer::new(RealTenantManifestSource, RealHttpDownloader::default(), env);
50    installer.install_tenant(&tenant, &token)
51}
52
53fn resolve_token(raw: Option<String>, locale: &str) -> Result<String> {
54    resolve_token_with(
55        raw,
56        std::io::stdin().is_terminal() && std::io::stdout().is_terminal(),
57        || prompt_for_token(locale),
58        locale,
59    )
60}
61
62fn resolve_token_with<F>(
63    raw: Option<String>,
64    interactive: bool,
65    prompt: F,
66    locale: &str,
67) -> Result<String>
68where
69    F: FnOnce() -> Result<String>,
70{
71    let Some(raw) = raw else {
72        if interactive {
73            return prompt();
74        }
75        bail!(
76            "{}",
77            i18n::t(locale, "cli.install.error.missing_token_non_interactive")
78        );
79    };
80    if let Some(var) = raw.strip_prefix("env:") {
81        let value = std::env::var(var).with_context(|| {
82            i18n::tf(
83                locale,
84                "cli.install.error.env_token_resolve",
85                &[("var", var.to_string())],
86            )
87        })?;
88        if value.trim().is_empty() {
89            bail!(
90                "{}",
91                i18n::tf(
92                    locale,
93                    "cli.install.error.env_token_empty",
94                    &[("var", var.to_string())],
95                )
96            );
97        }
98        Ok(value)
99    } else if raw.trim().is_empty() {
100        if interactive {
101            prompt()
102        } else {
103            bail!(
104                "{}",
105                i18n::t(locale, "cli.install.error.empty_token_non_interactive")
106            );
107        }
108    } else {
109        Ok(raw)
110    }
111}
112
113fn prompt_for_token(locale: &str) -> Result<String> {
114    let token = rpassword::prompt_password(i18n::t(locale, "cli.install.prompt.github_token"))
115        .context(i18n::t(locale, "cli.install.error.read_token"))?;
116    if token.trim().is_empty() {
117        bail!("{}", i18n::t(locale, "cli.install.error.empty_token"));
118    }
119    Ok(token)
120}
121
122#[derive(Clone, Debug)]
123struct InstallEnv {
124    install_root: PathBuf,
125    bin_dir: PathBuf,
126    docs_dir: PathBuf,
127    downloads_dir: PathBuf,
128    manifests_dir: PathBuf,
129    state_path: PathBuf,
130    platform: Platform,
131    locale: String,
132}
133
134impl InstallEnv {
135    fn detect(
136        bin_dir: Option<PathBuf>,
137        docs_dir: Option<PathBuf>,
138        locale: Option<String>,
139    ) -> Result<Self> {
140        let locale = locale.clone().unwrap_or_else(|| "en-US".to_string());
141        let home = dirs::home_dir().context(i18n::t(&locale, "cli.install.error.home_dir"))?;
142        let greentic_root = home.join(".greentic");
143        let install_root = greentic_root.join("install");
144        let bin_dir = match bin_dir {
145            Some(path) => path,
146            None => default_bin_dir(&home),
147        };
148        let docs_dir = docs_dir.unwrap_or_else(|| install_root.join("docs"));
149        let downloads_dir = install_root.join("downloads");
150        let manifests_dir = install_root.join("manifests");
151        let state_path = install_root.join("state.json");
152        Ok(Self {
153            install_root,
154            bin_dir,
155            docs_dir,
156            downloads_dir,
157            manifests_dir,
158            state_path,
159            platform: Platform::detect()?,
160            locale,
161        })
162    }
163
164    fn ensure_dirs(&self) -> Result<()> {
165        for dir in [
166            &self.install_root,
167            &self.bin_dir,
168            &self.docs_dir,
169            &self.downloads_dir,
170            &self.manifests_dir,
171        ] {
172            fs::create_dir_all(dir).with_context(|| {
173                i18n::tf(
174                    &self.locale,
175                    "cli.install.error.create_dir",
176                    &[("path", dir.display().to_string())],
177                )
178            })?;
179        }
180        Ok(())
181    }
182}
183
184fn default_bin_dir(home: &Path) -> PathBuf {
185    if let Ok(path) = std::env::var("CARGO_HOME") {
186        PathBuf::from(path).join("bin")
187    } else {
188        home.join(".cargo").join("bin")
189    }
190}
191
192#[derive(Clone, Debug, PartialEq, Eq)]
193struct Platform {
194    os: String,
195    arch: String,
196}
197
198impl Platform {
199    fn detect() -> Result<Self> {
200        let os = match std::env::consts::OS {
201            "linux" => "linux",
202            "windows" => "windows",
203            "macos" => "macos",
204            other => bail!(
205                "{}",
206                i18n::tf(
207                    "en",
208                    "cli.install.error.unsupported_os",
209                    &[("os", other.to_string())],
210                )
211            ),
212        };
213        let arch = match std::env::consts::ARCH {
214            "x86_64" => "x86_64",
215            "aarch64" => "aarch64",
216            other => bail!(
217                "{}",
218                i18n::tf(
219                    "en",
220                    "cli.install.error.unsupported_arch",
221                    &[("arch", other.to_string())],
222                )
223            ),
224        };
225        Ok(Self {
226            os: os.to_string(),
227            arch: arch.to_string(),
228        })
229    }
230}
231
232#[derive(Debug, Clone, Deserialize, Serialize)]
233struct TenantInstallManifest {
234    #[serde(rename = "$schema", default)]
235    schema: Option<String>,
236    schema_version: String,
237    tenant: String,
238    #[serde(default)]
239    tools: Vec<TenantToolDescriptor>,
240    #[serde(default)]
241    docs: Vec<TenantDocDescriptor>,
242}
243
244#[derive(Debug, Clone, Deserialize, Serialize)]
245struct TenantToolEntry {
246    #[serde(rename = "$schema", default)]
247    schema: Option<String>,
248    id: String,
249    name: String,
250    #[serde(default)]
251    description: Option<String>,
252    install: ToolInstall,
253    #[serde(default)]
254    docs: Vec<String>,
255    #[serde(default)]
256    i18n: std::collections::BTreeMap<String, ToolTranslation>,
257}
258
259#[derive(Debug, Clone, Deserialize, Serialize)]
260struct TenantDocEntry {
261    #[serde(rename = "$schema", default)]
262    schema: Option<String>,
263    id: String,
264    title: String,
265    source: DocSource,
266    download_file_name: String,
267    #[serde(alias = "relative_path")]
268    default_relative_path: String,
269    #[serde(default)]
270    i18n: std::collections::BTreeMap<String, DocTranslation>,
271}
272
273#[derive(Debug, Clone, Deserialize, Serialize)]
274struct RemoteDocManifest {
275    #[serde(rename = "$schema", default)]
276    schema: Option<String>,
277    #[serde(default)]
278    schema_version: Option<String>,
279    id: String,
280    #[serde(default)]
281    title: Option<String>,
282    #[serde(default)]
283    source: Option<DocSource>,
284    #[serde(default)]
285    download_file_name: Option<String>,
286    #[serde(alias = "relative_path", default)]
287    default_relative_path: Option<String>,
288    #[serde(default)]
289    docs: Vec<RemoteDocManifestEntry>,
290    #[serde(default)]
291    i18n: std::collections::BTreeMap<String, DocTranslation>,
292}
293
294#[derive(Debug, Clone, Deserialize, Serialize)]
295struct RemoteDocManifestEntry {
296    title: String,
297    source: DocSource,
298    download_file_name: String,
299    #[serde(alias = "relative_path")]
300    default_relative_path: String,
301    #[serde(default)]
302    i18n: std::collections::BTreeMap<String, DocTranslation>,
303}
304
305#[derive(Debug, Clone, Deserialize, Serialize)]
306struct SimpleTenantToolEntry {
307    id: String,
308    #[serde(default)]
309    binary_name: Option<String>,
310    targets: Vec<ReleaseTarget>,
311}
312
313#[derive(Debug, Clone, Deserialize, Serialize)]
314struct SimpleTenantDocEntry {
315    url: String,
316    #[serde(alias = "download_file_name")]
317    file_name: String,
318}
319
320#[derive(Debug, Clone, Deserialize, Serialize)]
321#[serde(untagged)]
322enum TenantToolDescriptor {
323    Expanded(TenantToolEntry),
324    Simple(SimpleTenantToolEntry),
325    Ref(RemoteManifestRef),
326    Id(String),
327}
328
329#[derive(Debug, Clone, Deserialize, Serialize)]
330#[serde(untagged)]
331enum TenantDocDescriptor {
332    Expanded(TenantDocEntry),
333    Simple(SimpleTenantDocEntry),
334    Ref(RemoteManifestRef),
335    Id(String),
336}
337
338#[derive(Debug, Clone, Deserialize, Serialize)]
339struct RemoteManifestRef {
340    id: String,
341    #[serde(alias = "manifest_url")]
342    url: String,
343}
344
345impl RemoteDocManifest {
346    fn into_entries(self) -> Result<Vec<TenantDocEntry>> {
347        let has_single_doc_fields = self.title.is_some()
348            || self.source.is_some()
349            || self.download_file_name.is_some()
350            || self.default_relative_path.is_some();
351        if has_single_doc_fields && !self.docs.is_empty() {
352            bail!(
353                "doc manifest `{}` must not mix single-doc fields with docs[]",
354                self.id
355            );
356        }
357
358        if !self.docs.is_empty() {
359            let schema = self.schema.clone();
360            let manifest_id = self.id;
361            return Ok(self
362                .docs
363                .into_iter()
364                .map(|entry| TenantDocEntry {
365                    schema: schema.clone(),
366                    id: format!("{}:{}", manifest_id, entry.download_file_name),
367                    title: entry.title,
368                    source: entry.source,
369                    download_file_name: entry.download_file_name,
370                    default_relative_path: entry.default_relative_path,
371                    i18n: entry.i18n,
372                })
373                .collect());
374        }
375
376        let Some(title) = self.title else {
377            bail!("doc manifest `{}` is missing `title`", self.id);
378        };
379        let Some(source) = self.source else {
380            bail!("doc manifest `{}` is missing `source`", self.id);
381        };
382        let Some(download_file_name) = self.download_file_name else {
383            bail!("doc manifest `{}` is missing `download_file_name`", self.id);
384        };
385        let Some(default_relative_path) = self.default_relative_path else {
386            bail!(
387                "doc manifest `{}` is missing `default_relative_path`",
388                self.id
389            );
390        };
391
392        Ok(vec![TenantDocEntry {
393            schema: self.schema,
394            id: self.id,
395            title,
396            source,
397            download_file_name,
398            default_relative_path,
399            i18n: self.i18n,
400        }])
401    }
402}
403
404#[derive(Debug, Clone, Default, Deserialize, Serialize)]
405struct ToolTranslation {
406    #[serde(default)]
407    name: Option<String>,
408    #[serde(default)]
409    description: Option<String>,
410    #[serde(default)]
411    docs: Option<Vec<String>>,
412}
413
414#[derive(Debug, Clone, Default, Deserialize, Serialize)]
415struct DocTranslation {
416    #[serde(default)]
417    title: Option<String>,
418    #[serde(default)]
419    download_file_name: Option<String>,
420    #[serde(default)]
421    default_relative_path: Option<String>,
422    #[serde(default)]
423    source: Option<DocSource>,
424}
425
426#[derive(Debug, Clone, Deserialize, Serialize)]
427struct ToolInstall {
428    #[serde(rename = "type")]
429    install_type: String,
430    binary_name: String,
431    targets: Vec<ReleaseTarget>,
432}
433
434#[derive(Debug, Clone, Deserialize, Serialize)]
435struct ReleaseTarget {
436    os: String,
437    arch: String,
438    url: String,
439    #[serde(default)]
440    sha256: Option<String>,
441}
442
443#[derive(Debug, Clone, Deserialize, Serialize)]
444struct DocSource {
445    #[serde(rename = "type")]
446    source_type: String,
447    url: String,
448}
449
450#[derive(Debug, Deserialize)]
451struct GithubRelease {
452    assets: Vec<GithubReleaseAsset>,
453}
454
455#[derive(Debug, Deserialize)]
456struct GithubReleaseAsset {
457    name: String,
458    url: String,
459}
460
461#[derive(Debug, Serialize, Deserialize)]
462struct InstallState {
463    tenant: String,
464    locale: String,
465    manifest_path: String,
466    installed_bins: Vec<String>,
467    installed_docs: Vec<String>,
468}
469
470#[async_trait]
471trait TenantManifestSource: Send + Sync {
472    async fn fetch_manifest(&self, tenant: &str, token: &str) -> Result<Vec<u8>>;
473}
474
475#[async_trait]
476trait Downloader: Send + Sync {
477    async fn download(&self, url: &str, token: &str) -> Result<Vec<u8>>;
478}
479
480struct Installer<S, D> {
481    source: S,
482    downloader: D,
483    env: InstallEnv,
484}
485
486impl<S, D> Installer<S, D>
487where
488    S: TenantManifestSource,
489    D: Downloader,
490{
491    fn new(source: S, downloader: D, env: InstallEnv) -> Self {
492        Self {
493            source,
494            downloader,
495            env,
496        }
497    }
498
499    fn install_tenant(&self, tenant: &str, token: &str) -> Result<()> {
500        block_on_maybe_runtime(self.install_tenant_async(tenant, token))
501    }
502
503    async fn install_tenant_async(&self, tenant: &str, token: &str) -> Result<()> {
504        self.env.ensure_dirs()?;
505        let manifest_bytes = self.source.fetch_manifest(tenant, token).await?;
506        let manifest: TenantInstallManifest = serde_json::from_slice(&manifest_bytes)
507            .with_context(|| {
508                i18n::tf(
509                    &self.env.locale,
510                    "cli.install.error.parse_tenant_manifest",
511                    &[("tenant", tenant.to_string())],
512                )
513            })?;
514        if manifest.tenant != tenant {
515            bail!(
516                "{}",
517                i18n::tf(
518                    &self.env.locale,
519                    "cli.install.error.tenant_manifest_mismatch",
520                    &[
521                        ("tenant", tenant.to_string()),
522                        ("manifest_tenant", manifest.tenant.clone())
523                    ]
524                )
525            );
526        }
527
528        let mut installed_bins = Vec::new();
529        let mut installed_tool_entries = Vec::new();
530        for tool in &manifest.tools {
531            let tool = self.resolve_tool(tool, token).await?;
532            let path = self.install_tool(&tool, token).await?;
533            installed_tool_entries.push((tool.id.clone(), path.clone()));
534            installed_bins.push(path.display().to_string());
535        }
536
537        let mut installed_docs = Vec::new();
538        let mut installed_doc_entries = Vec::new();
539        for doc in &manifest.docs {
540            let docs = self.resolve_doc(doc, token).await?;
541            for doc in docs {
542                let path = self.install_doc(&doc, token).await?;
543                installed_doc_entries.push((doc.id.clone(), path.clone()));
544                installed_docs.push(path.display().to_string());
545            }
546        }
547
548        let manifest_path = self.env.manifests_dir.join(format!("tenant-{tenant}.json"));
549        fs::write(&manifest_path, &manifest_bytes).with_context(|| {
550            i18n::tf(
551                &self.env.locale,
552                "cli.install.error.write_file",
553                &[("path", manifest_path.display().to_string())],
554            )
555        })?;
556        let state = InstallState {
557            tenant: tenant.to_string(),
558            locale: self.env.locale.clone(),
559            manifest_path: manifest_path.display().to_string(),
560            installed_bins,
561            installed_docs,
562        };
563        let state_json = serde_json::to_vec_pretty(&state).context(i18n::t(
564            &self.env.locale,
565            "cli.install.error.serialize_state",
566        ))?;
567        fs::write(&self.env.state_path, state_json).with_context(|| {
568            i18n::tf(
569                &self.env.locale,
570                "cli.install.error.write_file",
571                &[("path", self.env.state_path.display().to_string())],
572            )
573        })?;
574        print_install_summary(
575            &self.env.locale,
576            &installed_tool_entries,
577            &installed_doc_entries,
578        );
579        Ok(())
580    }
581
582    async fn resolve_tool(
583        &self,
584        tool: &TenantToolDescriptor,
585        token: &str,
586    ) -> Result<TenantToolEntry> {
587        match tool {
588            TenantToolDescriptor::Expanded(entry) => Ok(entry.clone()),
589            TenantToolDescriptor::Simple(entry) => Ok(TenantToolEntry {
590                schema: None,
591                id: entry.id.clone(),
592                name: entry.id.clone(),
593                description: None,
594                install: ToolInstall {
595                    install_type: "release-binary".to_string(),
596                    binary_name: entry
597                        .binary_name
598                        .clone()
599                        .unwrap_or_else(|| entry.id.clone()),
600                    targets: entry.targets.clone(),
601                },
602                docs: Vec::new(),
603                i18n: std::collections::BTreeMap::new(),
604            }),
605            TenantToolDescriptor::Ref(reference) => {
606                enforce_github_url(&reference.url)?;
607                let bytes = self.downloader.download(&reference.url, token).await?;
608                let manifest: TenantToolEntry =
609                    serde_json::from_slice(&bytes).with_context(|| {
610                        format!("failed to parse tool manifest `{}`", reference.url)
611                    })?;
612                if manifest.id != reference.id {
613                    bail!(
614                        "tool manifest mismatch: tenant referenced `{}` but manifest contained `{}`",
615                        reference.id,
616                        manifest.id
617                    );
618                }
619                Ok(manifest)
620            }
621            TenantToolDescriptor::Id(id) => bail!(
622                "tool id `{id}` requires a manifest URL; bare IDs are not supported by greentic-dev"
623            ),
624        }
625    }
626
627    async fn resolve_doc(
628        &self,
629        doc: &TenantDocDescriptor,
630        token: &str,
631    ) -> Result<Vec<TenantDocEntry>> {
632        match doc {
633            TenantDocDescriptor::Expanded(entry) => Ok(vec![entry.clone()]),
634            TenantDocDescriptor::Simple(entry) => Ok(vec![TenantDocEntry {
635                schema: None,
636                id: entry.file_name.clone(),
637                title: entry.file_name.clone(),
638                source: DocSource {
639                    source_type: "download".to_string(),
640                    url: entry.url.clone(),
641                },
642                download_file_name: entry.file_name.clone(),
643                default_relative_path: entry.file_name.clone(),
644                i18n: std::collections::BTreeMap::new(),
645            }]),
646            TenantDocDescriptor::Ref(reference) => {
647                enforce_github_url(&reference.url)?;
648                let bytes = self.downloader.download(&reference.url, token).await?;
649                let manifest: RemoteDocManifest = serde_json::from_slice(&bytes)
650                    .with_context(|| format!("failed to parse doc manifest `{}`", reference.url))?;
651                if manifest.id != reference.id {
652                    bail!(
653                        "doc manifest mismatch: tenant referenced `{}` but manifest contained `{}`",
654                        reference.id,
655                        manifest.id
656                    );
657                }
658                manifest.into_entries()
659            }
660            TenantDocDescriptor::Id(id) => bail!(
661                "doc id `{id}` requires a manifest URL; bare IDs are not supported by greentic-dev"
662            ),
663        }
664    }
665
666    async fn install_tool(&self, tool: &TenantToolEntry, token: &str) -> Result<PathBuf> {
667        let tool = apply_tool_locale(tool, &self.env.locale);
668        if tool.install.install_type != "release-binary" {
669            bail!(
670                "tool `{}` has unsupported install type `{}`",
671                tool.id,
672                tool.install.install_type
673            );
674        }
675        let target = select_release_target(&tool.install.targets, &self.env.platform)
676            .with_context(|| format!("failed to select release target for `{}`", tool.id))?;
677        enforce_github_url(&target.url)?;
678        let bytes = self.downloader.download(&target.url, token).await?;
679        if let Some(sha256) = &target.sha256 {
680            verify_sha256(&bytes, sha256)
681                .with_context(|| format!("checksum verification failed for `{}`", tool.id))?;
682        }
683
684        let target_name = binary_filename(&expected_binary_name(
685            &tool.install.binary_name,
686            &target.url,
687        ));
688        let staged_path =
689            self.env
690                .downloads_dir
691                .join(format!("{}-{}", tool.id, file_name_hint(&target.url)));
692        fs::write(&staged_path, &bytes)
693            .with_context(|| format!("failed to write {}", staged_path.display()))?;
694
695        let installed_path = if target.url.ends_with(".tar.gz") || target.url.ends_with(".tgz") {
696            extract_tar_gz_binary(&bytes, &target_name, &self.env.bin_dir)?
697        } else if target.url.ends_with(".zip") {
698            extract_zip_binary(&bytes, &target_name, &self.env.bin_dir)?
699        } else {
700            let dest_path = self.env.bin_dir.join(&target_name);
701            fs::write(&dest_path, &bytes)
702                .with_context(|| format!("failed to write {}", dest_path.display()))?;
703            dest_path
704        };
705
706        ensure_executable(&installed_path)?;
707        Ok(installed_path)
708    }
709
710    async fn install_doc(&self, doc: &TenantDocEntry, token: &str) -> Result<PathBuf> {
711        let doc = apply_doc_locale(doc, &self.env.locale);
712        if doc.source.source_type != "download" {
713            bail!(
714                "doc `{}` has unsupported source type `{}`",
715                doc.id,
716                doc.source.source_type
717            );
718        }
719        enforce_github_url(&doc.source.url)?;
720        let relative = sanitize_relative_path(&doc.default_relative_path)?;
721        let dest_path = self.env.docs_dir.join(relative);
722        if let Some(parent) = dest_path.parent() {
723            fs::create_dir_all(parent)
724                .with_context(|| format!("failed to create {}", parent.display()))?;
725        }
726        let bytes = self.downloader.download(&doc.source.url, token).await?;
727        fs::write(&dest_path, &bytes)
728            .with_context(|| format!("failed to write {}", dest_path.display()))?;
729        Ok(dest_path)
730    }
731}
732
733pub(crate) fn block_on_maybe_runtime<F, T>(future: F) -> Result<T>
734where
735    F: Future<Output = Result<T>>,
736{
737    if let Ok(handle) = tokio::runtime::Handle::try_current() {
738        tokio::task::block_in_place(|| handle.block_on(future))
739    } else {
740        let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?;
741        rt.block_on(future)
742    }
743}
744
745fn apply_tool_locale(tool: &TenantToolEntry, locale: &str) -> TenantToolEntry {
746    let mut localized = tool.clone();
747    if let Some(translation) = resolve_translation(&tool.i18n, locale) {
748        if let Some(name) = &translation.name {
749            localized.name = name.clone();
750        }
751        if let Some(description) = &translation.description {
752            localized.description = Some(description.clone());
753        }
754        if let Some(docs) = &translation.docs {
755            localized.docs = docs.clone();
756        }
757    }
758    localized
759}
760
761fn apply_doc_locale(doc: &TenantDocEntry, locale: &str) -> TenantDocEntry {
762    let mut localized = doc.clone();
763    if let Some(translation) = resolve_translation(&doc.i18n, locale) {
764        if let Some(title) = &translation.title {
765            localized.title = title.clone();
766        }
767        if let Some(download_file_name) = &translation.download_file_name {
768            localized.download_file_name = download_file_name.clone();
769        }
770        if let Some(default_relative_path) = &translation.default_relative_path {
771            localized.default_relative_path = default_relative_path.clone();
772        }
773        if let Some(source) = &translation.source {
774            localized.source = source.clone();
775        }
776    }
777    localized
778}
779
780fn resolve_translation<'a, T>(
781    map: &'a std::collections::BTreeMap<String, T>,
782    locale: &str,
783) -> Option<&'a T> {
784    if let Some(exact) = map.get(locale) {
785        return Some(exact);
786    }
787    let lang = locale.split(['-', '_']).next().unwrap_or(locale);
788    map.get(lang)
789}
790
791fn binary_filename(name: &str) -> String {
792    if cfg!(windows) && !name.ends_with(".exe") {
793        format!("{name}.exe")
794    } else {
795        name.to_string()
796    }
797}
798
799fn file_name_hint(url: &str) -> String {
800    url.rsplit('/')
801        .next()
802        .filter(|part| !part.is_empty())
803        .unwrap_or("download.bin")
804        .to_string()
805}
806
807fn expected_binary_name(configured: &str, url: &str) -> String {
808    let fallback = configured.to_string();
809    let asset = file_name_hint(url);
810    let stem = asset
811        .strip_suffix(".tar.gz")
812        .or_else(|| asset.strip_suffix(".tgz"))
813        .or_else(|| asset.strip_suffix(".zip"))
814        .unwrap_or(asset.as_str());
815    if let Some(prefix) = stem
816        .strip_suffix("-x86_64-unknown-linux-gnu")
817        .or_else(|| stem.strip_suffix("-aarch64-unknown-linux-gnu"))
818        .or_else(|| stem.strip_suffix("-x86_64-apple-darwin"))
819        .or_else(|| stem.strip_suffix("-aarch64-apple-darwin"))
820        .or_else(|| stem.strip_suffix("-x86_64-pc-windows-msvc"))
821        .or_else(|| stem.strip_suffix("-aarch64-pc-windows-msvc"))
822    {
823        return strip_version_suffix(prefix);
824    }
825    fallback
826}
827
828fn strip_version_suffix(name: &str) -> String {
829    let Some((prefix, last)) = name.rsplit_once('-') else {
830        return name.to_string();
831    };
832    if is_version_segment(last) {
833        prefix.to_string()
834    } else {
835        name.to_string()
836    }
837}
838
839fn is_version_segment(segment: &str) -> bool {
840    let trimmed = segment.strip_prefix('v').unwrap_or(segment);
841    !trimmed.is_empty()
842        && trimmed
843            .chars()
844            .all(|ch| ch.is_ascii_digit() || ch == '.' || ch == '_' || ch == '-')
845        && trimmed.chars().any(|ch| ch.is_ascii_digit())
846}
847
848fn select_release_target<'a>(
849    targets: &'a [ReleaseTarget],
850    platform: &Platform,
851) -> Result<&'a ReleaseTarget> {
852    targets
853        .iter()
854        .find(|target| target.os == platform.os && target.arch == platform.arch)
855        .ok_or_else(|| anyhow!("no target for {} / {}", platform.os, platform.arch))
856}
857
858fn verify_sha256(bytes: &[u8], expected: &str) -> Result<()> {
859    let actual = sha256_hex(bytes);
860    if actual != expected.to_ascii_lowercase() {
861        bail!("sha256 mismatch: expected {expected}, got {actual}");
862    }
863    Ok(())
864}
865
866fn sha256_hex(bytes: &[u8]) -> String {
867    let digest = Sha256::digest(bytes);
868    let mut output = String::with_capacity(digest.len() * 2);
869    for byte in digest {
870        output.push_str(&format!("{byte:02x}"));
871    }
872    output
873}
874
875fn sanitize_relative_path(path: &str) -> Result<PathBuf> {
876    let pb = PathBuf::from(path);
877    if pb.is_absolute() {
878        bail!("absolute doc install paths are not allowed");
879    }
880    for component in pb.components() {
881        if matches!(
882            component,
883            Component::ParentDir | Component::RootDir | Component::Prefix(_)
884        ) {
885            bail!("doc install path must stay within the docs directory");
886        }
887    }
888    Ok(pb)
889}
890
891fn extract_tar_gz_binary(bytes: &[u8], binary_name: &str, dest_dir: &Path) -> Result<PathBuf> {
892    let decoder = GzDecoder::new(Cursor::new(bytes));
893    let mut archive = Archive::new(decoder);
894    let mut fallback: Option<PathBuf> = None;
895    let mut extracted = Vec::new();
896    for entry in archive.entries().context("failed to read tar.gz archive")? {
897        let mut entry = entry.context("failed to read tar.gz archive entry")?;
898        let path = entry.path().context("failed to read tar.gz entry path")?;
899        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
900            continue;
901        };
902        let name = name.to_string();
903        if !entry.header().entry_type().is_file() {
904            continue;
905        }
906        let out_path = dest_dir.join(&name);
907        let mut buf = Vec::new();
908        entry
909            .read_to_end(&mut buf)
910            .with_context(|| format!("failed to extract `{name}` from tar.gz"))?;
911        fs::write(&out_path, buf)
912            .with_context(|| format!("failed to write {}", out_path.display()))?;
913        extracted.push(out_path.clone());
914        if name == binary_name {
915            return Ok(out_path);
916        }
917        if fallback.is_none() && archive_name_matches(binary_name, &name) {
918            fallback = Some(out_path);
919        }
920    }
921    if let Some(path) = fallback {
922        return Ok(path);
923    }
924    if let Some(path) = extracted.into_iter().next() {
925        return Ok(path);
926    }
927    let (debug_dir, entries) = dump_tar_gz_debug(bytes, binary_name)?;
928    bail!(
929        "archive did not contain `{binary_name}`. extracted debug dump to `{}` with entries: {}",
930        debug_dir.display(),
931        entries.join(", ")
932    );
933}
934
935fn extract_zip_binary(bytes: &[u8], binary_name: &str, dest_dir: &Path) -> Result<PathBuf> {
936    let cursor = Cursor::new(bytes);
937    let mut archive = ZipArchive::new(cursor).context("failed to open zip archive")?;
938    let mut fallback: Option<PathBuf> = None;
939    let mut extracted = Vec::new();
940    for idx in 0..archive.len() {
941        let mut file = archive
942            .by_index(idx)
943            .context("failed to read zip archive entry")?;
944        if file.is_dir() {
945            continue;
946        }
947        let Some(name) = Path::new(file.name())
948            .file_name()
949            .and_then(|name| name.to_str())
950        else {
951            continue;
952        };
953        let name = name.to_string();
954        let out_path = dest_dir.join(&name);
955        let mut buf = Vec::new();
956        file.read_to_end(&mut buf)
957            .with_context(|| format!("failed to extract `{name}` from zip"))?;
958        fs::write(&out_path, buf)
959            .with_context(|| format!("failed to write {}", out_path.display()))?;
960        extracted.push(out_path.clone());
961        if name == binary_name {
962            return Ok(out_path);
963        }
964        if fallback.is_none() && archive_name_matches(binary_name, &name) {
965            fallback = Some(out_path);
966        }
967    }
968    if let Some(path) = fallback {
969        return Ok(path);
970    }
971    if let Some(path) = extracted.into_iter().next() {
972        return Ok(path);
973    }
974    let (debug_dir, entries) = dump_zip_debug(bytes, binary_name)?;
975    bail!(
976        "archive did not contain `{binary_name}`. extracted debug dump to `{}` with entries: {}",
977        debug_dir.display(),
978        entries.join(", ")
979    );
980}
981
982fn archive_name_matches(expected: &str, actual: &str) -> bool {
983    let expected = expected.strip_suffix(".exe").unwrap_or(expected);
984    let actual = actual.strip_suffix(".exe").unwrap_or(actual);
985    actual == expected
986        || actual.starts_with(&format!("{expected}-"))
987        || actual.starts_with(&format!("{expected}_"))
988        || strip_version_suffix(actual) == expected
989}
990
991fn print_install_summary(locale: &str, tools: &[(String, PathBuf)], docs: &[(String, PathBuf)]) {
992    println!("{}", i18n::t(locale, "cli.install.summary.tools"));
993    for (id, path) in tools {
994        println!(
995            "{}",
996            i18n::tf(
997                locale,
998                "cli.install.summary.tool_item",
999                &[("id", id.clone()), ("path", path.display().to_string()),],
1000            )
1001        );
1002    }
1003    println!("{}", i18n::t(locale, "cli.install.summary.docs"));
1004    for (id, path) in docs {
1005        println!(
1006            "{}",
1007            i18n::tf(
1008                locale,
1009                "cli.install.summary.doc_item",
1010                &[("id", id.clone()), ("path", path.display().to_string()),],
1011            )
1012        );
1013    }
1014}
1015
1016fn dump_tar_gz_debug(bytes: &[u8], binary_name: &str) -> Result<(PathBuf, Vec<String>)> {
1017    let debug_dir = create_archive_debug_dir(binary_name)?;
1018    let decoder = GzDecoder::new(Cursor::new(bytes));
1019    let mut archive = Archive::new(decoder);
1020    let mut entries = Vec::new();
1021    for entry in archive
1022        .entries()
1023        .context("failed to read tar.gz archive for debug dump")?
1024    {
1025        let mut entry = entry.context("failed to read tar.gz archive entry for debug dump")?;
1026        let path = entry
1027            .path()
1028            .context("failed to read tar.gz entry path for debug dump")?
1029            .into_owned();
1030        let display = path.display().to_string();
1031        entries.push(display.clone());
1032        if let Some(relative) = safe_archive_relative_path(&path) {
1033            let out_path = debug_dir.join(relative);
1034            if let Some(parent) = out_path.parent() {
1035                fs::create_dir_all(parent)
1036                    .with_context(|| format!("failed to create {}", parent.display()))?;
1037            }
1038            if entry.header().entry_type().is_dir() {
1039                fs::create_dir_all(&out_path)
1040                    .with_context(|| format!("failed to create {}", out_path.display()))?;
1041            } else if entry.header().entry_type().is_file() {
1042                let mut buf = Vec::new();
1043                entry
1044                    .read_to_end(&mut buf)
1045                    .with_context(|| format!("failed to extract `{display}` for debug dump"))?;
1046                fs::write(&out_path, buf)
1047                    .with_context(|| format!("failed to write {}", out_path.display()))?;
1048            }
1049        }
1050    }
1051    Ok((debug_dir, entries))
1052}
1053
1054fn dump_zip_debug(bytes: &[u8], binary_name: &str) -> Result<(PathBuf, Vec<String>)> {
1055    let debug_dir = create_archive_debug_dir(binary_name)?;
1056    let cursor = Cursor::new(bytes);
1057    let mut archive =
1058        ZipArchive::new(cursor).context("failed to open zip archive for debug dump")?;
1059    let mut entries = Vec::new();
1060    for idx in 0..archive.len() {
1061        let mut file = archive
1062            .by_index(idx)
1063            .context("failed to read zip archive entry for debug dump")?;
1064        let path = PathBuf::from(file.name());
1065        let display = path.display().to_string();
1066        entries.push(display.clone());
1067        if let Some(relative) = safe_archive_relative_path(&path) {
1068            let out_path = debug_dir.join(relative);
1069            if file.is_dir() {
1070                fs::create_dir_all(&out_path)
1071                    .with_context(|| format!("failed to create {}", out_path.display()))?;
1072            } else {
1073                if let Some(parent) = out_path.parent() {
1074                    fs::create_dir_all(parent)
1075                        .with_context(|| format!("failed to create {}", parent.display()))?;
1076                }
1077                let mut buf = Vec::new();
1078                file.read_to_end(&mut buf)
1079                    .with_context(|| format!("failed to extract `{display}` for debug dump"))?;
1080                fs::write(&out_path, buf)
1081                    .with_context(|| format!("failed to write {}", out_path.display()))?;
1082            }
1083        }
1084    }
1085    Ok((debug_dir, entries))
1086}
1087
1088fn create_archive_debug_dir(binary_name: &str) -> Result<PathBuf> {
1089    let stamp = SystemTime::now()
1090        .duration_since(UNIX_EPOCH)
1091        .context("system time before unix epoch")?
1092        .as_millis();
1093    let dir = std::env::temp_dir().join(format!("greentic-dev-debug-{binary_name}-{stamp}"));
1094    fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
1095    Ok(dir)
1096}
1097
1098fn safe_archive_relative_path(path: &Path) -> Option<PathBuf> {
1099    let mut out = PathBuf::new();
1100    for component in path.components() {
1101        match component {
1102            Component::Normal(part) => out.push(part),
1103            Component::CurDir => {}
1104            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
1105        }
1106    }
1107    if out.as_os_str().is_empty() {
1108        None
1109    } else {
1110        Some(out)
1111    }
1112}
1113
1114fn ensure_executable(path: &Path) -> Result<()> {
1115    #[cfg(unix)]
1116    {
1117        use std::os::unix::fs::PermissionsExt;
1118        let mut perms = fs::metadata(path)
1119            .with_context(|| format!("failed to read {}", path.display()))?
1120            .permissions();
1121        perms.set_mode(0o755);
1122        fs::set_permissions(path, perms)
1123            .with_context(|| format!("failed to set executable bit on {}", path.display()))?;
1124    }
1125    Ok(())
1126}
1127
1128fn enforce_github_url(url: &str) -> Result<()> {
1129    let parsed = reqwest::Url::parse(url).with_context(|| format!("invalid URL `{url}`"))?;
1130    let Some(host) = parsed.host_str() else {
1131        bail!("URL `{url}` does not include a host");
1132    };
1133    let allowed = host == "github.com"
1134        || host.ends_with(".github.com")
1135        || host == "raw.githubusercontent.com"
1136        || host.ends_with(".githubusercontent.com")
1137        || host == "127.0.0.1"
1138        || host == "localhost";
1139    if !allowed {
1140        bail!("only GitHub-hosted assets are supported, got `{host}`");
1141    }
1142    Ok(())
1143}
1144
1145struct RealHttpDownloader {
1146    client: reqwest::Client,
1147}
1148
1149impl Default for RealHttpDownloader {
1150    fn default() -> Self {
1151        let client = reqwest::Client::builder()
1152            .user_agent(format!("greentic-dev/{}", env!("CARGO_PKG_VERSION")))
1153            .build()
1154            .expect("failed to build HTTP client");
1155        Self { client }
1156    }
1157}
1158
1159#[async_trait]
1160impl Downloader for RealHttpDownloader {
1161    async fn download(&self, url: &str, token: &str) -> Result<Vec<u8>> {
1162        let response =
1163            if let Some(asset_api_url) = self.resolve_github_asset_api_url(url, token).await? {
1164                self.client
1165                    .get(asset_api_url)
1166                    .bearer_auth(token)
1167                    .header(reqwest::header::ACCEPT, "application/octet-stream")
1168                    .send()
1169                    .await
1170                    .with_context(|| format!("failed to download `{url}`"))?
1171            } else {
1172                self.client
1173                    .get(url)
1174                    .bearer_auth(token)
1175                    .send()
1176                    .await
1177                    .with_context(|| format!("failed to download `{url}`"))?
1178            }
1179            .error_for_status()
1180            .with_context(|| format!("download failed for `{url}`"))?;
1181        let bytes = response
1182            .bytes()
1183            .await
1184            .with_context(|| format!("failed to read response body from `{url}`"))?;
1185        Ok(bytes.to_vec())
1186    }
1187}
1188
1189impl RealHttpDownloader {
1190    async fn resolve_github_asset_api_url(&self, url: &str, token: &str) -> Result<Option<String>> {
1191        let Some(spec) = parse_github_release_url(url) else {
1192            return Ok(None);
1193        };
1194        let api_url = if spec.tag == "latest" {
1195            format!(
1196                "https://api.github.com/repos/{}/{}/releases/latest",
1197                spec.owner, spec.repo
1198            )
1199        } else {
1200            format!(
1201                "https://api.github.com/repos/{}/{}/releases/tags/{}",
1202                spec.owner, spec.repo, spec.tag
1203            )
1204        };
1205        let release = self
1206            .client
1207            .get(api_url)
1208            .bearer_auth(token)
1209            .header(reqwest::header::ACCEPT, "application/vnd.github+json")
1210            .send()
1211            .await
1212            .with_context(|| format!("failed to resolve GitHub release for `{url}`"))?
1213            .error_for_status()
1214            .with_context(|| format!("failed to resolve GitHub release for `{url}`"))?
1215            .json::<GithubRelease>()
1216            .await
1217            .with_context(|| format!("failed to parse GitHub release metadata for `{url}`"))?;
1218        let Some(asset) = release
1219            .assets
1220            .into_iter()
1221            .find(|asset| asset.name == spec.asset_name)
1222        else {
1223            bail!(
1224                "download failed for `{url}`: release asset `{}` not found on tag `{}`",
1225                spec.asset_name,
1226                spec.tag
1227            );
1228        };
1229        Ok(Some(asset.url))
1230    }
1231}
1232
1233struct GithubReleaseUrlSpec {
1234    owner: String,
1235    repo: String,
1236    tag: String,
1237    asset_name: String,
1238}
1239
1240fn parse_github_release_url(url: &str) -> Option<GithubReleaseUrlSpec> {
1241    let parsed = reqwest::Url::parse(url).ok()?;
1242    if parsed.host_str()? != "github.com" {
1243        return None;
1244    }
1245    let segments = parsed.path_segments()?.collect::<Vec<_>>();
1246    if segments.len() < 6 || segments[2] != "releases" {
1247        return None;
1248    }
1249    let (tag, asset_start) = if segments[3] == "download" {
1250        (segments[4], 5)
1251    } else if segments[3] == "latest" && segments[4] == "download" {
1252        ("latest", 5)
1253    } else {
1254        return None;
1255    };
1256    Some(GithubReleaseUrlSpec {
1257        owner: segments[0].to_string(),
1258        repo: segments[1].to_string(),
1259        tag: tag.to_string(),
1260        asset_name: segments[asset_start..].join("/"),
1261    })
1262}
1263
1264#[derive(Clone)]
1265struct AuthRegistryClient {
1266    inner: Client,
1267    token: String,
1268}
1269
1270#[async_trait]
1271impl RegistryClient for AuthRegistryClient {
1272    fn default_client() -> Self {
1273        let config = ClientConfig {
1274            protocol: ClientProtocol::Https,
1275            ..Default::default()
1276        };
1277        Self {
1278            inner: Client::new(config),
1279            token: String::new(),
1280        }
1281    }
1282
1283    async fn pull(
1284        &self,
1285        reference: &Reference,
1286        accepted_manifest_types: &[&str],
1287    ) -> Result<greentic_distributor_client::oci_packs::PulledImage, OciDistributionError> {
1288        let image = self
1289            .inner
1290            .pull(
1291                reference,
1292                &RegistryAuth::Basic(OAUTH_USER.to_string(), self.token.clone()),
1293                accepted_manifest_types.to_vec(),
1294            )
1295            .await?;
1296        Ok(convert_image(image))
1297    }
1298}
1299
1300fn convert_image(image: ImageData) -> greentic_distributor_client::oci_packs::PulledImage {
1301    let layers = image
1302        .layers
1303        .into_iter()
1304        .map(|layer| {
1305            let digest = format!("sha256:{}", layer.sha256_digest());
1306            greentic_distributor_client::oci_packs::PulledLayer {
1307                media_type: layer.media_type,
1308                data: layer.data,
1309                digest: Some(digest),
1310            }
1311        })
1312        .collect();
1313    greentic_distributor_client::oci_packs::PulledImage {
1314        digest: image.digest,
1315        layers,
1316    }
1317}
1318
1319#[derive(Default)]
1320struct RealTenantManifestSource;
1321
1322#[async_trait]
1323impl TenantManifestSource for RealTenantManifestSource {
1324    async fn fetch_manifest(&self, tenant: &str, token: &str) -> Result<Vec<u8>> {
1325        if let Some(bytes) = self.fetch_github_release_manifest(tenant, token).await? {
1326            return Ok(bytes);
1327        }
1328        self.fetch_oci_manifest(tenant, token).await
1329    }
1330}
1331
1332impl RealTenantManifestSource {
1333    async fn fetch_github_release_manifest(
1334        &self,
1335        tenant: &str,
1336        token: &str,
1337    ) -> Result<Option<Vec<u8>>> {
1338        let client = reqwest::Client::builder()
1339            .user_agent(format!("greentic-dev/{}", env!("CARGO_PKG_VERSION")))
1340            .build()
1341            .context("failed to build GitHub HTTP client")?;
1342        let release_url = github_latest_release_api_url();
1343        let response = client
1344            .get(&release_url)
1345            .bearer_auth(token)
1346            .header(reqwest::header::ACCEPT, "application/vnd.github+json")
1347            .send()
1348            .await
1349            .with_context(|| format!("failed to resolve GitHub release `{release_url}`"))?;
1350        if response.status() == reqwest::StatusCode::NOT_FOUND {
1351            return Ok(None);
1352        }
1353        let release = response
1354            .error_for_status()
1355            .with_context(|| format!("failed to resolve GitHub release `{release_url}`"))?
1356            .json::<GithubRelease>()
1357            .await
1358            .with_context(|| format!("failed to parse GitHub release `{release_url}`"))?;
1359        let asset_name = tenant_manifest_asset_name(tenant);
1360        let Some(asset) = release
1361            .assets
1362            .into_iter()
1363            .find(|asset| asset.name == asset_name)
1364        else {
1365            return Ok(None);
1366        };
1367        let response = client
1368            .get(&asset.url)
1369            .bearer_auth(token)
1370            .header(reqwest::header::ACCEPT, "application/octet-stream")
1371            .send()
1372            .await
1373            .with_context(|| format!("failed to download tenant manifest asset `{asset_name}`"))?
1374            .error_for_status()
1375            .with_context(|| format!("failed to download tenant manifest asset `{asset_name}`"))?;
1376        let bytes = response
1377            .bytes()
1378            .await
1379            .with_context(|| format!("failed to read tenant manifest asset `{asset_name}`"))?;
1380        Ok(Some(bytes.to_vec()))
1381    }
1382
1383    async fn fetch_oci_manifest(&self, tenant: &str, token: &str) -> Result<Vec<u8>> {
1384        let opts = PackFetchOptions {
1385            allow_tags: true,
1386            accepted_manifest_types: vec![
1387                OCI_IMAGE_MEDIA_TYPE.to_string(),
1388                IMAGE_MANIFEST_MEDIA_TYPE.to_string(),
1389            ],
1390            accepted_layer_media_types: vec![OCI_LAYER_JSON_MEDIA_TYPE.to_string()],
1391            preferred_layer_media_types: vec![OCI_LAYER_JSON_MEDIA_TYPE.to_string()],
1392            ..Default::default()
1393        };
1394        let client = AuthRegistryClient {
1395            inner: Client::new(ClientConfig {
1396                protocol: ClientProtocol::Https,
1397                ..Default::default()
1398            }),
1399            token: token.to_string(),
1400        };
1401        let fetcher = OciPackFetcher::with_client(client, opts);
1402        let reference = format!("{CUSTOMERS_TOOLS_REPO}/{tenant}:latest");
1403        let resolved = match fetcher.fetch_pack_to_cache(&reference).await {
1404            Ok(resolved) => resolved,
1405            Err(err) => {
1406                let msg = err.to_string();
1407                if msg.contains("manifest unknown") {
1408                    return Err(anyhow!(
1409                        "tenant manifest not found at `{reference}`. Check that the tenant slug is correct and that the OCI artifact has been published with tag `latest`."
1410                    ));
1411                }
1412                return Err(err)
1413                    .with_context(|| format!("failed to pull tenant OCI manifest `{reference}`"));
1414            }
1415        };
1416        fs::read(&resolved.path).with_context(|| {
1417            format!(
1418                "failed to read cached OCI manifest {}",
1419                resolved.path.display()
1420            )
1421        })
1422    }
1423}
1424
1425fn github_latest_release_api_url() -> String {
1426    format!(
1427        "https://api.github.com/repos/{CUSTOMERS_TOOLS_GITHUB_OWNER}/{CUSTOMERS_TOOLS_GITHUB_REPO}/releases/tags/{CUSTOMERS_TOOLS_GITHUB_RELEASE_TAG}"
1428    )
1429}
1430
1431fn tenant_manifest_asset_name(tenant: &str) -> String {
1432    format!("{tenant}.json")
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437    use super::*;
1438    use anyhow::Result;
1439    use std::collections::HashMap;
1440    use tempfile::TempDir;
1441
1442    struct FakeTenantManifestSource {
1443        manifest: Vec<u8>,
1444    }
1445
1446    #[async_trait]
1447    impl TenantManifestSource for FakeTenantManifestSource {
1448        async fn fetch_manifest(&self, _tenant: &str, _token: &str) -> Result<Vec<u8>> {
1449            Ok(self.manifest.clone())
1450        }
1451    }
1452
1453    struct FakeDownloader {
1454        responses: HashMap<String, Vec<u8>>,
1455    }
1456
1457    #[async_trait]
1458    impl Downloader for FakeDownloader {
1459        async fn download(&self, url: &str, token: &str) -> Result<Vec<u8>> {
1460            assert_eq!(token, "secret-token");
1461            self.responses
1462                .get(url)
1463                .cloned()
1464                .ok_or_else(|| anyhow!("unexpected URL {url}"))
1465        }
1466    }
1467
1468    fn test_env(temp: &TempDir) -> Result<InstallEnv> {
1469        Ok(InstallEnv {
1470            install_root: temp.path().join("install"),
1471            bin_dir: temp.path().join("bin"),
1472            docs_dir: temp.path().join("docs"),
1473            downloads_dir: temp.path().join("downloads"),
1474            manifests_dir: temp.path().join("manifests"),
1475            state_path: temp.path().join("install/state.json"),
1476            platform: Platform {
1477                os: "linux".to_string(),
1478                arch: "x86_64".to_string(),
1479            },
1480            locale: "en-US".to_string(),
1481        })
1482    }
1483
1484    fn expanded_manifest(tool_url: &str, doc_url: &str, tar_sha: &str, doc_path: &str) -> Vec<u8> {
1485        serde_json::to_vec(&TenantInstallManifest {
1486            schema: Some("https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tenant-tools.schema.json".to_string()),
1487            schema_version: "1".to_string(),
1488            tenant: "acme".to_string(),
1489            tools: vec![TenantToolDescriptor::Expanded(TenantToolEntry {
1490                schema: Some(
1491                    "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tool.schema.json".to_string(),
1492                ),
1493                id: "greentic-x-cli".to_string(),
1494                name: "Greentic X CLI".to_string(),
1495                description: Some("CLI".to_string()),
1496                install: ToolInstall {
1497                    install_type: "release-binary".to_string(),
1498                    binary_name: "greentic-x".to_string(),
1499                    targets: vec![ReleaseTarget {
1500                        os: "linux".to_string(),
1501                        arch: "x86_64".to_string(),
1502                        url: tool_url.to_string(),
1503                        sha256: Some(tar_sha.to_string()),
1504                    }],
1505                },
1506                docs: vec!["acme-onboarding".to_string()],
1507                i18n: std::collections::BTreeMap::new(),
1508            })],
1509            docs: vec![TenantDocDescriptor::Expanded(TenantDocEntry {
1510                schema: Some(
1511                    "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/doc.schema.json".to_string(),
1512                ),
1513                id: "acme-onboarding".to_string(),
1514                title: "Acme onboarding".to_string(),
1515                source: DocSource {
1516                    source_type: "download".to_string(),
1517                    url: doc_url.to_string(),
1518                },
1519                download_file_name: "onboarding.md".to_string(),
1520                default_relative_path: doc_path.to_string(),
1521                i18n: std::collections::BTreeMap::new(),
1522            })],
1523        })
1524        .unwrap()
1525    }
1526
1527    fn referenced_manifest(tool_manifest_url: &str, doc_manifest_url: &str) -> Vec<u8> {
1528        serde_json::to_vec(&TenantInstallManifest {
1529            schema: Some("https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tenant-tools.schema.json".to_string()),
1530            schema_version: "1".to_string(),
1531            tenant: "acme".to_string(),
1532            tools: vec![TenantToolDescriptor::Ref(RemoteManifestRef {
1533                id: "greentic-x-cli".to_string(),
1534                url: tool_manifest_url.to_string(),
1535            })],
1536            docs: vec![TenantDocDescriptor::Ref(RemoteManifestRef {
1537                id: "acme-onboarding".to_string(),
1538                url: doc_manifest_url.to_string(),
1539            })],
1540        })
1541        .unwrap()
1542    }
1543
1544    fn tar_gz_with_binary(name: &str, contents: &[u8]) -> Vec<u8> {
1545        let mut tar_buf = Vec::new();
1546        {
1547            let mut builder = tar::Builder::new(&mut tar_buf);
1548            let mut header = tar::Header::new_gnu();
1549            header.set_mode(0o755);
1550            header.set_size(contents.len() as u64);
1551            header.set_cksum();
1552            builder
1553                .append_data(&mut header, name, Cursor::new(contents))
1554                .unwrap();
1555            builder.finish().unwrap();
1556        }
1557        let mut out = Vec::new();
1558        {
1559            let mut encoder =
1560                flate2::write::GzEncoder::new(&mut out, flate2::Compression::default());
1561            std::io::copy(&mut Cursor::new(tar_buf), &mut encoder).unwrap();
1562            encoder.finish().unwrap();
1563        }
1564        out
1565    }
1566
1567    #[test]
1568    fn selects_matching_target() -> Result<()> {
1569        let platform = Platform {
1570            os: "linux".to_string(),
1571            arch: "x86_64".to_string(),
1572        };
1573        let targets = vec![
1574            ReleaseTarget {
1575                os: "windows".to_string(),
1576                arch: "x86_64".to_string(),
1577                url: "https://github.com/x.zip".to_string(),
1578                sha256: Some("a".repeat(64)),
1579            },
1580            ReleaseTarget {
1581                os: "linux".to_string(),
1582                arch: "x86_64".to_string(),
1583                url: "https://github.com/y.tar.gz".to_string(),
1584                sha256: Some("b".repeat(64)),
1585            },
1586        ];
1587        let selected = select_release_target(&targets, &platform)?;
1588        assert_eq!(selected.url, "https://github.com/y.tar.gz");
1589        Ok(())
1590    }
1591
1592    #[test]
1593    fn checksum_verification_reports_failure() {
1594        let err = verify_sha256(b"abc", &"0".repeat(64)).unwrap_err();
1595        assert!(format!("{err}").contains("sha256 mismatch"));
1596    }
1597
1598    #[test]
1599    fn resolve_token_prompts_when_missing_in_interactive_mode() -> Result<()> {
1600        let token = resolve_token_with(None, true, || Ok("secret-token".to_string()), "en")?;
1601        assert_eq!(token, "secret-token");
1602        Ok(())
1603    }
1604
1605    #[test]
1606    fn resolve_token_errors_when_missing_in_non_interactive_mode() {
1607        let err = resolve_token_with(None, false, || Ok("unused".to_string()), "en").unwrap_err();
1608        assert!(format!("{err}").contains("no interactive terminal"));
1609    }
1610
1611    #[test]
1612    fn tenant_manifest_asset_name_uses_tenant_json() {
1613        assert_eq!(tenant_manifest_asset_name("3point"), "3point.json");
1614        assert_eq!(
1615            github_latest_release_api_url(),
1616            "https://api.github.com/repos/greentic-biz/customers-tools/releases/tags/latest"
1617        );
1618    }
1619
1620    #[test]
1621    fn parses_github_latest_release_download_url() {
1622        let spec = parse_github_release_url(
1623            "https://github.com/greentic-biz/greentic-mcp-generator/releases/latest/download/greentic-mcp-generator.json",
1624        )
1625        .unwrap();
1626        assert_eq!(spec.owner, "greentic-biz");
1627        assert_eq!(spec.repo, "greentic-mcp-generator");
1628        assert_eq!(spec.tag, "latest");
1629        assert_eq!(spec.asset_name, "greentic-mcp-generator.json");
1630    }
1631
1632    #[test]
1633    fn parses_github_tagged_release_download_url() {
1634        let spec = parse_github_release_url(
1635            "https://github.com/greentic-biz/greentic-mcp-generator/releases/download/v1.0.0/greentic-mcp-generator.json",
1636        )
1637        .unwrap();
1638        assert_eq!(spec.owner, "greentic-biz");
1639        assert_eq!(spec.repo, "greentic-mcp-generator");
1640        assert_eq!(spec.tag, "v1.0.0");
1641        assert_eq!(spec.asset_name, "greentic-mcp-generator.json");
1642    }
1643
1644    #[test]
1645    fn extracts_tar_gz_binary() -> Result<()> {
1646        let temp = TempDir::new()?;
1647        let archive = tar_gz_with_binary("greentic-x", b"hello");
1648        let out = extract_tar_gz_binary(&archive, "greentic-x", temp.path())?;
1649        assert_eq!(out, temp.path().join("greentic-x"));
1650        assert_eq!(fs::read(&out)?, b"hello");
1651        Ok(())
1652    }
1653
1654    #[test]
1655    fn tenant_install_happy_path_writes_binary_doc_manifest_and_state() -> Result<()> {
1656        let temp = TempDir::new()?;
1657        let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1658        let sha = sha256_hex(&tool_archive);
1659        let tool_url =
1660            "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1661        let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1662        let manifest = expanded_manifest(tool_url, doc_url, &sha, "acme/onboarding/README.md");
1663
1664        let installer = Installer::new(
1665            FakeTenantManifestSource { manifest },
1666            FakeDownloader {
1667                responses: HashMap::from([
1668                    (tool_url.to_string(), tool_archive.clone()),
1669                    (doc_url.to_string(), b"# onboarding\n".to_vec()),
1670                ]),
1671            },
1672            test_env(&temp)?,
1673        );
1674        installer.install_tenant("acme", "secret-token")?;
1675
1676        assert_eq!(fs::read(temp.path().join("bin/greentic-x"))?, b"bin");
1677        assert_eq!(
1678            fs::read_to_string(temp.path().join("docs/acme/onboarding/README.md"))?,
1679            "# onboarding\n"
1680        );
1681        assert!(temp.path().join("manifests/tenant-acme.json").exists());
1682        assert!(temp.path().join("install/state.json").exists());
1683        Ok(())
1684    }
1685
1686    #[test]
1687    fn install_rejects_path_traversal_in_docs() -> Result<()> {
1688        let temp = TempDir::new()?;
1689        let archive = tar_gz_with_binary("greentic-x", b"bin");
1690        let sha = sha256_hex(&archive);
1691        let tool_url =
1692            "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1693        let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1694        let manifest = expanded_manifest(tool_url, doc_url, &sha, "../escape.md");
1695        let installer = Installer::new(
1696            FakeTenantManifestSource { manifest },
1697            FakeDownloader {
1698                responses: HashMap::from([
1699                    (tool_url.to_string(), archive),
1700                    (doc_url.to_string(), b"# onboarding\n".to_vec()),
1701                ]),
1702            },
1703            test_env(&temp)?,
1704        );
1705        let err = installer
1706            .install_tenant("acme", "secret-token")
1707            .unwrap_err();
1708        assert!(format!("{err}").contains("docs directory"));
1709        Ok(())
1710    }
1711
1712    #[test]
1713    fn archive_name_matching_handles_versioned_binaries() {
1714        assert!(archive_name_matches("greentic-x", "greentic-x"));
1715        assert!(archive_name_matches("greentic-x", "greentic-x-v1.2.3"));
1716        assert!(archive_name_matches("greentic-x.exe", "greentic-x.exe"));
1717        assert!(!archive_name_matches("greentic-x", "other-tool"));
1718    }
1719
1720    #[test]
1721    fn safe_archive_relative_path_rejects_escaping_paths() {
1722        assert_eq!(
1723            safe_archive_relative_path(Path::new("bin/greentic-x")),
1724            Some(PathBuf::from("bin/greentic-x"))
1725        );
1726        assert!(safe_archive_relative_path(Path::new("../escape")).is_none());
1727        assert!(safe_archive_relative_path(Path::new("/absolute")).is_none());
1728    }
1729
1730    #[test]
1731    fn github_url_enforcement_allows_github_and_localhost_only() {
1732        enforce_github_url("https://github.com/acme/project/releases/download/v1/tool.tgz")
1733            .unwrap();
1734        enforce_github_url("http://localhost:8080/test").unwrap();
1735
1736        let err = enforce_github_url("https://example.com/tool.tgz").unwrap_err();
1737        assert!(format!("{err}").contains("GitHub-hosted assets"));
1738    }
1739
1740    #[test]
1741    fn tenant_install_resolves_tool_and_doc_manifests_by_url() -> Result<()> {
1742        let temp = TempDir::new()?;
1743        let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1744        let sha = sha256_hex(&tool_archive);
1745        let tool_url =
1746            "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1747        let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1748        let tool_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/tools/greentic-x-cli/manifest.json";
1749        let doc_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/docs/acme-onboarding.json";
1750        let tenant_manifest = referenced_manifest(tool_manifest_url, doc_manifest_url);
1751        let tool_manifest = serde_json::to_vec(&TenantToolEntry {
1752            schema: Some(
1753                "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tool.schema.json".to_string(),
1754            ),
1755            id: "greentic-x-cli".to_string(),
1756            name: "Greentic X CLI".to_string(),
1757            description: Some("CLI".to_string()),
1758            install: ToolInstall {
1759                install_type: "release-binary".to_string(),
1760                binary_name: "greentic-x".to_string(),
1761                targets: vec![ReleaseTarget {
1762                    os: "linux".to_string(),
1763                    arch: "x86_64".to_string(),
1764                    url: tool_url.to_string(),
1765                    sha256: Some(sha.clone()),
1766                }],
1767            },
1768            docs: vec!["acme-onboarding".to_string()],
1769            i18n: std::collections::BTreeMap::new(),
1770        })?;
1771        let doc_manifest = serde_json::to_vec(&TenantDocEntry {
1772            schema: Some(
1773                "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/doc.schema.json".to_string(),
1774            ),
1775            id: "acme-onboarding".to_string(),
1776            title: "Acme onboarding".to_string(),
1777            source: DocSource {
1778                source_type: "download".to_string(),
1779                url: doc_url.to_string(),
1780            },
1781            download_file_name: "onboarding.md".to_string(),
1782            default_relative_path: "acme/onboarding/README.md".to_string(),
1783            i18n: std::collections::BTreeMap::new(),
1784        })?;
1785
1786        let installer = Installer::new(
1787            FakeTenantManifestSource {
1788                manifest: tenant_manifest,
1789            },
1790            FakeDownloader {
1791                responses: HashMap::from([
1792                    (tool_manifest_url.to_string(), tool_manifest),
1793                    (doc_manifest_url.to_string(), doc_manifest),
1794                    (tool_url.to_string(), tool_archive),
1795                    (doc_url.to_string(), b"# onboarding\n".to_vec()),
1796                ]),
1797            },
1798            test_env(&temp)?,
1799        );
1800        installer.install_tenant("acme", "secret-token")?;
1801        assert_eq!(fs::read(temp.path().join("bin/greentic-x"))?, b"bin");
1802        assert_eq!(
1803            fs::read_to_string(temp.path().join("docs/acme/onboarding/README.md"))?,
1804            "# onboarding\n"
1805        );
1806        Ok(())
1807    }
1808
1809    #[test]
1810    fn tenant_install_supports_referenced_multi_doc_manifest() -> Result<()> {
1811        let temp = TempDir::new()?;
1812        let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1813        let sha = sha256_hex(&tool_archive);
1814        let tool_url =
1815            "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1816        let doc_a_url = "https://raw.githubusercontent.com/acme/docs/main/a.md";
1817        let doc_b_url = "https://raw.githubusercontent.com/acme/docs/main/b.md";
1818        let tool_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/tools/greentic-x-cli/manifest.json";
1819        let doc_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/docs/acme-onboarding.json";
1820        let tenant_manifest = referenced_manifest(tool_manifest_url, doc_manifest_url);
1821        let tool_manifest = serde_json::to_vec(&TenantToolEntry {
1822            schema: Some(
1823                "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tool.schema.json".to_string(),
1824            ),
1825            id: "greentic-x-cli".to_string(),
1826            name: "Greentic X CLI".to_string(),
1827            description: Some("CLI".to_string()),
1828            install: ToolInstall {
1829                install_type: "release-binary".to_string(),
1830                binary_name: "greentic-x".to_string(),
1831                targets: vec![ReleaseTarget {
1832                    os: "linux".to_string(),
1833                    arch: "x86_64".to_string(),
1834                    url: tool_url.to_string(),
1835                    sha256: Some(sha),
1836                }],
1837            },
1838            docs: vec!["acme-onboarding".to_string()],
1839            i18n: std::collections::BTreeMap::new(),
1840        })?;
1841        let doc_manifest = serde_json::json!({
1842            "schema_version": "1",
1843            "id": "acme-onboarding",
1844            "docs": [
1845                {
1846                    "title": "A",
1847                    "source": {
1848                        "type": "download",
1849                        "url": doc_a_url
1850                    },
1851                    "download_file_name": "a.md",
1852                    "default_relative_path": "docs/a.md"
1853                },
1854                {
1855                    "title": "B",
1856                    "source": {
1857                        "type": "download",
1858                        "url": doc_b_url
1859                    },
1860                    "download_file_name": "b.md",
1861                    "default_relative_path": "docs/b.md"
1862                }
1863            ]
1864        });
1865
1866        let installer = Installer::new(
1867            FakeTenantManifestSource {
1868                manifest: tenant_manifest,
1869            },
1870            FakeDownloader {
1871                responses: HashMap::from([
1872                    (tool_manifest_url.to_string(), tool_manifest),
1873                    (
1874                        doc_manifest_url.to_string(),
1875                        serde_json::to_vec(&doc_manifest)?,
1876                    ),
1877                    (tool_url.to_string(), tool_archive),
1878                    (doc_a_url.to_string(), b"# A\n".to_vec()),
1879                    (doc_b_url.to_string(), b"# B\n".to_vec()),
1880                ]),
1881            },
1882            test_env(&temp)?,
1883        );
1884        installer.install_tenant("acme", "secret-token")?;
1885        assert_eq!(
1886            fs::read_to_string(temp.path().join("docs/docs/a.md"))?,
1887            "# A\n"
1888        );
1889        assert_eq!(
1890            fs::read_to_string(temp.path().join("docs/docs/b.md"))?,
1891            "# B\n"
1892        );
1893        Ok(())
1894    }
1895
1896    #[test]
1897    fn locale_uses_language_specific_doc_translation() -> Result<()> {
1898        let temp = TempDir::new()?;
1899        let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1900        let sha = sha256_hex(&tool_archive);
1901        let en_doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1902        let nl_doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.nl.md";
1903        let manifest = serde_json::to_vec(&TenantInstallManifest {
1904            schema: None,
1905            schema_version: "1".to_string(),
1906            tenant: "acme".to_string(),
1907            tools: vec![TenantToolDescriptor::Expanded(TenantToolEntry {
1908                schema: None,
1909                id: "greentic-x-cli".to_string(),
1910                name: "Greentic X CLI".to_string(),
1911                description: None,
1912                install: ToolInstall {
1913                    install_type: "release-binary".to_string(),
1914                    binary_name: "greentic-x".to_string(),
1915                    targets: vec![ReleaseTarget {
1916                        os: "linux".to_string(),
1917                        arch: "x86_64".to_string(),
1918                        url: "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz".to_string(),
1919                        sha256: Some(sha),
1920                    }],
1921                },
1922                docs: vec!["acme-onboarding".to_string()],
1923                i18n: std::collections::BTreeMap::new(),
1924            })],
1925            docs: vec![TenantDocDescriptor::Expanded(TenantDocEntry {
1926                schema: None,
1927                id: "acme-onboarding".to_string(),
1928                title: "Acme onboarding".to_string(),
1929                source: DocSource {
1930                    source_type: "download".to_string(),
1931                    url: en_doc_url.to_string(),
1932                },
1933                download_file_name: "onboarding.md".to_string(),
1934                default_relative_path: "acme/onboarding/README.md".to_string(),
1935                i18n: std::collections::BTreeMap::from([(
1936                    "nl".to_string(),
1937                    DocTranslation {
1938                        title: Some("Acme onboarding NL".to_string()),
1939                        download_file_name: Some("onboarding.nl.md".to_string()),
1940                        default_relative_path: Some("acme/onboarding/README.nl.md".to_string()),
1941                        source: Some(DocSource {
1942                            source_type: "download".to_string(),
1943                            url: nl_doc_url.to_string(),
1944                        }),
1945                    },
1946                )]),
1947            })],
1948        })?;
1949        let mut env = test_env(&temp)?;
1950        env.locale = "nl".to_string();
1951        let installer = Installer::new(
1952            FakeTenantManifestSource { manifest },
1953            FakeDownloader {
1954                responses: HashMap::from([
1955                    (
1956                        "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz".to_string(),
1957                        tool_archive,
1958                    ),
1959                    (en_doc_url.to_string(), b"# onboarding en\n".to_vec()),
1960                    (nl_doc_url.to_string(), b"# onboarding nl\n".to_vec()),
1961                ]),
1962            },
1963            env,
1964        );
1965        installer.install_tenant("acme", "secret-token")?;
1966        assert_eq!(
1967            fs::read_to_string(temp.path().join("docs/acme/onboarding/README.nl.md"))?,
1968            "# onboarding nl\n"
1969        );
1970        Ok(())
1971    }
1972
1973    #[test]
1974    fn tenant_install_accepts_simple_manifest_shape() -> Result<()> {
1975        let temp = TempDir::new()?;
1976        let tool_archive = tar_gz_with_binary("greentic-fast2flow", b"bin");
1977        let tool_url = "https://github.com/greentic-biz/greentic-fast2flow/releases/download/v0.4.1/greentic-fast2flow-v0.4.1-x86_64-unknown-linux-gnu.tar.gz";
1978        let doc_url =
1979            "https://raw.githubusercontent.com/greentic-biz/greentic-fast2flow/master/README.md";
1980        let manifest = serde_json::to_vec(&TenantInstallManifest {
1981            schema: None,
1982            schema_version: "1".to_string(),
1983            tenant: "3point".to_string(),
1984            tools: vec![TenantToolDescriptor::Simple(SimpleTenantToolEntry {
1985                id: "greentic-fast2flow".to_string(),
1986                binary_name: None,
1987                targets: vec![ReleaseTarget {
1988                    os: "linux".to_string(),
1989                    arch: "x86_64".to_string(),
1990                    url: tool_url.to_string(),
1991                    sha256: None,
1992                }],
1993            })],
1994            docs: vec![TenantDocDescriptor::Simple(SimpleTenantDocEntry {
1995                url: doc_url.to_string(),
1996                file_name: "greentic-fast2flow-guide.md".to_string(),
1997            })],
1998        })?;
1999        let installer = Installer::new(
2000            FakeTenantManifestSource { manifest },
2001            FakeDownloader {
2002                responses: HashMap::from([
2003                    (tool_url.to_string(), tool_archive),
2004                    (doc_url.to_string(), b"# fast2flow\n".to_vec()),
2005                ]),
2006            },
2007            test_env(&temp)?,
2008        );
2009        installer.install_tenant("3point", "secret-token")?;
2010        assert_eq!(
2011            fs::read(temp.path().join("bin/greentic-fast2flow"))?,
2012            b"bin"
2013        );
2014        assert_eq!(
2015            fs::read_to_string(temp.path().join("docs/greentic-fast2flow-guide.md"))?,
2016            "# fast2flow\n"
2017        );
2018        Ok(())
2019    }
2020
2021    #[test]
2022    fn expected_binary_name_strips_release_target_and_version() {
2023        let name = expected_binary_name(
2024            "greentic-fast2flow",
2025            "https://github.com/greentic-biz/greentic-fast2flow/releases/download/v0.4.1/greentic-fast2flow-v0.4.1-x86_64-unknown-linux-gnu.tar.gz",
2026        );
2027        assert_eq!(name, "greentic-fast2flow");
2028    }
2029
2030    #[test]
2031    fn extracts_tar_gz_binary_with_versioned_entry_name() -> Result<()> {
2032        let temp = TempDir::new()?;
2033        let archive = tar_gz_with_binary(
2034            "greentic-mcp-generator-0.4.14-x86_64-unknown-linux-gnu",
2035            b"bin",
2036        );
2037        let out = extract_tar_gz_binary(&archive, "greentic-mcp-generator", temp.path())?;
2038        assert_eq!(
2039            out,
2040            temp.path()
2041                .join("greentic-mcp-generator-0.4.14-x86_64-unknown-linux-gnu")
2042        );
2043        assert_eq!(fs::read(out)?, b"bin");
2044        Ok(())
2045    }
2046
2047    #[test]
2048    fn extracts_tar_gz_binary_even_when_archive_name_differs() -> Result<()> {
2049        let temp = TempDir::new()?;
2050        let archive = tar_gz_with_binary("greentic-mcp-gen", b"bin");
2051        let out = extract_tar_gz_binary(&archive, "greentic-mcp-generator", temp.path())?;
2052        assert_eq!(out, temp.path().join("greentic-mcp-gen"));
2053        assert_eq!(fs::read(out)?, b"bin");
2054        Ok(())
2055    }
2056}