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 = sha256_hex(bytes);
756 if actual != expected.to_ascii_lowercase() {
757 bail!("sha256 mismatch: expected {expected}, got {actual}");
758 }
759 Ok(())
760}
761
762fn sha256_hex(bytes: &[u8]) -> String {
763 let digest = Sha256::digest(bytes);
764 let mut output = String::with_capacity(digest.len() * 2);
765 for byte in digest {
766 output.push_str(&format!("{byte:02x}"));
767 }
768 output
769}
770
771fn sanitize_relative_path(path: &str) -> Result<PathBuf> {
772 let pb = PathBuf::from(path);
773 if pb.is_absolute() {
774 bail!("absolute doc install paths are not allowed");
775 }
776 for component in pb.components() {
777 if matches!(
778 component,
779 Component::ParentDir | Component::RootDir | Component::Prefix(_)
780 ) {
781 bail!("doc install path must stay within the docs directory");
782 }
783 }
784 Ok(pb)
785}
786
787fn extract_tar_gz_binary(bytes: &[u8], binary_name: &str, dest_dir: &Path) -> Result<PathBuf> {
788 let decoder = GzDecoder::new(Cursor::new(bytes));
789 let mut archive = Archive::new(decoder);
790 let mut fallback: Option<PathBuf> = None;
791 let mut extracted = Vec::new();
792 for entry in archive.entries().context("failed to read tar.gz archive")? {
793 let mut entry = entry.context("failed to read tar.gz archive entry")?;
794 let path = entry.path().context("failed to read tar.gz entry path")?;
795 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
796 continue;
797 };
798 let name = name.to_string();
799 if !entry.header().entry_type().is_file() {
800 continue;
801 }
802 let out_path = dest_dir.join(&name);
803 let mut buf = Vec::new();
804 entry
805 .read_to_end(&mut buf)
806 .with_context(|| format!("failed to extract `{name}` from tar.gz"))?;
807 fs::write(&out_path, buf)
808 .with_context(|| format!("failed to write {}", out_path.display()))?;
809 extracted.push(out_path.clone());
810 if name == binary_name {
811 return Ok(out_path);
812 }
813 if fallback.is_none() && archive_name_matches(binary_name, &name) {
814 fallback = Some(out_path);
815 }
816 }
817 if let Some(path) = fallback {
818 return Ok(path);
819 }
820 if let Some(path) = extracted.into_iter().next() {
821 return Ok(path);
822 }
823 let (debug_dir, entries) = dump_tar_gz_debug(bytes, binary_name)?;
824 bail!(
825 "archive did not contain `{binary_name}`. extracted debug dump to `{}` with entries: {}",
826 debug_dir.display(),
827 entries.join(", ")
828 );
829}
830
831fn extract_zip_binary(bytes: &[u8], binary_name: &str, dest_dir: &Path) -> Result<PathBuf> {
832 let cursor = Cursor::new(bytes);
833 let mut archive = ZipArchive::new(cursor).context("failed to open zip archive")?;
834 let mut fallback: Option<PathBuf> = None;
835 let mut extracted = Vec::new();
836 for idx in 0..archive.len() {
837 let mut file = archive
838 .by_index(idx)
839 .context("failed to read zip archive entry")?;
840 if file.is_dir() {
841 continue;
842 }
843 let Some(name) = Path::new(file.name())
844 .file_name()
845 .and_then(|name| name.to_str())
846 else {
847 continue;
848 };
849 let name = name.to_string();
850 let out_path = dest_dir.join(&name);
851 let mut buf = Vec::new();
852 file.read_to_end(&mut buf)
853 .with_context(|| format!("failed to extract `{name}` from zip"))?;
854 fs::write(&out_path, buf)
855 .with_context(|| format!("failed to write {}", out_path.display()))?;
856 extracted.push(out_path.clone());
857 if name == binary_name {
858 return Ok(out_path);
859 }
860 if fallback.is_none() && archive_name_matches(binary_name, &name) {
861 fallback = Some(out_path);
862 }
863 }
864 if let Some(path) = fallback {
865 return Ok(path);
866 }
867 if let Some(path) = extracted.into_iter().next() {
868 return Ok(path);
869 }
870 let (debug_dir, entries) = dump_zip_debug(bytes, binary_name)?;
871 bail!(
872 "archive did not contain `{binary_name}`. extracted debug dump to `{}` with entries: {}",
873 debug_dir.display(),
874 entries.join(", ")
875 );
876}
877
878fn archive_name_matches(expected: &str, actual: &str) -> bool {
879 let expected = expected.strip_suffix(".exe").unwrap_or(expected);
880 let actual = actual.strip_suffix(".exe").unwrap_or(actual);
881 actual == expected
882 || actual.starts_with(&format!("{expected}-"))
883 || actual.starts_with(&format!("{expected}_"))
884 || strip_version_suffix(actual) == expected
885}
886
887fn print_install_summary(locale: &str, tools: &[(String, PathBuf)], docs: &[(String, PathBuf)]) {
888 println!("{}", i18n::t(locale, "cli.install.summary.tools"));
889 for (id, path) in tools {
890 println!(
891 "{}",
892 i18n::tf(
893 locale,
894 "cli.install.summary.tool_item",
895 &[("id", id.clone()), ("path", path.display().to_string()),],
896 )
897 );
898 }
899 println!("{}", i18n::t(locale, "cli.install.summary.docs"));
900 for (id, path) in docs {
901 println!(
902 "{}",
903 i18n::tf(
904 locale,
905 "cli.install.summary.doc_item",
906 &[("id", id.clone()), ("path", path.display().to_string()),],
907 )
908 );
909 }
910}
911
912fn dump_tar_gz_debug(bytes: &[u8], binary_name: &str) -> Result<(PathBuf, Vec<String>)> {
913 let debug_dir = create_archive_debug_dir(binary_name)?;
914 let decoder = GzDecoder::new(Cursor::new(bytes));
915 let mut archive = Archive::new(decoder);
916 let mut entries = Vec::new();
917 for entry in archive
918 .entries()
919 .context("failed to read tar.gz archive for debug dump")?
920 {
921 let mut entry = entry.context("failed to read tar.gz archive entry for debug dump")?;
922 let path = entry
923 .path()
924 .context("failed to read tar.gz entry path for debug dump")?
925 .into_owned();
926 let display = path.display().to_string();
927 entries.push(display.clone());
928 if let Some(relative) = safe_archive_relative_path(&path) {
929 let out_path = debug_dir.join(relative);
930 if let Some(parent) = out_path.parent() {
931 fs::create_dir_all(parent)
932 .with_context(|| format!("failed to create {}", parent.display()))?;
933 }
934 if entry.header().entry_type().is_dir() {
935 fs::create_dir_all(&out_path)
936 .with_context(|| format!("failed to create {}", out_path.display()))?;
937 } else if entry.header().entry_type().is_file() {
938 let mut buf = Vec::new();
939 entry
940 .read_to_end(&mut buf)
941 .with_context(|| format!("failed to extract `{display}` for debug dump"))?;
942 fs::write(&out_path, buf)
943 .with_context(|| format!("failed to write {}", out_path.display()))?;
944 }
945 }
946 }
947 Ok((debug_dir, entries))
948}
949
950fn dump_zip_debug(bytes: &[u8], binary_name: &str) -> Result<(PathBuf, Vec<String>)> {
951 let debug_dir = create_archive_debug_dir(binary_name)?;
952 let cursor = Cursor::new(bytes);
953 let mut archive =
954 ZipArchive::new(cursor).context("failed to open zip archive for debug dump")?;
955 let mut entries = Vec::new();
956 for idx in 0..archive.len() {
957 let mut file = archive
958 .by_index(idx)
959 .context("failed to read zip archive entry for debug dump")?;
960 let path = PathBuf::from(file.name());
961 let display = path.display().to_string();
962 entries.push(display.clone());
963 if let Some(relative) = safe_archive_relative_path(&path) {
964 let out_path = debug_dir.join(relative);
965 if file.is_dir() {
966 fs::create_dir_all(&out_path)
967 .with_context(|| format!("failed to create {}", out_path.display()))?;
968 } else {
969 if let Some(parent) = out_path.parent() {
970 fs::create_dir_all(parent)
971 .with_context(|| format!("failed to create {}", parent.display()))?;
972 }
973 let mut buf = Vec::new();
974 file.read_to_end(&mut buf)
975 .with_context(|| format!("failed to extract `{display}` for debug dump"))?;
976 fs::write(&out_path, buf)
977 .with_context(|| format!("failed to write {}", out_path.display()))?;
978 }
979 }
980 }
981 Ok((debug_dir, entries))
982}
983
984fn create_archive_debug_dir(binary_name: &str) -> Result<PathBuf> {
985 let stamp = SystemTime::now()
986 .duration_since(UNIX_EPOCH)
987 .context("system time before unix epoch")?
988 .as_millis();
989 let dir = std::env::temp_dir().join(format!("greentic-dev-debug-{binary_name}-{stamp}"));
990 fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
991 Ok(dir)
992}
993
994fn safe_archive_relative_path(path: &Path) -> Option<PathBuf> {
995 let mut out = PathBuf::new();
996 for component in path.components() {
997 match component {
998 Component::Normal(part) => out.push(part),
999 Component::CurDir => {}
1000 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
1001 }
1002 }
1003 if out.as_os_str().is_empty() {
1004 None
1005 } else {
1006 Some(out)
1007 }
1008}
1009
1010fn ensure_executable(path: &Path) -> Result<()> {
1011 #[cfg(unix)]
1012 {
1013 use std::os::unix::fs::PermissionsExt;
1014 let mut perms = fs::metadata(path)
1015 .with_context(|| format!("failed to read {}", path.display()))?
1016 .permissions();
1017 perms.set_mode(0o755);
1018 fs::set_permissions(path, perms)
1019 .with_context(|| format!("failed to set executable bit on {}", path.display()))?;
1020 }
1021 Ok(())
1022}
1023
1024fn enforce_github_url(url: &str) -> Result<()> {
1025 let parsed = reqwest::Url::parse(url).with_context(|| format!("invalid URL `{url}`"))?;
1026 let Some(host) = parsed.host_str() else {
1027 bail!("URL `{url}` does not include a host");
1028 };
1029 let allowed = host == "github.com"
1030 || host.ends_with(".github.com")
1031 || host == "raw.githubusercontent.com"
1032 || host.ends_with(".githubusercontent.com")
1033 || host == "127.0.0.1"
1034 || host == "localhost";
1035 if !allowed {
1036 bail!("only GitHub-hosted assets are supported, got `{host}`");
1037 }
1038 Ok(())
1039}
1040
1041struct RealHttpDownloader {
1042 client: reqwest::Client,
1043}
1044
1045impl Default for RealHttpDownloader {
1046 fn default() -> Self {
1047 let client = reqwest::Client::builder()
1048 .user_agent(format!("greentic-dev/{}", env!("CARGO_PKG_VERSION")))
1049 .build()
1050 .expect("failed to build HTTP client");
1051 Self { client }
1052 }
1053}
1054
1055#[async_trait]
1056impl Downloader for RealHttpDownloader {
1057 async fn download(&self, url: &str, token: &str) -> Result<Vec<u8>> {
1058 let response =
1059 if let Some(asset_api_url) = self.resolve_github_asset_api_url(url, token).await? {
1060 self.client
1061 .get(asset_api_url)
1062 .bearer_auth(token)
1063 .header(reqwest::header::ACCEPT, "application/octet-stream")
1064 .send()
1065 .await
1066 .with_context(|| format!("failed to download `{url}`"))?
1067 } else {
1068 self.client
1069 .get(url)
1070 .bearer_auth(token)
1071 .send()
1072 .await
1073 .with_context(|| format!("failed to download `{url}`"))?
1074 }
1075 .error_for_status()
1076 .with_context(|| format!("download failed for `{url}`"))?;
1077 let bytes = response
1078 .bytes()
1079 .await
1080 .with_context(|| format!("failed to read response body from `{url}`"))?;
1081 Ok(bytes.to_vec())
1082 }
1083}
1084
1085impl RealHttpDownloader {
1086 async fn resolve_github_asset_api_url(&self, url: &str, token: &str) -> Result<Option<String>> {
1087 let Some(spec) = parse_github_release_url(url) else {
1088 return Ok(None);
1089 };
1090 let api_url = format!(
1091 "https://api.github.com/repos/{}/{}/releases/tags/{}",
1092 spec.owner, spec.repo, spec.tag
1093 );
1094 let release = self
1095 .client
1096 .get(api_url)
1097 .bearer_auth(token)
1098 .header(reqwest::header::ACCEPT, "application/vnd.github+json")
1099 .send()
1100 .await
1101 .with_context(|| format!("failed to resolve GitHub release for `{url}`"))?
1102 .error_for_status()
1103 .with_context(|| format!("failed to resolve GitHub release for `{url}`"))?
1104 .json::<GithubRelease>()
1105 .await
1106 .with_context(|| format!("failed to parse GitHub release metadata for `{url}`"))?;
1107 let Some(asset) = release
1108 .assets
1109 .into_iter()
1110 .find(|asset| asset.name == spec.asset_name)
1111 else {
1112 bail!(
1113 "download failed for `{url}`: release asset `{}` not found on tag `{}`",
1114 spec.asset_name,
1115 spec.tag
1116 );
1117 };
1118 Ok(Some(asset.url))
1119 }
1120}
1121
1122struct GithubReleaseUrlSpec {
1123 owner: String,
1124 repo: String,
1125 tag: String,
1126 asset_name: String,
1127}
1128
1129fn parse_github_release_url(url: &str) -> Option<GithubReleaseUrlSpec> {
1130 let parsed = reqwest::Url::parse(url).ok()?;
1131 if parsed.host_str()? != "github.com" {
1132 return None;
1133 }
1134 let segments = parsed.path_segments()?.collect::<Vec<_>>();
1135 if segments.len() < 6 {
1136 return None;
1137 }
1138 if segments[2] != "releases" || segments[3] != "download" {
1139 return None;
1140 }
1141 Some(GithubReleaseUrlSpec {
1142 owner: segments[0].to_string(),
1143 repo: segments[1].to_string(),
1144 tag: segments[4].to_string(),
1145 asset_name: segments[5..].join("/"),
1146 })
1147}
1148
1149#[derive(Clone)]
1150struct AuthRegistryClient {
1151 inner: Client,
1152 token: String,
1153}
1154
1155#[async_trait]
1156impl RegistryClient for AuthRegistryClient {
1157 fn default_client() -> Self {
1158 let config = ClientConfig {
1159 protocol: ClientProtocol::Https,
1160 ..Default::default()
1161 };
1162 Self {
1163 inner: Client::new(config),
1164 token: String::new(),
1165 }
1166 }
1167
1168 async fn pull(
1169 &self,
1170 reference: &Reference,
1171 accepted_manifest_types: &[&str],
1172 ) -> Result<greentic_distributor_client::oci_packs::PulledImage, OciDistributionError> {
1173 let image = self
1174 .inner
1175 .pull(
1176 reference,
1177 &RegistryAuth::Basic(OAUTH_USER.to_string(), self.token.clone()),
1178 accepted_manifest_types.to_vec(),
1179 )
1180 .await?;
1181 Ok(convert_image(image))
1182 }
1183}
1184
1185fn convert_image(image: ImageData) -> greentic_distributor_client::oci_packs::PulledImage {
1186 let layers = image
1187 .layers
1188 .into_iter()
1189 .map(|layer| {
1190 let digest = format!("sha256:{}", layer.sha256_digest());
1191 greentic_distributor_client::oci_packs::PulledLayer {
1192 media_type: layer.media_type,
1193 data: layer.data,
1194 digest: Some(digest),
1195 }
1196 })
1197 .collect();
1198 greentic_distributor_client::oci_packs::PulledImage {
1199 digest: image.digest,
1200 layers,
1201 }
1202}
1203
1204#[derive(Default)]
1205struct RealTenantManifestSource;
1206
1207#[async_trait]
1208impl TenantManifestSource for RealTenantManifestSource {
1209 async fn fetch_manifest(&self, tenant: &str, token: &str) -> Result<Vec<u8>> {
1210 let opts = PackFetchOptions {
1211 allow_tags: true,
1212 accepted_manifest_types: vec![
1213 OCI_IMAGE_MEDIA_TYPE.to_string(),
1214 IMAGE_MANIFEST_MEDIA_TYPE.to_string(),
1215 ],
1216 accepted_layer_media_types: vec![OCI_LAYER_JSON_MEDIA_TYPE.to_string()],
1217 preferred_layer_media_types: vec![OCI_LAYER_JSON_MEDIA_TYPE.to_string()],
1218 ..Default::default()
1219 };
1220 let client = AuthRegistryClient {
1221 inner: Client::new(ClientConfig {
1222 protocol: ClientProtocol::Https,
1223 ..Default::default()
1224 }),
1225 token: token.to_string(),
1226 };
1227 let fetcher = OciPackFetcher::with_client(client, opts);
1228 let reference = format!("{CUSTOMERS_TOOLS_REPO}/{tenant}:latest");
1229 let resolved = match fetcher.fetch_pack_to_cache(&reference).await {
1230 Ok(resolved) => resolved,
1231 Err(err) => {
1232 let msg = err.to_string();
1233 if msg.contains("manifest unknown") {
1234 return Err(anyhow!(
1235 "tenant manifest not found at `{reference}`. Check that the tenant slug is correct and that the OCI artifact has been published with tag `latest`."
1236 ));
1237 }
1238 return Err(err)
1239 .with_context(|| format!("failed to pull tenant OCI manifest `{reference}`"));
1240 }
1241 };
1242 fs::read(&resolved.path).with_context(|| {
1243 format!(
1244 "failed to read cached OCI manifest {}",
1245 resolved.path.display()
1246 )
1247 })
1248 }
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253 use super::*;
1254 use anyhow::Result;
1255 use std::collections::HashMap;
1256 use tempfile::TempDir;
1257
1258 struct FakeTenantManifestSource {
1259 manifest: Vec<u8>,
1260 }
1261
1262 #[async_trait]
1263 impl TenantManifestSource for FakeTenantManifestSource {
1264 async fn fetch_manifest(&self, _tenant: &str, _token: &str) -> Result<Vec<u8>> {
1265 Ok(self.manifest.clone())
1266 }
1267 }
1268
1269 struct FakeDownloader {
1270 responses: HashMap<String, Vec<u8>>,
1271 }
1272
1273 #[async_trait]
1274 impl Downloader for FakeDownloader {
1275 async fn download(&self, url: &str, token: &str) -> Result<Vec<u8>> {
1276 assert_eq!(token, "secret-token");
1277 self.responses
1278 .get(url)
1279 .cloned()
1280 .ok_or_else(|| anyhow!("unexpected URL {url}"))
1281 }
1282 }
1283
1284 fn test_env(temp: &TempDir) -> Result<InstallEnv> {
1285 Ok(InstallEnv {
1286 install_root: temp.path().join("install"),
1287 bin_dir: temp.path().join("bin"),
1288 docs_dir: temp.path().join("docs"),
1289 downloads_dir: temp.path().join("downloads"),
1290 manifests_dir: temp.path().join("manifests"),
1291 state_path: temp.path().join("install/state.json"),
1292 platform: Platform {
1293 os: "linux".to_string(),
1294 arch: "x86_64".to_string(),
1295 },
1296 locale: "en-US".to_string(),
1297 })
1298 }
1299
1300 fn expanded_manifest(tool_url: &str, doc_url: &str, tar_sha: &str, doc_path: &str) -> Vec<u8> {
1301 serde_json::to_vec(&TenantInstallManifest {
1302 schema: Some("https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tenant-tools.schema.json".to_string()),
1303 schema_version: "1".to_string(),
1304 tenant: "acme".to_string(),
1305 tools: vec![TenantToolDescriptor::Expanded(TenantToolEntry {
1306 schema: Some(
1307 "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tool.schema.json".to_string(),
1308 ),
1309 id: "greentic-x-cli".to_string(),
1310 name: "Greentic X CLI".to_string(),
1311 description: Some("CLI".to_string()),
1312 install: ToolInstall {
1313 install_type: "release-binary".to_string(),
1314 binary_name: "greentic-x".to_string(),
1315 targets: vec![ReleaseTarget {
1316 os: "linux".to_string(),
1317 arch: "x86_64".to_string(),
1318 url: tool_url.to_string(),
1319 sha256: Some(tar_sha.to_string()),
1320 }],
1321 },
1322 docs: vec!["acme-onboarding".to_string()],
1323 i18n: std::collections::BTreeMap::new(),
1324 })],
1325 docs: vec![TenantDocDescriptor::Expanded(TenantDocEntry {
1326 schema: Some(
1327 "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/doc.schema.json".to_string(),
1328 ),
1329 id: "acme-onboarding".to_string(),
1330 title: "Acme onboarding".to_string(),
1331 source: DocSource {
1332 source_type: "download".to_string(),
1333 url: doc_url.to_string(),
1334 },
1335 download_file_name: "onboarding.md".to_string(),
1336 default_relative_path: doc_path.to_string(),
1337 i18n: std::collections::BTreeMap::new(),
1338 })],
1339 })
1340 .unwrap()
1341 }
1342
1343 fn referenced_manifest(tool_manifest_url: &str, doc_manifest_url: &str) -> Vec<u8> {
1344 serde_json::to_vec(&TenantInstallManifest {
1345 schema: Some("https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tenant-tools.schema.json".to_string()),
1346 schema_version: "1".to_string(),
1347 tenant: "acme".to_string(),
1348 tools: vec![TenantToolDescriptor::Ref(RemoteManifestRef {
1349 id: "greentic-x-cli".to_string(),
1350 url: tool_manifest_url.to_string(),
1351 })],
1352 docs: vec![TenantDocDescriptor::Ref(RemoteManifestRef {
1353 id: "acme-onboarding".to_string(),
1354 url: doc_manifest_url.to_string(),
1355 })],
1356 })
1357 .unwrap()
1358 }
1359
1360 fn tar_gz_with_binary(name: &str, contents: &[u8]) -> Vec<u8> {
1361 let mut tar_buf = Vec::new();
1362 {
1363 let mut builder = tar::Builder::new(&mut tar_buf);
1364 let mut header = tar::Header::new_gnu();
1365 header.set_mode(0o755);
1366 header.set_size(contents.len() as u64);
1367 header.set_cksum();
1368 builder
1369 .append_data(&mut header, name, Cursor::new(contents))
1370 .unwrap();
1371 builder.finish().unwrap();
1372 }
1373 let mut out = Vec::new();
1374 {
1375 let mut encoder =
1376 flate2::write::GzEncoder::new(&mut out, flate2::Compression::default());
1377 std::io::copy(&mut Cursor::new(tar_buf), &mut encoder).unwrap();
1378 encoder.finish().unwrap();
1379 }
1380 out
1381 }
1382
1383 #[test]
1384 fn selects_matching_target() -> Result<()> {
1385 let platform = Platform {
1386 os: "linux".to_string(),
1387 arch: "x86_64".to_string(),
1388 };
1389 let targets = vec![
1390 ReleaseTarget {
1391 os: "windows".to_string(),
1392 arch: "x86_64".to_string(),
1393 url: "https://github.com/x.zip".to_string(),
1394 sha256: Some("a".repeat(64)),
1395 },
1396 ReleaseTarget {
1397 os: "linux".to_string(),
1398 arch: "x86_64".to_string(),
1399 url: "https://github.com/y.tar.gz".to_string(),
1400 sha256: Some("b".repeat(64)),
1401 },
1402 ];
1403 let selected = select_release_target(&targets, &platform)?;
1404 assert_eq!(selected.url, "https://github.com/y.tar.gz");
1405 Ok(())
1406 }
1407
1408 #[test]
1409 fn checksum_verification_reports_failure() {
1410 let err = verify_sha256(b"abc", &"0".repeat(64)).unwrap_err();
1411 assert!(format!("{err}").contains("sha256 mismatch"));
1412 }
1413
1414 #[test]
1415 fn resolve_token_prompts_when_missing_in_interactive_mode() -> Result<()> {
1416 let token = resolve_token_with(None, true, || Ok("secret-token".to_string()), "en")?;
1417 assert_eq!(token, "secret-token");
1418 Ok(())
1419 }
1420
1421 #[test]
1422 fn resolve_token_errors_when_missing_in_non_interactive_mode() {
1423 let err = resolve_token_with(None, false, || Ok("unused".to_string()), "en").unwrap_err();
1424 assert!(format!("{err}").contains("no interactive terminal"));
1425 }
1426
1427 #[test]
1428 fn extracts_tar_gz_binary() -> Result<()> {
1429 let temp = TempDir::new()?;
1430 let archive = tar_gz_with_binary("greentic-x", b"hello");
1431 let out = extract_tar_gz_binary(&archive, "greentic-x", temp.path())?;
1432 assert_eq!(out, temp.path().join("greentic-x"));
1433 assert_eq!(fs::read(&out)?, b"hello");
1434 Ok(())
1435 }
1436
1437 #[test]
1438 fn tenant_install_happy_path_writes_binary_doc_manifest_and_state() -> Result<()> {
1439 let temp = TempDir::new()?;
1440 let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1441 let sha = sha256_hex(&tool_archive);
1442 let tool_url =
1443 "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1444 let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1445 let manifest = expanded_manifest(tool_url, doc_url, &sha, "acme/onboarding/README.md");
1446
1447 let installer = Installer::new(
1448 FakeTenantManifestSource { manifest },
1449 FakeDownloader {
1450 responses: HashMap::from([
1451 (tool_url.to_string(), tool_archive.clone()),
1452 (doc_url.to_string(), b"# onboarding\n".to_vec()),
1453 ]),
1454 },
1455 test_env(&temp)?,
1456 );
1457 installer.install_tenant("acme", "secret-token")?;
1458
1459 assert_eq!(fs::read(temp.path().join("bin/greentic-x"))?, b"bin");
1460 assert_eq!(
1461 fs::read_to_string(temp.path().join("docs/acme/onboarding/README.md"))?,
1462 "# onboarding\n"
1463 );
1464 assert!(temp.path().join("manifests/tenant-acme.json").exists());
1465 assert!(temp.path().join("install/state.json").exists());
1466 Ok(())
1467 }
1468
1469 #[test]
1470 fn install_rejects_path_traversal_in_docs() -> Result<()> {
1471 let temp = TempDir::new()?;
1472 let archive = tar_gz_with_binary("greentic-x", b"bin");
1473 let sha = sha256_hex(&archive);
1474 let tool_url =
1475 "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1476 let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1477 let manifest = expanded_manifest(tool_url, doc_url, &sha, "../escape.md");
1478 let installer = Installer::new(
1479 FakeTenantManifestSource { manifest },
1480 FakeDownloader {
1481 responses: HashMap::from([
1482 (tool_url.to_string(), archive),
1483 (doc_url.to_string(), b"# onboarding\n".to_vec()),
1484 ]),
1485 },
1486 test_env(&temp)?,
1487 );
1488 let err = installer
1489 .install_tenant("acme", "secret-token")
1490 .unwrap_err();
1491 assert!(format!("{err}").contains("docs directory"));
1492 Ok(())
1493 }
1494
1495 #[test]
1496 fn archive_name_matching_handles_versioned_binaries() {
1497 assert!(archive_name_matches("greentic-x", "greentic-x"));
1498 assert!(archive_name_matches("greentic-x", "greentic-x-v1.2.3"));
1499 assert!(archive_name_matches("greentic-x.exe", "greentic-x.exe"));
1500 assert!(!archive_name_matches("greentic-x", "other-tool"));
1501 }
1502
1503 #[test]
1504 fn safe_archive_relative_path_rejects_escaping_paths() {
1505 assert_eq!(
1506 safe_archive_relative_path(Path::new("bin/greentic-x")),
1507 Some(PathBuf::from("bin/greentic-x"))
1508 );
1509 assert!(safe_archive_relative_path(Path::new("../escape")).is_none());
1510 assert!(safe_archive_relative_path(Path::new("/absolute")).is_none());
1511 }
1512
1513 #[test]
1514 fn github_url_enforcement_allows_github_and_localhost_only() {
1515 enforce_github_url("https://github.com/acme/project/releases/download/v1/tool.tgz")
1516 .unwrap();
1517 enforce_github_url("http://localhost:8080/test").unwrap();
1518
1519 let err = enforce_github_url("https://example.com/tool.tgz").unwrap_err();
1520 assert!(format!("{err}").contains("GitHub-hosted assets"));
1521 }
1522
1523 #[test]
1524 fn tenant_install_resolves_tool_and_doc_manifests_by_url() -> Result<()> {
1525 let temp = TempDir::new()?;
1526 let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1527 let sha = sha256_hex(&tool_archive);
1528 let tool_url =
1529 "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz";
1530 let doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1531 let tool_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/tools/greentic-x-cli/manifest.json";
1532 let doc_manifest_url = "https://raw.githubusercontent.com/greenticai/customers-tools/main/docs/acme-onboarding.json";
1533 let tenant_manifest = referenced_manifest(tool_manifest_url, doc_manifest_url);
1534 let tool_manifest = serde_json::to_vec(&TenantToolEntry {
1535 schema: Some(
1536 "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/tool.schema.json".to_string(),
1537 ),
1538 id: "greentic-x-cli".to_string(),
1539 name: "Greentic X CLI".to_string(),
1540 description: Some("CLI".to_string()),
1541 install: ToolInstall {
1542 install_type: "release-binary".to_string(),
1543 binary_name: "greentic-x".to_string(),
1544 targets: vec![ReleaseTarget {
1545 os: "linux".to_string(),
1546 arch: "x86_64".to_string(),
1547 url: tool_url.to_string(),
1548 sha256: Some(sha.clone()),
1549 }],
1550 },
1551 docs: vec!["acme-onboarding".to_string()],
1552 i18n: std::collections::BTreeMap::new(),
1553 })?;
1554 let doc_manifest = serde_json::to_vec(&TenantDocEntry {
1555 schema: Some(
1556 "https://raw.githubusercontent.com/greenticai/customers-tools/main/schemas/doc.schema.json".to_string(),
1557 ),
1558 id: "acme-onboarding".to_string(),
1559 title: "Acme onboarding".to_string(),
1560 source: DocSource {
1561 source_type: "download".to_string(),
1562 url: doc_url.to_string(),
1563 },
1564 download_file_name: "onboarding.md".to_string(),
1565 default_relative_path: "acme/onboarding/README.md".to_string(),
1566 i18n: std::collections::BTreeMap::new(),
1567 })?;
1568
1569 let installer = Installer::new(
1570 FakeTenantManifestSource {
1571 manifest: tenant_manifest,
1572 },
1573 FakeDownloader {
1574 responses: HashMap::from([
1575 (tool_manifest_url.to_string(), tool_manifest),
1576 (doc_manifest_url.to_string(), doc_manifest),
1577 (tool_url.to_string(), tool_archive),
1578 (doc_url.to_string(), b"# onboarding\n".to_vec()),
1579 ]),
1580 },
1581 test_env(&temp)?,
1582 );
1583 installer.install_tenant("acme", "secret-token")?;
1584 assert_eq!(fs::read(temp.path().join("bin/greentic-x"))?, b"bin");
1585 assert_eq!(
1586 fs::read_to_string(temp.path().join("docs/acme/onboarding/README.md"))?,
1587 "# onboarding\n"
1588 );
1589 Ok(())
1590 }
1591
1592 #[test]
1593 fn locale_uses_language_specific_doc_translation() -> Result<()> {
1594 let temp = TempDir::new()?;
1595 let tool_archive = tar_gz_with_binary("greentic-x", b"bin");
1596 let sha = sha256_hex(&tool_archive);
1597 let en_doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.md";
1598 let nl_doc_url = "https://raw.githubusercontent.com/acme/docs/main/onboarding.nl.md";
1599 let manifest = serde_json::to_vec(&TenantInstallManifest {
1600 schema: None,
1601 schema_version: "1".to_string(),
1602 tenant: "acme".to_string(),
1603 tools: vec![TenantToolDescriptor::Expanded(TenantToolEntry {
1604 schema: None,
1605 id: "greentic-x-cli".to_string(),
1606 name: "Greentic X CLI".to_string(),
1607 description: None,
1608 install: ToolInstall {
1609 install_type: "release-binary".to_string(),
1610 binary_name: "greentic-x".to_string(),
1611 targets: vec![ReleaseTarget {
1612 os: "linux".to_string(),
1613 arch: "x86_64".to_string(),
1614 url: "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz".to_string(),
1615 sha256: Some(sha),
1616 }],
1617 },
1618 docs: vec!["acme-onboarding".to_string()],
1619 i18n: std::collections::BTreeMap::new(),
1620 })],
1621 docs: vec![TenantDocDescriptor::Expanded(TenantDocEntry {
1622 schema: None,
1623 id: "acme-onboarding".to_string(),
1624 title: "Acme onboarding".to_string(),
1625 source: DocSource {
1626 source_type: "download".to_string(),
1627 url: en_doc_url.to_string(),
1628 },
1629 download_file_name: "onboarding.md".to_string(),
1630 default_relative_path: "acme/onboarding/README.md".to_string(),
1631 i18n: std::collections::BTreeMap::from([(
1632 "nl".to_string(),
1633 DocTranslation {
1634 title: Some("Acme onboarding NL".to_string()),
1635 download_file_name: Some("onboarding.nl.md".to_string()),
1636 default_relative_path: Some("acme/onboarding/README.nl.md".to_string()),
1637 source: Some(DocSource {
1638 source_type: "download".to_string(),
1639 url: nl_doc_url.to_string(),
1640 }),
1641 },
1642 )]),
1643 })],
1644 })?;
1645 let mut env = test_env(&temp)?;
1646 env.locale = "nl".to_string();
1647 let installer = Installer::new(
1648 FakeTenantManifestSource { manifest },
1649 FakeDownloader {
1650 responses: HashMap::from([
1651 (
1652 "https://github.com/acme/releases/download/v1.2.3/greentic-x-linux-x86_64.tar.gz".to_string(),
1653 tool_archive,
1654 ),
1655 (en_doc_url.to_string(), b"# onboarding en\n".to_vec()),
1656 (nl_doc_url.to_string(), b"# onboarding nl\n".to_vec()),
1657 ]),
1658 },
1659 env,
1660 );
1661 installer.install_tenant("acme", "secret-token")?;
1662 assert_eq!(
1663 fs::read_to_string(temp.path().join("docs/acme/onboarding/README.nl.md"))?,
1664 "# onboarding nl\n"
1665 );
1666 Ok(())
1667 }
1668
1669 #[test]
1670 fn tenant_install_accepts_simple_manifest_shape() -> Result<()> {
1671 let temp = TempDir::new()?;
1672 let tool_archive = tar_gz_with_binary("greentic-fast2flow", b"bin");
1673 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";
1674 let doc_url =
1675 "https://raw.githubusercontent.com/greentic-biz/greentic-fast2flow/master/README.md";
1676 let manifest = serde_json::to_vec(&TenantInstallManifest {
1677 schema: None,
1678 schema_version: "1".to_string(),
1679 tenant: "3point".to_string(),
1680 tools: vec![TenantToolDescriptor::Simple(SimpleTenantToolEntry {
1681 id: "greentic-fast2flow".to_string(),
1682 binary_name: None,
1683 targets: vec![ReleaseTarget {
1684 os: "linux".to_string(),
1685 arch: "x86_64".to_string(),
1686 url: tool_url.to_string(),
1687 sha256: None,
1688 }],
1689 })],
1690 docs: vec![TenantDocDescriptor::Simple(SimpleTenantDocEntry {
1691 url: doc_url.to_string(),
1692 file_name: "greentic-fast2flow-guide.md".to_string(),
1693 })],
1694 })?;
1695 let installer = Installer::new(
1696 FakeTenantManifestSource { manifest },
1697 FakeDownloader {
1698 responses: HashMap::from([
1699 (tool_url.to_string(), tool_archive),
1700 (doc_url.to_string(), b"# fast2flow\n".to_vec()),
1701 ]),
1702 },
1703 test_env(&temp)?,
1704 );
1705 installer.install_tenant("3point", "secret-token")?;
1706 assert_eq!(
1707 fs::read(temp.path().join("bin/greentic-fast2flow"))?,
1708 b"bin"
1709 );
1710 assert_eq!(
1711 fs::read_to_string(temp.path().join("docs/greentic-fast2flow-guide.md"))?,
1712 "# fast2flow\n"
1713 );
1714 Ok(())
1715 }
1716
1717 #[test]
1718 fn expected_binary_name_strips_release_target_and_version() {
1719 let name = expected_binary_name(
1720 "greentic-fast2flow",
1721 "https://github.com/greentic-biz/greentic-fast2flow/releases/download/v0.4.1/greentic-fast2flow-v0.4.1-x86_64-unknown-linux-gnu.tar.gz",
1722 );
1723 assert_eq!(name, "greentic-fast2flow");
1724 }
1725
1726 #[test]
1727 fn extracts_tar_gz_binary_with_versioned_entry_name() -> Result<()> {
1728 let temp = TempDir::new()?;
1729 let archive = tar_gz_with_binary(
1730 "greentic-mcp-generator-0.4.14-x86_64-unknown-linux-gnu",
1731 b"bin",
1732 );
1733 let out = extract_tar_gz_binary(&archive, "greentic-mcp-generator", temp.path())?;
1734 assert_eq!(
1735 out,
1736 temp.path()
1737 .join("greentic-mcp-generator-0.4.14-x86_64-unknown-linux-gnu")
1738 );
1739 assert_eq!(fs::read(out)?, b"bin");
1740 Ok(())
1741 }
1742
1743 #[test]
1744 fn extracts_tar_gz_binary_even_when_archive_name_differs() -> Result<()> {
1745 let temp = TempDir::new()?;
1746 let archive = tar_gz_with_binary("greentic-mcp-gen", b"bin");
1747 let out = extract_tar_gz_binary(&archive, "greentic-mcp-generator", temp.path())?;
1748 assert_eq!(out, temp.path().join("greentic-mcp-gen"));
1749 assert_eq!(fs::read(out)?, b"bin");
1750 Ok(())
1751 }
1752}