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