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