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