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