Skip to main content

greentic_dev/
install.rs

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