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