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 = format!(
1098            "https://api.github.com/repos/{}/{}/releases/tags/{}",
1099            spec.owner, spec.repo, spec.tag
1100        );
1101        let release = self
1102            .client
1103            .get(api_url)
1104            .bearer_auth(token)
1105            .header(reqwest::header::ACCEPT, "application/vnd.github+json")
1106            .send()
1107            .await
1108            .with_context(|| format!("failed to resolve GitHub release for `{url}`"))?
1109            .error_for_status()
1110            .with_context(|| format!("failed to resolve GitHub release for `{url}`"))?
1111            .json::<GithubRelease>()
1112            .await
1113            .with_context(|| format!("failed to parse GitHub release metadata for `{url}`"))?;
1114        let Some(asset) = release
1115            .assets
1116            .into_iter()
1117            .find(|asset| asset.name == spec.asset_name)
1118        else {
1119            bail!(
1120                "download failed for `{url}`: release asset `{}` not found on tag `{}`",
1121                spec.asset_name,
1122                spec.tag
1123            );
1124        };
1125        Ok(Some(asset.url))
1126    }
1127}
1128
1129struct GithubReleaseUrlSpec {
1130    owner: String,
1131    repo: String,
1132    tag: String,
1133    asset_name: String,
1134}
1135
1136fn parse_github_release_url(url: &str) -> Option<GithubReleaseUrlSpec> {
1137    let parsed = reqwest::Url::parse(url).ok()?;
1138    if parsed.host_str()? != "github.com" {
1139        return None;
1140    }
1141    let segments = parsed.path_segments()?.collect::<Vec<_>>();
1142    if segments.len() < 6 {
1143        return None;
1144    }
1145    if segments[2] != "releases" || segments[3] != "download" {
1146        return None;
1147    }
1148    Some(GithubReleaseUrlSpec {
1149        owner: segments[0].to_string(),
1150        repo: segments[1].to_string(),
1151        tag: segments[4].to_string(),
1152        asset_name: segments[5..].join("/"),
1153    })
1154}
1155
1156#[derive(Clone)]
1157struct AuthRegistryClient {
1158    inner: Client,
1159    token: String,
1160}
1161
1162#[async_trait]
1163impl RegistryClient for AuthRegistryClient {
1164    fn default_client() -> Self {
1165        let config = ClientConfig {
1166            protocol: ClientProtocol::Https,
1167            ..Default::default()
1168        };
1169        Self {
1170            inner: Client::new(config),
1171            token: String::new(),
1172        }
1173    }
1174
1175    async fn pull(
1176        &self,
1177        reference: &Reference,
1178        accepted_manifest_types: &[&str],
1179    ) -> Result<greentic_distributor_client::oci_packs::PulledImage, OciDistributionError> {
1180        let image = self
1181            .inner
1182            .pull(
1183                reference,
1184                &RegistryAuth::Basic(OAUTH_USER.to_string(), self.token.clone()),
1185                accepted_manifest_types.to_vec(),
1186            )
1187            .await?;
1188        Ok(convert_image(image))
1189    }
1190}
1191
1192fn convert_image(image: ImageData) -> greentic_distributor_client::oci_packs::PulledImage {
1193    let layers = image
1194        .layers
1195        .into_iter()
1196        .map(|layer| {
1197            let digest = format!("sha256:{}", layer.sha256_digest());
1198            greentic_distributor_client::oci_packs::PulledLayer {
1199                media_type: layer.media_type,
1200                data: layer.data,
1201                digest: Some(digest),
1202            }
1203        })
1204        .collect();
1205    greentic_distributor_client::oci_packs::PulledImage {
1206        digest: image.digest,
1207        layers,
1208    }
1209}
1210
1211#[derive(Default)]
1212struct RealTenantManifestSource;
1213
1214#[async_trait]
1215impl TenantManifestSource for RealTenantManifestSource {
1216    async fn fetch_manifest(&self, tenant: &str, token: &str) -> Result<Vec<u8>> {
1217        if let Some(bytes) = self.fetch_github_release_manifest(tenant, token).await? {
1218            return Ok(bytes);
1219        }
1220        self.fetch_oci_manifest(tenant, token).await
1221    }
1222}
1223
1224impl RealTenantManifestSource {
1225    async fn fetch_github_release_manifest(
1226        &self,
1227        tenant: &str,
1228        token: &str,
1229    ) -> Result<Option<Vec<u8>>> {
1230        let client = reqwest::Client::builder()
1231            .user_agent(format!("greentic-dev/{}", env!("CARGO_PKG_VERSION")))
1232            .build()
1233            .context("failed to build GitHub HTTP client")?;
1234        let release_url = github_latest_release_api_url();
1235        let response = client
1236            .get(&release_url)
1237            .bearer_auth(token)
1238            .header(reqwest::header::ACCEPT, "application/vnd.github+json")
1239            .send()
1240            .await
1241            .with_context(|| format!("failed to resolve GitHub release `{release_url}`"))?;
1242        if response.status() == reqwest::StatusCode::NOT_FOUND {
1243            return Ok(None);
1244        }
1245        let release = response
1246            .error_for_status()
1247            .with_context(|| format!("failed to resolve GitHub release `{release_url}`"))?
1248            .json::<GithubRelease>()
1249            .await
1250            .with_context(|| format!("failed to parse GitHub release `{release_url}`"))?;
1251        let asset_name = tenant_manifest_asset_name(tenant);
1252        let Some(asset) = release
1253            .assets
1254            .into_iter()
1255            .find(|asset| asset.name == asset_name)
1256        else {
1257            return Ok(None);
1258        };
1259        let response = client
1260            .get(&asset.url)
1261            .bearer_auth(token)
1262            .header(reqwest::header::ACCEPT, "application/octet-stream")
1263            .send()
1264            .await
1265            .with_context(|| format!("failed to download tenant manifest asset `{asset_name}`"))?
1266            .error_for_status()
1267            .with_context(|| format!("failed to download tenant manifest asset `{asset_name}`"))?;
1268        let bytes = response
1269            .bytes()
1270            .await
1271            .with_context(|| format!("failed to read tenant manifest asset `{asset_name}`"))?;
1272        Ok(Some(bytes.to_vec()))
1273    }
1274
1275    async fn fetch_oci_manifest(&self, tenant: &str, token: &str) -> Result<Vec<u8>> {
1276        let opts = PackFetchOptions {
1277            allow_tags: true,
1278            accepted_manifest_types: vec![
1279                OCI_IMAGE_MEDIA_TYPE.to_string(),
1280                IMAGE_MANIFEST_MEDIA_TYPE.to_string(),
1281            ],
1282            accepted_layer_media_types: vec![OCI_LAYER_JSON_MEDIA_TYPE.to_string()],
1283            preferred_layer_media_types: vec![OCI_LAYER_JSON_MEDIA_TYPE.to_string()],
1284            ..Default::default()
1285        };
1286        let client = AuthRegistryClient {
1287            inner: Client::new(ClientConfig {
1288                protocol: ClientProtocol::Https,
1289                ..Default::default()
1290            }),
1291            token: token.to_string(),
1292        };
1293        let fetcher = OciPackFetcher::with_client(client, opts);
1294        let reference = format!("{CUSTOMERS_TOOLS_REPO}/{tenant}:latest");
1295        let resolved = match fetcher.fetch_pack_to_cache(&reference).await {
1296            Ok(resolved) => resolved,
1297            Err(err) => {
1298                let msg = err.to_string();
1299                if msg.contains("manifest unknown") {
1300                    return Err(anyhow!(
1301                        "tenant manifest not found at `{reference}`. Check that the tenant slug is correct and that the OCI artifact has been published with tag `latest`."
1302                    ));
1303                }
1304                return Err(err)
1305                    .with_context(|| format!("failed to pull tenant OCI manifest `{reference}`"));
1306            }
1307        };
1308        fs::read(&resolved.path).with_context(|| {
1309            format!(
1310                "failed to read cached OCI manifest {}",
1311                resolved.path.display()
1312            )
1313        })
1314    }
1315}
1316
1317fn github_latest_release_api_url() -> String {
1318    format!(
1319        "https://api.github.com/repos/{CUSTOMERS_TOOLS_GITHUB_OWNER}/{CUSTOMERS_TOOLS_GITHUB_REPO}/releases/tags/{CUSTOMERS_TOOLS_GITHUB_RELEASE_TAG}"
1320    )
1321}
1322
1323fn tenant_manifest_asset_name(tenant: &str) -> String {
1324    format!("{tenant}.json")
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329    use super::*;
1330    use anyhow::Result;
1331    use std::collections::HashMap;
1332    use tempfile::TempDir;
1333
1334    struct FakeTenantManifestSource {
1335        manifest: Vec<u8>,
1336    }
1337
1338    #[async_trait]
1339    impl TenantManifestSource for FakeTenantManifestSource {
1340        async fn fetch_manifest(&self, _tenant: &str, _token: &str) -> Result<Vec<u8>> {
1341            Ok(self.manifest.clone())
1342        }
1343    }
1344
1345    struct FakeDownloader {
1346        responses: HashMap<String, Vec<u8>>,
1347    }
1348
1349    #[async_trait]
1350    impl Downloader for FakeDownloader {
1351        async fn download(&self, url: &str, token: &str) -> Result<Vec<u8>> {
1352            assert_eq!(token, "secret-token");
1353            self.responses
1354                .get(url)
1355                .cloned()
1356                .ok_or_else(|| anyhow!("unexpected URL {url}"))
1357        }
1358    }
1359
1360    fn test_env(temp: &TempDir) -> Result<InstallEnv> {
1361        Ok(InstallEnv {
1362            install_root: temp.path().join("install"),
1363            bin_dir: temp.path().join("bin"),
1364            docs_dir: temp.path().join("docs"),
1365            downloads_dir: temp.path().join("downloads"),
1366            manifests_dir: temp.path().join("manifests"),
1367            state_path: temp.path().join("install/state.json"),
1368            platform: Platform {
1369                os: "linux".to_string(),
1370                arch: "x86_64".to_string(),
1371            },
1372            locale: "en-US".to_string(),
1373        })
1374    }
1375
1376    fn expanded_manifest(tool_url: &str, doc_url: &str, tar_sha: &str, doc_path: &str) -> Vec<u8> {
1377        serde_json::to_vec(&TenantInstallManifest {
1378            schema: Some("https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tenant-tools.schema.json".to_string()),
1379            schema_version: "1".to_string(),
1380            tenant: "acme".to_string(),
1381            tools: vec![TenantToolDescriptor::Expanded(TenantToolEntry {
1382                schema: Some(
1383                    "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tool.schema.json".to_string(),
1384                ),
1385                id: "greentic-x-cli".to_string(),
1386                name: "Greentic X CLI".to_string(),
1387                description: Some("CLI".to_string()),
1388                install: ToolInstall {
1389                    install_type: "release-binary".to_string(),
1390                    binary_name: "greentic-x".to_string(),
1391                    targets: vec![ReleaseTarget {
1392                        os: "linux".to_string(),
1393                        arch: "x86_64".to_string(),
1394                        url: tool_url.to_string(),
1395                        sha256: Some(tar_sha.to_string()),
1396                    }],
1397                },
1398                docs: vec!["acme-onboarding".to_string()],
1399                i18n: std::collections::BTreeMap::new(),
1400            })],
1401            docs: vec![TenantDocDescriptor::Expanded(TenantDocEntry {
1402                schema: Some(
1403                    "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/doc.schema.json".to_string(),
1404                ),
1405                id: "acme-onboarding".to_string(),
1406                title: "Acme onboarding".to_string(),
1407                source: DocSource {
1408                    source_type: "download".to_string(),
1409                    url: doc_url.to_string(),
1410                },
1411                download_file_name: "onboarding.md".to_string(),
1412                default_relative_path: doc_path.to_string(),
1413                i18n: std::collections::BTreeMap::new(),
1414            })],
1415        })
1416        .unwrap()
1417    }
1418
1419    fn referenced_manifest(tool_manifest_url: &str, doc_manifest_url: &str) -> Vec<u8> {
1420        serde_json::to_vec(&TenantInstallManifest {
1421            schema: Some("https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tenant-tools.schema.json".to_string()),
1422            schema_version: "1".to_string(),
1423            tenant: "acme".to_string(),
1424            tools: vec![TenantToolDescriptor::Ref(RemoteManifestRef {
1425                id: "greentic-x-cli".to_string(),
1426                url: tool_manifest_url.to_string(),
1427            })],
1428            docs: vec![TenantDocDescriptor::Ref(RemoteManifestRef {
1429                id: "acme-onboarding".to_string(),
1430                url: doc_manifest_url.to_string(),
1431            })],
1432        })
1433        .unwrap()
1434    }
1435
1436    fn tar_gz_with_binary(name: &str, contents: &[u8]) -> Vec<u8> {
1437        let mut tar_buf = Vec::new();
1438        {
1439            let mut builder = tar::Builder::new(&mut tar_buf);
1440            let mut header = tar::Header::new_gnu();
1441            header.set_mode(0o755);
1442            header.set_size(contents.len() as u64);
1443            header.set_cksum();
1444            builder
1445                .append_data(&mut header, name, Cursor::new(contents))
1446                .unwrap();
1447            builder.finish().unwrap();
1448        }
1449        let mut out = Vec::new();
1450        {
1451            let mut encoder =
1452                flate2::write::GzEncoder::new(&mut out, flate2::Compression::default());
1453            std::io::copy(&mut Cursor::new(tar_buf), &mut encoder).unwrap();
1454            encoder.finish().unwrap();
1455        }
1456        out
1457    }
1458
1459    #[test]
1460    fn selects_matching_target() -> Result<()> {
1461        let platform = Platform {
1462            os: "linux".to_string(),
1463            arch: "x86_64".to_string(),
1464        };
1465        let targets = vec![
1466            ReleaseTarget {
1467                os: "windows".to_string(),
1468                arch: "x86_64".to_string(),
1469                url: "https://github.com/x.zip".to_string(),
1470                sha256: Some("a".repeat(64)),
1471            },
1472            ReleaseTarget {
1473                os: "linux".to_string(),
1474                arch: "x86_64".to_string(),
1475                url: "https://github.com/y.tar.gz".to_string(),
1476                sha256: Some("b".repeat(64)),
1477            },
1478        ];
1479        let selected = select_release_target(&targets, &platform)?;
1480        assert_eq!(selected.url, "https://github.com/y.tar.gz");
1481        Ok(())
1482    }
1483
1484    #[test]
1485    fn checksum_verification_reports_failure() {
1486        let err = verify_sha256(b"abc", &"0".repeat(64)).unwrap_err();
1487        assert!(format!("{err}").contains("sha256 mismatch"));
1488    }
1489
1490    #[test]
1491    fn resolve_token_prompts_when_missing_in_interactive_mode() -> Result<()> {
1492        let token = resolve_token_with(None, true, || Ok("secret-token".to_string()), "en")?;
1493        assert_eq!(token, "secret-token");
1494        Ok(())
1495    }
1496
1497    #[test]
1498    fn resolve_token_errors_when_missing_in_non_interactive_mode() {
1499        let err = resolve_token_with(None, false, || Ok("unused".to_string()), "en").unwrap_err();
1500        assert!(format!("{err}").contains("no interactive terminal"));
1501    }
1502
1503    #[test]
1504    fn tenant_manifest_asset_name_uses_tenant_json() {
1505        assert_eq!(tenant_manifest_asset_name("3point"), "3point.json");
1506        assert_eq!(
1507            github_latest_release_api_url(),
1508            "https://api.github.com/repos/greentic-biz/customers-tools/releases/tags/latest"
1509        );
1510    }
1511
1512    #[test]
1513    fn extracts_tar_gz_binary() -> Result<()> {
1514        let temp = TempDir::new()?;
1515        let archive = tar_gz_with_binary("greentic-x", b"hello");
1516        let out = extract_tar_gz_binary(&archive, "greentic-x", temp.path())?;
1517        assert_eq!(out, temp.path().join("greentic-x"));
1518        assert_eq!(fs::read(&out)?, b"hello");
1519        Ok(())
1520    }
1521
1522    #[test]
1523    fn tenant_install_happy_path_writes_binary_doc_manifest_and_state() -> Result<()> {
1524        let temp = TempDir::new()?;
1525        let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1526        let sha = sha256_hex(&tool_archive);
1527        let tool_url =
1528            "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1529        let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1530        let manifest = expanded_manifest(tool_url, doc_url, &sha, "acme/onboarding/README.md");
1531
1532        let installer = Installer::new(
1533            FakeTenantManifestSource { manifest },
1534            FakeDownloader {
1535                responses: HashMap::from([
1536                    (tool_url.to_string(), tool_archive.clone()),
1537                    (doc_url.to_string(), b"# onboarding\n".to_vec()),
1538                ]),
1539            },
1540            test_env(&temp)?,
1541        );
1542        installer.install_tenant("acme", "secret-token")?;
1543
1544        assert_eq!(fs::read(temp.path().join("bin/greentic-x"))?, b"bin");
1545        assert_eq!(
1546            fs::read_to_string(temp.path().join("docs/acme/onboarding/README.md"))?,
1547            "# onboarding\n"
1548        );
1549        assert!(temp.path().join("manifests/tenant-acme.json").exists());
1550        assert!(temp.path().join("install/state.json").exists());
1551        Ok(())
1552    }
1553
1554    #[test]
1555    fn install_rejects_path_traversal_in_docs() -> Result<()> {
1556        let temp = TempDir::new()?;
1557        let archive = tar_gz_with_binary("greentic-x", b"bin");
1558        let sha = sha256_hex(&archive);
1559        let tool_url =
1560            "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1561        let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1562        let manifest = expanded_manifest(tool_url, doc_url, &sha, "../escape.md");
1563        let installer = Installer::new(
1564            FakeTenantManifestSource { manifest },
1565            FakeDownloader {
1566                responses: HashMap::from([
1567                    (tool_url.to_string(), archive),
1568                    (doc_url.to_string(), b"# onboarding\n".to_vec()),
1569                ]),
1570            },
1571            test_env(&temp)?,
1572        );
1573        let err = installer
1574            .install_tenant("acme", "secret-token")
1575            .unwrap_err();
1576        assert!(format!("{err}").contains("docs directory"));
1577        Ok(())
1578    }
1579
1580    #[test]
1581    fn archive_name_matching_handles_versioned_binaries() {
1582        assert!(archive_name_matches("greentic-x", "greentic-x"));
1583        assert!(archive_name_matches("greentic-x", "greentic-x-v1.2.3"));
1584        assert!(archive_name_matches("greentic-x.exe", "greentic-x.exe"));
1585        assert!(!archive_name_matches("greentic-x", "other-tool"));
1586    }
1587
1588    #[test]
1589    fn safe_archive_relative_path_rejects_escaping_paths() {
1590        assert_eq!(
1591            safe_archive_relative_path(Path::new("bin/greentic-x")),
1592            Some(PathBuf::from("bin/greentic-x"))
1593        );
1594        assert!(safe_archive_relative_path(Path::new("../escape")).is_none());
1595        assert!(safe_archive_relative_path(Path::new("/absolute")).is_none());
1596    }
1597
1598    #[test]
1599    fn github_url_enforcement_allows_github_and_localhost_only() {
1600        enforce_github_url("https://github.com/acme/project/releases/download/v1/tool.tgz")
1601            .unwrap();
1602        enforce_github_url("http://localhost:8080/test").unwrap();
1603
1604        let err = enforce_github_url("https://example.com/tool.tgz").unwrap_err();
1605        assert!(format!("{err}").contains("GitHub-hosted assets"));
1606    }
1607
1608    #[test]
1609    fn tenant_install_resolves_tool_and_doc_manifests_by_url() -> Result<()> {
1610        let temp = TempDir::new()?;
1611        let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1612        let sha = sha256_hex(&tool_archive);
1613        let tool_url =
1614            "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1615        let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1616        let tool_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/tools/greentic-x-cli/manifest.json";
1617        let doc_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/docs/acme-onboarding.json";
1618        let tenant_manifest = referenced_manifest(tool_manifest_url, doc_manifest_url);
1619        let tool_manifest = serde_json::to_vec(&TenantToolEntry {
1620            schema: Some(
1621                "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tool.schema.json".to_string(),
1622            ),
1623            id: "greentic-x-cli".to_string(),
1624            name: "Greentic X CLI".to_string(),
1625            description: Some("CLI".to_string()),
1626            install: ToolInstall {
1627                install_type: "release-binary".to_string(),
1628                binary_name: "greentic-x".to_string(),
1629                targets: vec![ReleaseTarget {
1630                    os: "linux".to_string(),
1631                    arch: "x86_64".to_string(),
1632                    url: tool_url.to_string(),
1633                    sha256: Some(sha.clone()),
1634                }],
1635            },
1636            docs: vec!["acme-onboarding".to_string()],
1637            i18n: std::collections::BTreeMap::new(),
1638        })?;
1639        let doc_manifest = serde_json::to_vec(&TenantDocEntry {
1640            schema: Some(
1641                "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/doc.schema.json".to_string(),
1642            ),
1643            id: "acme-onboarding".to_string(),
1644            title: "Acme onboarding".to_string(),
1645            source: DocSource {
1646                source_type: "download".to_string(),
1647                url: doc_url.to_string(),
1648            },
1649            download_file_name: "onboarding.md".to_string(),
1650            default_relative_path: "acme/onboarding/README.md".to_string(),
1651            i18n: std::collections::BTreeMap::new(),
1652        })?;
1653
1654        let installer = Installer::new(
1655            FakeTenantManifestSource {
1656                manifest: tenant_manifest,
1657            },
1658            FakeDownloader {
1659                responses: HashMap::from([
1660                    (tool_manifest_url.to_string(), tool_manifest),
1661                    (doc_manifest_url.to_string(), doc_manifest),
1662                    (tool_url.to_string(), tool_archive),
1663                    (doc_url.to_string(), b"# onboarding\n".to_vec()),
1664                ]),
1665            },
1666            test_env(&temp)?,
1667        );
1668        installer.install_tenant("acme", "secret-token")?;
1669        assert_eq!(fs::read(temp.path().join("bin/greentic-x"))?, b"bin");
1670        assert_eq!(
1671            fs::read_to_string(temp.path().join("docs/acme/onboarding/README.md"))?,
1672            "# onboarding\n"
1673        );
1674        Ok(())
1675    }
1676
1677    #[test]
1678    fn locale_uses_language_specific_doc_translation() -> Result<()> {
1679        let temp = TempDir::new()?;
1680        let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1681        let sha = sha256_hex(&tool_archive);
1682        let en_doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1683        let nl_doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.nl.md";
1684        let manifest = serde_json::to_vec(&TenantInstallManifest {
1685            schema: None,
1686            schema_version: "1".to_string(),
1687            tenant: "acme".to_string(),
1688            tools: vec![TenantToolDescriptor::Expanded(TenantToolEntry {
1689                schema: None,
1690                id: "greentic-x-cli".to_string(),
1691                name: "Greentic X CLI".to_string(),
1692                description: None,
1693                install: ToolInstall {
1694                    install_type: "release-binary".to_string(),
1695                    binary_name: "greentic-x".to_string(),
1696                    targets: vec![ReleaseTarget {
1697                        os: "linux".to_string(),
1698                        arch: "x86_64".to_string(),
1699                        url: "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz".to_string(),
1700                        sha256: Some(sha),
1701                    }],
1702                },
1703                docs: vec!["acme-onboarding".to_string()],
1704                i18n: std::collections::BTreeMap::new(),
1705            })],
1706            docs: vec![TenantDocDescriptor::Expanded(TenantDocEntry {
1707                schema: None,
1708                id: "acme-onboarding".to_string(),
1709                title: "Acme onboarding".to_string(),
1710                source: DocSource {
1711                    source_type: "download".to_string(),
1712                    url: en_doc_url.to_string(),
1713                },
1714                download_file_name: "onboarding.md".to_string(),
1715                default_relative_path: "acme/onboarding/README.md".to_string(),
1716                i18n: std::collections::BTreeMap::from([(
1717                    "nl".to_string(),
1718                    DocTranslation {
1719                        title: Some("Acme onboarding NL".to_string()),
1720                        download_file_name: Some("onboarding.nl.md".to_string()),
1721                        default_relative_path: Some("acme/onboarding/README.nl.md".to_string()),
1722                        source: Some(DocSource {
1723                            source_type: "download".to_string(),
1724                            url: nl_doc_url.to_string(),
1725                        }),
1726                    },
1727                )]),
1728            })],
1729        })?;
1730        let mut env = test_env(&temp)?;
1731        env.locale = "nl".to_string();
1732        let installer = Installer::new(
1733            FakeTenantManifestSource { manifest },
1734            FakeDownloader {
1735                responses: HashMap::from([
1736                    (
1737                        "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz".to_string(),
1738                        tool_archive,
1739                    ),
1740                    (en_doc_url.to_string(), b"# onboarding en\n".to_vec()),
1741                    (nl_doc_url.to_string(), b"# onboarding nl\n".to_vec()),
1742                ]),
1743            },
1744            env,
1745        );
1746        installer.install_tenant("acme", "secret-token")?;
1747        assert_eq!(
1748            fs::read_to_string(temp.path().join("docs/acme/onboarding/README.nl.md"))?,
1749            "# onboarding nl\n"
1750        );
1751        Ok(())
1752    }
1753
1754    #[test]
1755    fn tenant_install_accepts_simple_manifest_shape() -> Result<()> {
1756        let temp = TempDir::new()?;
1757        let tool_archive = tar_gz_with_binary("greentic-fast2flow", b"bin");
1758        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";
1759        let doc_url =
1760            "https://raw.githubusercontent.com/greentic-biz/greentic-fast2flow/master/README.md";
1761        let manifest = serde_json::to_vec(&TenantInstallManifest {
1762            schema: None,
1763            schema_version: "1".to_string(),
1764            tenant: "3point".to_string(),
1765            tools: vec![TenantToolDescriptor::Simple(SimpleTenantToolEntry {
1766                id: "greentic-fast2flow".to_string(),
1767                binary_name: None,
1768                targets: vec![ReleaseTarget {
1769                    os: "linux".to_string(),
1770                    arch: "x86_64".to_string(),
1771                    url: tool_url.to_string(),
1772                    sha256: None,
1773                }],
1774            })],
1775            docs: vec![TenantDocDescriptor::Simple(SimpleTenantDocEntry {
1776                url: doc_url.to_string(),
1777                file_name: "greentic-fast2flow-guide.md".to_string(),
1778            })],
1779        })?;
1780        let installer = Installer::new(
1781            FakeTenantManifestSource { manifest },
1782            FakeDownloader {
1783                responses: HashMap::from([
1784                    (tool_url.to_string(), tool_archive),
1785                    (doc_url.to_string(), b"# fast2flow\n".to_vec()),
1786                ]),
1787            },
1788            test_env(&temp)?,
1789        );
1790        installer.install_tenant("3point", "secret-token")?;
1791        assert_eq!(
1792            fs::read(temp.path().join("bin/greentic-fast2flow"))?,
1793            b"bin"
1794        );
1795        assert_eq!(
1796            fs::read_to_string(temp.path().join("docs/greentic-fast2flow-guide.md"))?,
1797            "# fast2flow\n"
1798        );
1799        Ok(())
1800    }
1801
1802    #[test]
1803    fn expected_binary_name_strips_release_target_and_version() {
1804        let name = expected_binary_name(
1805            "greentic-fast2flow",
1806            "https://github.com/greentic-biz/greentic-fast2flow/releases/download/v0.4.1/greentic-fast2flow-v0.4.1-x86_64-unknown-linux-gnu.tar.gz",
1807        );
1808        assert_eq!(name, "greentic-fast2flow");
1809    }
1810
1811    #[test]
1812    fn extracts_tar_gz_binary_with_versioned_entry_name() -> Result<()> {
1813        let temp = TempDir::new()?;
1814        let archive = tar_gz_with_binary(
1815            "greentic-mcp-generator-0.4.14-x86_64-unknown-linux-gnu",
1816            b"bin",
1817        );
1818        let out = extract_tar_gz_binary(&archive, "greentic-mcp-generator", temp.path())?;
1819        assert_eq!(
1820            out,
1821            temp.path()
1822                .join("greentic-mcp-generator-0.4.14-x86_64-unknown-linux-gnu")
1823        );
1824        assert_eq!(fs::read(out)?, b"bin");
1825        Ok(())
1826    }
1827
1828    #[test]
1829    fn extracts_tar_gz_binary_even_when_archive_name_differs() -> Result<()> {
1830        let temp = TempDir::new()?;
1831        let archive = tar_gz_with_binary("greentic-mcp-gen", b"bin");
1832        let out = extract_tar_gz_binary(&archive, "greentic-mcp-generator", temp.path())?;
1833        assert_eq!(out, temp.path().join("greentic-mcp-gen"));
1834        assert_eq!(fs::read(out)?, b"bin");
1835        Ok(())
1836    }
1837}