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