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 greentic_distributor_client::oci_packs::PulledImage {
1314 digest: image.digest,
1315 layers,
1316 }
1317}
1318
1319#[derive(Default)]
1320struct RealTenantManifestSource;
1321
1322#[async_trait]
1323impl TenantManifestSource for RealTenantManifestSource {
1324 async fn fetch_manifest(&self, tenant: &str, token: &str) -> Result<Vec<u8>> {
1325 if let Some(bytes) = self.fetch_github_release_manifest(tenant, token).await? {
1326 return Ok(bytes);
1327 }
1328 self.fetch_oci_manifest(tenant, token).await
1329 }
1330}
1331
1332impl RealTenantManifestSource {
1333 async fn fetch_github_release_manifest(
1334 &self,
1335 tenant: &str,
1336 token: &str,
1337 ) -> Result<Option<Vec<u8>>> {
1338 let client = reqwest::Client::builder()
1339 .user_agent(format!("greentic-dev/{}", env!("CARGO_PKG_VERSION")))
1340 .build()
1341 .context("failed to build GitHub HTTP client")?;
1342 let release_url = github_latest_release_api_url();
1343 let response = client
1344 .get(&release_url)
1345 .bearer_auth(token)
1346 .header(reqwest::header::ACCEPT, "application/vnd.github+json")
1347 .send()
1348 .await
1349 .with_context(|| format!("failed to resolve GitHub release `{release_url}`"))?;
1350 if response.status() == reqwest::StatusCode::NOT_FOUND {
1351 return Ok(None);
1352 }
1353 let release = response
1354 .error_for_status()
1355 .with_context(|| format!("failed to resolve GitHub release `{release_url}`"))?
1356 .json::<GithubRelease>()
1357 .await
1358 .with_context(|| format!("failed to parse GitHub release `{release_url}`"))?;
1359 let asset_name = tenant_manifest_asset_name(tenant);
1360 let Some(asset) = release
1361 .assets
1362 .into_iter()
1363 .find(|asset| asset.name == asset_name)
1364 else {
1365 return Ok(None);
1366 };
1367 let response = client
1368 .get(&asset.url)
1369 .bearer_auth(token)
1370 .header(reqwest::header::ACCEPT, "application/octet-stream")
1371 .send()
1372 .await
1373 .with_context(|| format!("failed to download tenant manifest asset `{asset_name}`"))?
1374 .error_for_status()
1375 .with_context(|| format!("failed to download tenant manifest asset `{asset_name}`"))?;
1376 let bytes = response
1377 .bytes()
1378 .await
1379 .with_context(|| format!("failed to read tenant manifest asset `{asset_name}`"))?;
1380 Ok(Some(bytes.to_vec()))
1381 }
1382
1383 async fn fetch_oci_manifest(&self, tenant: &str, token: &str) -> Result<Vec<u8>> {
1384 let opts = PackFetchOptions {
1385 allow_tags: true,
1386 accepted_manifest_types: vec![
1387 OCI_IMAGE_MEDIA_TYPE.to_string(),
1388 IMAGE_MANIFEST_MEDIA_TYPE.to_string(),
1389 ],
1390 accepted_layer_media_types: vec![OCI_LAYER_JSON_MEDIA_TYPE.to_string()],
1391 preferred_layer_media_types: vec![OCI_LAYER_JSON_MEDIA_TYPE.to_string()],
1392 ..Default::default()
1393 };
1394 let client = AuthRegistryClient {
1395 inner: Client::new(ClientConfig {
1396 protocol: ClientProtocol::Https,
1397 ..Default::default()
1398 }),
1399 token: token.to_string(),
1400 };
1401 let fetcher = OciPackFetcher::with_client(client, opts);
1402 let reference = format!("{CUSTOMERS_TOOLS_REPO}/{tenant}:latest");
1403 let resolved = match fetcher.fetch_pack_to_cache(&reference).await {
1404 Ok(resolved) => resolved,
1405 Err(err) => {
1406 let msg = err.to_string();
1407 if msg.contains("manifest unknown") {
1408 return Err(anyhow!(
1409 "tenant manifest not found at `{reference}`. Check that the tenant slug is correct and that the OCI artifact has been published with tag `latest`."
1410 ));
1411 }
1412 return Err(err)
1413 .with_context(|| format!("failed to pull tenant OCI manifest `{reference}`"));
1414 }
1415 };
1416 fs::read(&resolved.path).with_context(|| {
1417 format!(
1418 "failed to read cached OCI manifest {}",
1419 resolved.path.display()
1420 )
1421 })
1422 }
1423}
1424
1425fn github_latest_release_api_url() -> String {
1426 format!(
1427 "https://api.github.com/repos/{CUSTOMERS_TOOLS_GITHUB_OWNER}/{CUSTOMERS_TOOLS_GITHUB_REPO}/releases/tags/{CUSTOMERS_TOOLS_GITHUB_RELEASE_TAG}"
1428 )
1429}
1430
1431fn tenant_manifest_asset_name(tenant: &str) -> String {
1432 format!("{tenant}.json")
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437 use super::*;
1438 use anyhow::Result;
1439 use std::collections::HashMap;
1440 use tempfile::TempDir;
1441
1442 struct FakeTenantManifestSource {
1443 manifest: Vec<u8>,
1444 }
1445
1446 #[async_trait]
1447 impl TenantManifestSource for FakeTenantManifestSource {
1448 async fn fetch_manifest(&self, _tenant: &str, _token: &str) -> Result<Vec<u8>> {
1449 Ok(self.manifest.clone())
1450 }
1451 }
1452
1453 struct FakeDownloader {
1454 responses: HashMap<String, Vec<u8>>,
1455 }
1456
1457 #[async_trait]
1458 impl Downloader for FakeDownloader {
1459 async fn download(&self, url: &str, token: &str) -> Result<Vec<u8>> {
1460 assert_eq!(token, "secret-token");
1461 self.responses
1462 .get(url)
1463 .cloned()
1464 .ok_or_else(|| anyhow!("unexpected URL {url}"))
1465 }
1466 }
1467
1468 fn test_env(temp: &TempDir) -> Result<InstallEnv> {
1469 Ok(InstallEnv {
1470 install_root: temp.path().join("install"),
1471 bin_dir: temp.path().join("bin"),
1472 docs_dir: temp.path().join("docs"),
1473 downloads_dir: temp.path().join("downloads"),
1474 manifests_dir: temp.path().join("manifests"),
1475 state_path: temp.path().join("install/state.json"),
1476 platform: Platform {
1477 os: "linux".to_string(),
1478 arch: "x86_64".to_string(),
1479 },
1480 locale: "en-US".to_string(),
1481 })
1482 }
1483
1484 fn expanded_manifest(tool_url: &str, doc_url: &str, tar_sha: &str, doc_path: &str) -> Vec<u8> {
1485 serde_json::to_vec(&TenantInstallManifest {
1486 schema: Some("https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tenant-tools.schema.json".to_string()),
1487 schema_version: "1".to_string(),
1488 tenant: "acme".to_string(),
1489 tools: vec![TenantToolDescriptor::Expanded(TenantToolEntry {
1490 schema: Some(
1491 "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tool.schema.json".to_string(),
1492 ),
1493 id: "greentic-x-cli".to_string(),
1494 name: "Greentic X CLI".to_string(),
1495 description: Some("CLI".to_string()),
1496 install: ToolInstall {
1497 install_type: "release-binary".to_string(),
1498 binary_name: "greentic-x".to_string(),
1499 targets: vec![ReleaseTarget {
1500 os: "linux".to_string(),
1501 arch: "x86_64".to_string(),
1502 url: tool_url.to_string(),
1503 sha256: Some(tar_sha.to_string()),
1504 }],
1505 },
1506 docs: vec!["acme-onboarding".to_string()],
1507 i18n: std::collections::BTreeMap::new(),
1508 })],
1509 docs: vec![TenantDocDescriptor::Expanded(TenantDocEntry {
1510 schema: Some(
1511 "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/doc.schema.json".to_string(),
1512 ),
1513 id: "acme-onboarding".to_string(),
1514 title: "Acme onboarding".to_string(),
1515 source: DocSource {
1516 source_type: "download".to_string(),
1517 url: doc_url.to_string(),
1518 },
1519 download_file_name: "onboarding.md".to_string(),
1520 default_relative_path: doc_path.to_string(),
1521 i18n: std::collections::BTreeMap::new(),
1522 })],
1523 })
1524 .unwrap()
1525 }
1526
1527 fn referenced_manifest(tool_manifest_url: &str, doc_manifest_url: &str) -> Vec<u8> {
1528 serde_json::to_vec(&TenantInstallManifest {
1529 schema: Some("https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tenant-tools.schema.json".to_string()),
1530 schema_version: "1".to_string(),
1531 tenant: "acme".to_string(),
1532 tools: vec![TenantToolDescriptor::Ref(RemoteManifestRef {
1533 id: "greentic-x-cli".to_string(),
1534 url: tool_manifest_url.to_string(),
1535 })],
1536 docs: vec![TenantDocDescriptor::Ref(RemoteManifestRef {
1537 id: "acme-onboarding".to_string(),
1538 url: doc_manifest_url.to_string(),
1539 })],
1540 })
1541 .unwrap()
1542 }
1543
1544 fn tar_gz_with_binary(name: &str, contents: &[u8]) -> Vec<u8> {
1545 let mut tar_buf = Vec::new();
1546 {
1547 let mut builder = tar::Builder::new(&mut tar_buf);
1548 let mut header = tar::Header::new_gnu();
1549 header.set_mode(0o755);
1550 header.set_size(contents.len() as u64);
1551 header.set_cksum();
1552 builder
1553 .append_data(&mut header, name, Cursor::new(contents))
1554 .unwrap();
1555 builder.finish().unwrap();
1556 }
1557 let mut out = Vec::new();
1558 {
1559 let mut encoder =
1560 flate2::write::GzEncoder::new(&mut out, flate2::Compression::default());
1561 std::io::copy(&mut Cursor::new(tar_buf), &mut encoder).unwrap();
1562 encoder.finish().unwrap();
1563 }
1564 out
1565 }
1566
1567 #[test]
1568 fn selects_matching_target() -> Result<()> {
1569 let platform = Platform {
1570 os: "linux".to_string(),
1571 arch: "x86_64".to_string(),
1572 };
1573 let targets = vec![
1574 ReleaseTarget {
1575 os: "windows".to_string(),
1576 arch: "x86_64".to_string(),
1577 url: "https://github.com/x.zip".to_string(),
1578 sha256: Some("a".repeat(64)),
1579 },
1580 ReleaseTarget {
1581 os: "linux".to_string(),
1582 arch: "x86_64".to_string(),
1583 url: "https://github.com/y.tar.gz".to_string(),
1584 sha256: Some("b".repeat(64)),
1585 },
1586 ];
1587 let selected = select_release_target(&targets, &platform)?;
1588 assert_eq!(selected.url, "https://github.com/y.tar.gz");
1589 Ok(())
1590 }
1591
1592 #[test]
1593 fn checksum_verification_reports_failure() {
1594 let err = verify_sha256(b"abc", &"0".repeat(64)).unwrap_err();
1595 assert!(format!("{err}").contains("sha256 mismatch"));
1596 }
1597
1598 #[test]
1599 fn resolve_token_prompts_when_missing_in_interactive_mode() -> Result<()> {
1600 let token = resolve_token_with(None, true, || Ok("secret-token".to_string()), "en")?;
1601 assert_eq!(token, "secret-token");
1602 Ok(())
1603 }
1604
1605 #[test]
1606 fn resolve_token_errors_when_missing_in_non_interactive_mode() {
1607 let err = resolve_token_with(None, false, || Ok("unused".to_string()), "en").unwrap_err();
1608 assert!(format!("{err}").contains("no interactive terminal"));
1609 }
1610
1611 #[test]
1612 fn tenant_manifest_asset_name_uses_tenant_json() {
1613 assert_eq!(tenant_manifest_asset_name("3point"), "3point.json");
1614 assert_eq!(
1615 github_latest_release_api_url(),
1616 "https://api.github.com/repos/greentic-biz/customers-tools/releases/tags/latest"
1617 );
1618 }
1619
1620 #[test]
1621 fn parses_github_latest_release_download_url() {
1622 let spec = parse_github_release_url(
1623 "https://github.com/greentic-biz/greentic-mcp-generator/releases/latest/download/greentic-mcp-generator.json",
1624 )
1625 .unwrap();
1626 assert_eq!(spec.owner, "greentic-biz");
1627 assert_eq!(spec.repo, "greentic-mcp-generator");
1628 assert_eq!(spec.tag, "latest");
1629 assert_eq!(spec.asset_name, "greentic-mcp-generator.json");
1630 }
1631
1632 #[test]
1633 fn parses_github_tagged_release_download_url() {
1634 let spec = parse_github_release_url(
1635 "https://github.com/greentic-biz/greentic-mcp-generator/releases/download/v1.0.0/greentic-mcp-generator.json",
1636 )
1637 .unwrap();
1638 assert_eq!(spec.owner, "greentic-biz");
1639 assert_eq!(spec.repo, "greentic-mcp-generator");
1640 assert_eq!(spec.tag, "v1.0.0");
1641 assert_eq!(spec.asset_name, "greentic-mcp-generator.json");
1642 }
1643
1644 #[test]
1645 fn extracts_tar_gz_binary() -> Result<()> {
1646 let temp = TempDir::new()?;
1647 let archive = tar_gz_with_binary("greentic-x", b"hello");
1648 let out = extract_tar_gz_binary(&archive, "greentic-x", temp.path())?;
1649 assert_eq!(out, temp.path().join("greentic-x"));
1650 assert_eq!(fs::read(&out)?, b"hello");
1651 Ok(())
1652 }
1653
1654 #[test]
1655 fn tenant_install_happy_path_writes_binary_doc_manifest_and_state() -> Result<()> {
1656 let temp = TempDir::new()?;
1657 let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1658 let sha = sha256_hex(&tool_archive);
1659 let tool_url =
1660 "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1661 let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1662 let manifest = expanded_manifest(tool_url, doc_url, &sha, "acme/onboarding/README.md");
1663
1664 let installer = Installer::new(
1665 FakeTenantManifestSource { manifest },
1666 FakeDownloader {
1667 responses: HashMap::from([
1668 (tool_url.to_string(), tool_archive.clone()),
1669 (doc_url.to_string(), b"# onboarding\n".to_vec()),
1670 ]),
1671 },
1672 test_env(&temp)?,
1673 );
1674 installer.install_tenant("acme", "secret-token")?;
1675
1676 assert_eq!(fs::read(temp.path().join("bin/greentic-x"))?, b"bin");
1677 assert_eq!(
1678 fs::read_to_string(temp.path().join("docs/acme/onboarding/README.md"))?,
1679 "# onboarding\n"
1680 );
1681 assert!(temp.path().join("manifests/tenant-acme.json").exists());
1682 assert!(temp.path().join("install/state.json").exists());
1683 Ok(())
1684 }
1685
1686 #[test]
1687 fn install_rejects_path_traversal_in_docs() -> Result<()> {
1688 let temp = TempDir::new()?;
1689 let archive = tar_gz_with_binary("greentic-x", b"bin");
1690 let sha = sha256_hex(&archive);
1691 let tool_url =
1692 "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1693 let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1694 let manifest = expanded_manifest(tool_url, doc_url, &sha, "../escape.md");
1695 let installer = Installer::new(
1696 FakeTenantManifestSource { manifest },
1697 FakeDownloader {
1698 responses: HashMap::from([
1699 (tool_url.to_string(), archive),
1700 (doc_url.to_string(), b"# onboarding\n".to_vec()),
1701 ]),
1702 },
1703 test_env(&temp)?,
1704 );
1705 let err = installer
1706 .install_tenant("acme", "secret-token")
1707 .unwrap_err();
1708 assert!(format!("{err}").contains("docs directory"));
1709 Ok(())
1710 }
1711
1712 #[test]
1713 fn archive_name_matching_handles_versioned_binaries() {
1714 assert!(archive_name_matches("greentic-x", "greentic-x"));
1715 assert!(archive_name_matches("greentic-x", "greentic-x-v1.2.3"));
1716 assert!(archive_name_matches("greentic-x.exe", "greentic-x.exe"));
1717 assert!(!archive_name_matches("greentic-x", "other-tool"));
1718 }
1719
1720 #[test]
1721 fn safe_archive_relative_path_rejects_escaping_paths() {
1722 assert_eq!(
1723 safe_archive_relative_path(Path::new("bin/greentic-x")),
1724 Some(PathBuf::from("bin/greentic-x"))
1725 );
1726 assert!(safe_archive_relative_path(Path::new("../escape")).is_none());
1727 assert!(safe_archive_relative_path(Path::new("/absolute")).is_none());
1728 }
1729
1730 #[test]
1731 fn github_url_enforcement_allows_github_and_localhost_only() {
1732 enforce_github_url("https://github.com/acme/project/releases/download/v1/tool.tgz")
1733 .unwrap();
1734 enforce_github_url("http://localhost:8080/test").unwrap();
1735
1736 let err = enforce_github_url("https://example.com/tool.tgz").unwrap_err();
1737 assert!(format!("{err}").contains("GitHub-hosted assets"));
1738 }
1739
1740 #[test]
1741 fn tenant_install_resolves_tool_and_doc_manifests_by_url() -> Result<()> {
1742 let temp = TempDir::new()?;
1743 let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1744 let sha = sha256_hex(&tool_archive);
1745 let tool_url =
1746 "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1747 let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1748 let tool_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/tools/greentic-x-cli/manifest.json";
1749 let doc_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/docs/acme-onboarding.json";
1750 let tenant_manifest = referenced_manifest(tool_manifest_url, doc_manifest_url);
1751 let tool_manifest = serde_json::to_vec(&TenantToolEntry {
1752 schema: Some(
1753 "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tool.schema.json".to_string(),
1754 ),
1755 id: "greentic-x-cli".to_string(),
1756 name: "Greentic X CLI".to_string(),
1757 description: Some("CLI".to_string()),
1758 install: ToolInstall {
1759 install_type: "release-binary".to_string(),
1760 binary_name: "greentic-x".to_string(),
1761 targets: vec![ReleaseTarget {
1762 os: "linux".to_string(),
1763 arch: "x86_64".to_string(),
1764 url: tool_url.to_string(),
1765 sha256: Some(sha.clone()),
1766 }],
1767 },
1768 docs: vec!["acme-onboarding".to_string()],
1769 i18n: std::collections::BTreeMap::new(),
1770 })?;
1771 let doc_manifest = serde_json::to_vec(&TenantDocEntry {
1772 schema: Some(
1773 "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/doc.schema.json".to_string(),
1774 ),
1775 id: "acme-onboarding".to_string(),
1776 title: "Acme onboarding".to_string(),
1777 source: DocSource {
1778 source_type: "download".to_string(),
1779 url: doc_url.to_string(),
1780 },
1781 download_file_name: "onboarding.md".to_string(),
1782 default_relative_path: "acme/onboarding/README.md".to_string(),
1783 i18n: std::collections::BTreeMap::new(),
1784 })?;
1785
1786 let installer = Installer::new(
1787 FakeTenantManifestSource {
1788 manifest: tenant_manifest,
1789 },
1790 FakeDownloader {
1791 responses: HashMap::from([
1792 (tool_manifest_url.to_string(), tool_manifest),
1793 (doc_manifest_url.to_string(), doc_manifest),
1794 (tool_url.to_string(), tool_archive),
1795 (doc_url.to_string(), b"# onboarding\n".to_vec()),
1796 ]),
1797 },
1798 test_env(&temp)?,
1799 );
1800 installer.install_tenant("acme", "secret-token")?;
1801 assert_eq!(fs::read(temp.path().join("bin/greentic-x"))?, b"bin");
1802 assert_eq!(
1803 fs::read_to_string(temp.path().join("docs/acme/onboarding/README.md"))?,
1804 "# onboarding\n"
1805 );
1806 Ok(())
1807 }
1808
1809 #[test]
1810 fn tenant_install_supports_referenced_multi_doc_manifest() -> Result<()> {
1811 let temp = TempDir::new()?;
1812 let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1813 let sha = sha256_hex(&tool_archive);
1814 let tool_url =
1815 "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1816 let doc_a_url = "https://raw.githubusercontent.com/acme/docs/main/a.md";
1817 let doc_b_url = "https://raw.githubusercontent.com/acme/docs/main/b.md";
1818 let tool_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/tools/greentic-x-cli/manifest.json";
1819 let doc_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/docs/acme-onboarding.json";
1820 let tenant_manifest = referenced_manifest(tool_manifest_url, doc_manifest_url);
1821 let tool_manifest = serde_json::to_vec(&TenantToolEntry {
1822 schema: Some(
1823 "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tool.schema.json".to_string(),
1824 ),
1825 id: "greentic-x-cli".to_string(),
1826 name: "Greentic X CLI".to_string(),
1827 description: Some("CLI".to_string()),
1828 install: ToolInstall {
1829 install_type: "release-binary".to_string(),
1830 binary_name: "greentic-x".to_string(),
1831 targets: vec![ReleaseTarget {
1832 os: "linux".to_string(),
1833 arch: "x86_64".to_string(),
1834 url: tool_url.to_string(),
1835 sha256: Some(sha),
1836 }],
1837 },
1838 docs: vec!["acme-onboarding".to_string()],
1839 i18n: std::collections::BTreeMap::new(),
1840 })?;
1841 let doc_manifest = serde_json::json!({
1842 "schema_version": "1",
1843 "id": "acme-onboarding",
1844 "docs": [
1845 {
1846 "title": "A",
1847 "source": {
1848 "type": "download",
1849 "url": doc_a_url
1850 },
1851 "download_file_name": "a.md",
1852 "default_relative_path": "docs/a.md"
1853 },
1854 {
1855 "title": "B",
1856 "source": {
1857 "type": "download",
1858 "url": doc_b_url
1859 },
1860 "download_file_name": "b.md",
1861 "default_relative_path": "docs/b.md"
1862 }
1863 ]
1864 });
1865
1866 let installer = Installer::new(
1867 FakeTenantManifestSource {
1868 manifest: tenant_manifest,
1869 },
1870 FakeDownloader {
1871 responses: HashMap::from([
1872 (tool_manifest_url.to_string(), tool_manifest),
1873 (
1874 doc_manifest_url.to_string(),
1875 serde_json::to_vec(&doc_manifest)?,
1876 ),
1877 (tool_url.to_string(), tool_archive),
1878 (doc_a_url.to_string(), b"# A\n".to_vec()),
1879 (doc_b_url.to_string(), b"# B\n".to_vec()),
1880 ]),
1881 },
1882 test_env(&temp)?,
1883 );
1884 installer.install_tenant("acme", "secret-token")?;
1885 assert_eq!(
1886 fs::read_to_string(temp.path().join("docs/docs/a.md"))?,
1887 "# A\n"
1888 );
1889 assert_eq!(
1890 fs::read_to_string(temp.path().join("docs/docs/b.md"))?,
1891 "# B\n"
1892 );
1893 Ok(())
1894 }
1895
1896 #[test]
1897 fn locale_uses_language_specific_doc_translation() -> Result<()> {
1898 let temp = TempDir::new()?;
1899 let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1900 let sha = sha256_hex(&tool_archive);
1901 let en_doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1902 let nl_doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.nl.md";
1903 let manifest = serde_json::to_vec(&TenantInstallManifest {
1904 schema: None,
1905 schema_version: "1".to_string(),
1906 tenant: "acme".to_string(),
1907 tools: vec![TenantToolDescriptor::Expanded(TenantToolEntry {
1908 schema: None,
1909 id: "greentic-x-cli".to_string(),
1910 name: "Greentic X CLI".to_string(),
1911 description: None,
1912 install: ToolInstall {
1913 install_type: "release-binary".to_string(),
1914 binary_name: "greentic-x".to_string(),
1915 targets: vec![ReleaseTarget {
1916 os: "linux".to_string(),
1917 arch: "x86_64".to_string(),
1918 url: "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz".to_string(),
1919 sha256: Some(sha),
1920 }],
1921 },
1922 docs: vec!["acme-onboarding".to_string()],
1923 i18n: std::collections::BTreeMap::new(),
1924 })],
1925 docs: vec![TenantDocDescriptor::Expanded(TenantDocEntry {
1926 schema: None,
1927 id: "acme-onboarding".to_string(),
1928 title: "Acme onboarding".to_string(),
1929 source: DocSource {
1930 source_type: "download".to_string(),
1931 url: en_doc_url.to_string(),
1932 },
1933 download_file_name: "onboarding.md".to_string(),
1934 default_relative_path: "acme/onboarding/README.md".to_string(),
1935 i18n: std::collections::BTreeMap::from([(
1936 "nl".to_string(),
1937 DocTranslation {
1938 title: Some("Acme onboarding NL".to_string()),
1939 download_file_name: Some("onboarding.nl.md".to_string()),
1940 default_relative_path: Some("acme/onboarding/README.nl.md".to_string()),
1941 source: Some(DocSource {
1942 source_type: "download".to_string(),
1943 url: nl_doc_url.to_string(),
1944 }),
1945 },
1946 )]),
1947 })],
1948 })?;
1949 let mut env = test_env(&temp)?;
1950 env.locale = "nl".to_string();
1951 let installer = Installer::new(
1952 FakeTenantManifestSource { manifest },
1953 FakeDownloader {
1954 responses: HashMap::from([
1955 (
1956 "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz".to_string(),
1957 tool_archive,
1958 ),
1959 (en_doc_url.to_string(), b"# onboarding en\n".to_vec()),
1960 (nl_doc_url.to_string(), b"# onboarding nl\n".to_vec()),
1961 ]),
1962 },
1963 env,
1964 );
1965 installer.install_tenant("acme", "secret-token")?;
1966 assert_eq!(
1967 fs::read_to_string(temp.path().join("docs/acme/onboarding/README.nl.md"))?,
1968 "# onboarding nl\n"
1969 );
1970 Ok(())
1971 }
1972
1973 #[test]
1974 fn tenant_install_accepts_simple_manifest_shape() -> Result<()> {
1975 let temp = TempDir::new()?;
1976 let tool_archive = tar_gz_with_binary("greentic-fast2flow", b"bin");
1977 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";
1978 let doc_url =
1979 "https://raw.githubusercontent.com/greentic-biz/greentic-fast2flow/master/README.md";
1980 let manifest = serde_json::to_vec(&TenantInstallManifest {
1981 schema: None,
1982 schema_version: "1".to_string(),
1983 tenant: "3point".to_string(),
1984 tools: vec![TenantToolDescriptor::Simple(SimpleTenantToolEntry {
1985 id: "greentic-fast2flow".to_string(),
1986 binary_name: None,
1987 targets: vec![ReleaseTarget {
1988 os: "linux".to_string(),
1989 arch: "x86_64".to_string(),
1990 url: tool_url.to_string(),
1991 sha256: None,
1992 }],
1993 })],
1994 docs: vec![TenantDocDescriptor::Simple(SimpleTenantDocEntry {
1995 url: doc_url.to_string(),
1996 file_name: "greentic-fast2flow-guide.md".to_string(),
1997 })],
1998 })?;
1999 let installer = Installer::new(
2000 FakeTenantManifestSource { manifest },
2001 FakeDownloader {
2002 responses: HashMap::from([
2003 (tool_url.to_string(), tool_archive),
2004 (doc_url.to_string(), b"# fast2flow\n".to_vec()),
2005 ]),
2006 },
2007 test_env(&temp)?,
2008 );
2009 installer.install_tenant("3point", "secret-token")?;
2010 assert_eq!(
2011 fs::read(temp.path().join("bin/greentic-fast2flow"))?,
2012 b"bin"
2013 );
2014 assert_eq!(
2015 fs::read_to_string(temp.path().join("docs/greentic-fast2flow-guide.md"))?,
2016 "# fast2flow\n"
2017 );
2018 Ok(())
2019 }
2020
2021 #[test]
2022 fn expected_binary_name_strips_release_target_and_version() {
2023 let name = expected_binary_name(
2024 "greentic-fast2flow",
2025 "https://github.com/greentic-biz/greentic-fast2flow/releases/download/v0.4.1/greentic-fast2flow-v0.4.1-x86_64-unknown-linux-gnu.tar.gz",
2026 );
2027 assert_eq!(name, "greentic-fast2flow");
2028 }
2029
2030 #[test]
2031 fn extracts_tar_gz_binary_with_versioned_entry_name() -> Result<()> {
2032 let temp = TempDir::new()?;
2033 let archive = tar_gz_with_binary(
2034 "greentic-mcp-generator-0.4.14-x86_64-unknown-linux-gnu",
2035 b"bin",
2036 );
2037 let out = extract_tar_gz_binary(&archive, "greentic-mcp-generator", temp.path())?;
2038 assert_eq!(
2039 out,
2040 temp.path()
2041 .join("greentic-mcp-generator-0.4.14-x86_64-unknown-linux-gnu")
2042 );
2043 assert_eq!(fs::read(out)?, b"bin");
2044 Ok(())
2045 }
2046
2047 #[test]
2048 fn extracts_tar_gz_binary_even_when_archive_name_differs() -> Result<()> {
2049 let temp = TempDir::new()?;
2050 let archive = tar_gz_with_binary("greentic-mcp-gen", b"bin");
2051 let out = extract_tar_gz_binary(&archive, "greentic-mcp-generator", temp.path())?;
2052 assert_eq!(out, temp.path().join("greentic-mcp-gen"));
2053 assert_eq!(fs::read(out)?, b"bin");
2054 Ok(())
2055 }
2056}