1use std::collections::HashMap;
8use std::fs;
9use std::io::Read;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Result, anyhow};
13use colored::Colorize;
14use comfy_table::Table;
15use comfy_table::presets::UTF8_FULL;
16use dialoguer::Select;
17use dialoguer::theme::ColorfulTheme;
18use indicatif::{ProgressBar, ProgressStyle};
19use regex::Regex;
20use sha2::{Digest, Sha256};
21use walkdir::WalkDir;
22use zoi_core::types::SourceType;
23use zoi_core::{cache, config, pin, types};
24
25#[derive(Debug)]
27pub struct ResolvedSource {
28 pub path: PathBuf,
30 pub source_type: SourceType,
32 pub repo_name: Option<String>,
34 pub repo_type: Option<String>,
36 pub registry_handle: Option<String>,
38 pub sharable_manifest: Option<types::SharableInstallManifest>,
40 pub git_sha: Option<String>
42}
43
44#[derive(Debug, Default)]
46pub struct PackageRequest {
47 pub handle: Option<String>,
49 pub repo: Option<String>,
51 pub name: String,
53 pub sub_package: Option<String>,
55 pub version_spec: Option<String>
57}
58
59use std::sync::{LazyLock, Mutex};
60
61static HANDLE_RE: LazyLock<Regex> = LazyLock::new(|| {
63 Regex::new(r"^(?:#(?P<handle>[^@]+))?(?P<main_part>.*)$")
64 .expect("Static HANDLE_RE regex is valid")
65});
66
67static MAIN_RE: LazyLock<Regex> = LazyLock::new(|| {
70 Regex::new(r"^@?(?P<repo_and_name>[^@]+)(?:@(?P<version>.+))?$")
71 .expect("Static MAIN_RE regex is valid")
72});
73
74static CONFIRMED_UNTRUSTED_SOURCES: LazyLock<
77 Mutex<std::collections::HashSet<String>>
78> = LazyLock::new(|| Mutex::new(std::collections::HashSet::new()));
79
80fn split_explicit_file_source(
82 source_str: &str
83) -> Option<(&str, Option<String>, Option<String>)> {
84 let (main_part, version_spec) =
85 if let Some((base, version)) = source_str.rsplit_once('@') {
86 let base_path = if let Some((path, sub)) = base.rsplit_once(':') {
87 if (path.ends_with(".pkg.lua")
88 || path.ends_with(".manifest.yaml")
89 || std::path::Path::new(path)
90 .extension()
91 .is_some_and(|ext| ext.eq_ignore_ascii_case("zpa"))
92 || std::path::Path::new(path)
93 .extension()
94 .is_some_and(|ext| ext.eq_ignore_ascii_case("zsa")))
95 && !sub.contains('/')
96 {
97 path
98 } else {
99 base
100 }
101 } else {
102 base
103 };
104
105 if base_path.ends_with(".pkg.lua")
106 || base_path.ends_with(".manifest.yaml")
107 || std::path::Path::new(base_path)
108 .extension()
109 .is_some_and(|ext| ext.eq_ignore_ascii_case("zpa"))
110 || std::path::Path::new(base_path)
111 .extension()
112 .is_some_and(|ext| ext.eq_ignore_ascii_case("zsa"))
113 {
114 (base, Some(version.to_string()))
115 } else {
116 (source_str, None)
117 }
118 } else {
119 (source_str, None)
120 };
121
122 let (path_part, sub_package) =
123 if let Some((base, sub)) = main_part.rsplit_once(':') {
124 if (base.ends_with(".pkg.lua")
125 || base.ends_with(".manifest.yaml")
126 || std::path::Path::new(base)
127 .extension()
128 .is_some_and(|ext| ext.eq_ignore_ascii_case("zpa"))
129 || std::path::Path::new(base)
130 .extension()
131 .is_some_and(|ext| ext.eq_ignore_ascii_case("zsa")))
132 && !sub.contains('/')
133 {
134 (base, Some(sub.to_string()))
135 } else {
136 (main_part, None)
137 }
138 } else {
139 (main_part, None)
140 };
141
142 if path_part.ends_with(".pkg.lua")
143 || path_part.ends_with(".manifest.yaml")
144 || std::path::Path::new(path_part)
145 .extension()
146 .is_some_and(|ext| ext.eq_ignore_ascii_case("zpa"))
147 || std::path::Path::new(path_part)
148 .extension()
149 .is_some_and(|ext| ext.eq_ignore_ascii_case("zsa"))
150 {
151 Some((path_part, sub_package, version_spec))
152 } else {
153 None
154 }
155}
156
157fn download_source_for_explicit_path<'a>(
159 source: &'a str,
160 path_part: Option<&'a str>
161) -> &'a str {
162 path_part.unwrap_or(source)
163}
164
165fn get_git_head_sha(repo_path: &Path) -> Option<String> {
167 let repo = git2::Repository::open(repo_path).ok()?;
168 let head = repo.head().ok()?;
169 let target = head.target()?;
170 Some(target.to_string())
171}
172
173pub fn get_db_root() -> Result<PathBuf> {
179 if let Ok(path) = std::env::var("ZOI_DB_DIR") {
180 return Ok(PathBuf::from(path));
181 }
182
183 let local_db = std::env::current_dir()?
184 .join(".zoi")
185 .join("pkgs")
186 .join("db");
187 if local_db.exists() {
188 return Ok(local_db);
189 }
190
191 zoi_core::utils::get_db_base_dir(zoi_core::types::Scope::User)
193}
194
195pub fn get_host_db_root() -> Result<PathBuf> {
201 zoi_core::utils::get_db_base_dir(zoi_core::types::Scope::User)
202}
203
204pub fn parse_source_string(source_str: &str) -> Result<PackageRequest> {
210 if let Some((path_part, sub_package_from_path, version_spec)) =
211 split_explicit_file_source(source_str)
212 {
213 let path = std::path::Path::new(path_part);
214 let file_stem = path.file_stem().unwrap_or_default().to_string_lossy();
215 let name = if let Some(stripped) = file_stem.strip_suffix(".manifest") {
216 stripped.to_string()
217 } else if let Some(stripped) = file_stem.strip_suffix(".pkg") {
218 stripped.to_string()
219 } else {
220 file_stem.to_string()
221 };
222 return Ok(PackageRequest {
223 handle: None,
224 repo: None,
225 name,
226 sub_package: sub_package_from_path,
227 version_spec
228 });
229 }
230
231 let caps = HANDLE_RE
232 .captures(source_str)
233 .ok_or_else(|| anyhow!("Invalid source string format"))?;
234 let handle = caps.name("handle").map(|m| m.as_str().to_string());
235 let main_part = caps
236 .name("main_part")
237 .ok_or_else(|| {
238 anyhow!(
239 "Regex matched but main_part group not found in '{source_str}'"
240 )
241 })?
242 .as_str();
243
244 let caps_main = MAIN_RE.captures(main_part).ok_or_else(|| {
245 anyhow!("Invalid source string format in '{main_part}'")
246 })?;
247
248 let repo_and_name = caps_main
249 .name("repo_and_name")
250 .ok_or_else(|| {
251 anyhow!(
252 "Regex matched but repo_and_name group not found in \
253 '{main_part}'"
254 )
255 })?
256 .as_str();
257 let version_spec =
258 caps_main.name("version").map(|m| m.as_str().to_string());
259
260 let (repo, name_and_sub) = if main_part.starts_with('@') {
261 if let Some(slash_pos) = repo_and_name.find('/') {
262 let (repo_str, name_str) = repo_and_name.split_at(slash_pos);
263 (Some(repo_str.to_lowercase()), &name_str[1..])
264 } else {
265 return Err(anyhow!("Invalid repo format: expected @repo/name"));
266 }
267 } else {
268 (None, repo_and_name)
269 };
270
271 let (name, sub_package) =
272 if let Some((n, s)) = name_and_sub.rsplit_once(':') {
273 (n, Some(s.to_string()))
274 } else {
275 (name_and_sub, None)
276 };
277
278 if name.is_empty() {
279 return Err(anyhow!("Invalid source string: package name is empty."));
280 }
281
282 Ok(PackageRequest {
283 handle,
284 repo,
285 name: name.to_lowercase(),
286 sub_package,
287 version_spec
288 })
289}
290
291fn find_package_in_db(
293 request: &PackageRequest,
294 quiet: bool
295) -> Result<ResolvedSource> {
296 struct FoundPackage {
299 path: PathBuf,
300 source_type: SourceType,
301 repo_name: String,
302 repo_type: String,
303 description: String,
304 license: String,
305 size: Option<u64>
306 }
307
308 fn process_found_package(
310 path: PathBuf,
311 repo_name: &str,
312 is_default_registry: bool,
313 registry_db_path: &Path,
314 quiet: bool
315 ) -> Result<FoundPackage> {
316 let pkg: types::Package = zoi_lua::parser::parse_lua_package(
317 path.to_str().ok_or_else(|| {
318 anyhow!(
319 "Path contains invalid UTF-8 characters: {}",
320 path.display()
321 )
322 })?,
323 None,
324 None,
325 quiet
326 )?;
327 let major_repo = repo_name
328 .split('/')
329 .next()
330 .unwrap_or_default()
331 .to_lowercase();
332
333 let repo_config = config::read_repo_config(registry_db_path).ok();
334 let repo_type = if let Some(ref cfg) = repo_config {
335 cfg.repos.iter().find(|r| r.name == major_repo).map_or_else(
336 || "unofficial".to_string(),
337 |r| r.repo_type.clone()
338 )
339 } else {
340 "unofficial".to_string()
341 };
342
343 let source_type = if is_default_registry && repo_type == "official" {
344 SourceType::OfficialRepo
345 } else {
346 SourceType::UntrustedRepo(repo_name.to_string())
347 };
348
349 Ok(FoundPackage {
350 path,
351 source_type,
352 repo_name: pkg.repo.clone(),
353 repo_type,
354 description: pkg.description,
355 license: pkg.license,
356 size: pkg.installed_size
357 })
358 }
359
360 let db_root = get_db_root()?;
361 let config = config::read_config()?;
362
363 let (registry_db_path, search_repos, is_default_registry, registry_handle) =
364 if let Some(h) = &request.handle {
365 let is_default = config
366 .default_registry
367 .as_ref()
368 .is_some_and(|reg| reg.handle == *h);
369
370 if is_default {
371 let default_registry = config
372 .default_registry
373 .as_ref()
374 .ok_or_else(|| anyhow!("Default registry not found"))?;
375
376 let mut default_path = db_root.join(&default_registry.handle);
377 if !default_path.exists()
378 && zoi_core::sysroot::get_sysroot().is_some()
379 {
380 let sys_root = zoi_core::utils::get_db_base_dir(
383 zoi_core::types::Scope::System
384 )?;
385 let sys_path = sys_root.join(&default_registry.handle);
386 if sys_path.exists() {
387 default_path = sys_path;
388 }
389 }
390
391 (
392 default_path,
393 config.repos,
394 true,
395 Some(default_registry.handle.clone())
396 )
397 } else if let Some(registry) =
398 config.added_registries.iter().find(|r| r.handle == *h)
399 {
400 let mut repo_path = db_root.join(®istry.handle);
401
402 if !repo_path.exists()
403 && zoi_core::sysroot::get_sysroot().is_some()
404 {
405 let sys_root = zoi_core::utils::get_db_base_dir(
409 zoi_core::types::Scope::System
410 )?;
411 let sys_path = sys_root.join(®istry.handle);
412 if sys_path.exists() {
413 repo_path = sys_path;
414 } else if let Ok(host_root) = get_host_db_root() {
415 let host_path = host_root.join(®istry.handle);
416 if host_path.exists() {
417 repo_path = host_path;
418 }
419 }
420 }
421
422 let all_sub_repos = if repo_path.exists() {
423 fs::read_dir(&repo_path)?
424 .filter_map(Result::ok)
425 .filter(|entry| {
426 entry.path().is_dir() && entry.file_name() != ".git"
427 })
428 .map(|entry| {
429 entry.file_name().to_string_lossy().into_owned()
430 })
431 .collect()
432 } else {
433 Vec::new()
434 };
435 (
436 repo_path,
437 all_sub_repos,
438 false,
439 Some(registry.handle.clone())
440 )
441 } else {
442 if let Some(builtin) = zoi_core::builtin::registry::get(h) {
446 return Err(anyhow!(
447 "This package is from registry '{}' which you haven't \
448 added. Run 'zoi sync add {}' to add it.",
449 builtin.handle,
450 builtin.handle
451 ));
452 }
453 return Err(anyhow!("Registry with handle '{h}' not found."));
454 }
455 } else {
456 let default_registry = config
457 .default_registry
458 .as_ref()
459 .ok_or_else(|| anyhow!("No default registry set."))?;
460
461 let default_handle = default_registry.handle.clone();
462 let mut default_path = db_root.join(&default_handle);
463
464 if !default_path.exists()
465 && zoi_core::sysroot::get_sysroot().is_some()
466 {
467 let host_root = get_host_db_root()?;
469 let host_path = host_root.join(&default_handle);
470 if host_path.exists() {
471 default_path = host_path;
472 }
473 }
474
475 let (registry_path, effective_handle) = if default_path.exists()
476 && (default_path.join("repo.yaml").exists()
477 || default_path.join("packages.json").exists())
478 {
479 (default_path, default_handle)
480 } else {
481 let mut found_path = default_path.clone();
482 let mut found_handle = default_handle.clone();
483 let mut found = false;
484
485 let roots_to_check =
486 if zoi_core::sysroot::get_sysroot().is_some() {
487 let mut roots = vec![
491 db_root.clone(),
492 get_host_db_root()?,
493 zoi_core::utils::get_db_base_dir(
494 zoi_core::types::Scope::System
495 )?,
496 ];
497 roots.dedup();
498 roots
499 } else {
500 vec![db_root.clone()]
501 };
502
503 for root in roots_to_check {
504 if let Ok(entries) = fs::read_dir(&root) {
505 for entry in entries.flatten() {
506 let path = entry.path();
507 if !path.is_dir() {
508 continue;
509 }
510 let name = entry.file_name();
511 if name == ".git" {
512 continue;
513 }
514 let candidate = name.to_string_lossy().to_string();
515 let candidate_path = root.join(&candidate);
516 if candidate_path.join("repo.yaml").exists()
517 || candidate_path.join("packages.json").exists()
518 {
519 found_path = candidate_path;
520 found_handle = candidate;
521 found = true;
522 break;
523 }
524 }
525 }
526 if found {
527 break;
528 }
529 }
530
531 if !found {
532 return Err(anyhow!(
533 "No synced registries found. Please run 'zoi sync' to \
534 download the package database."
535 ));
536 }
537 (found_path, found_handle)
538 };
539
540 (registry_path, config.repos, true, Some(effective_handle))
541 };
542
543 if !registry_db_path.exists() {
544 return Err(anyhow!(
545 "Registry '{}' is not synced. Please run 'zoi sync' to download \
546 the package database.",
547 registry_handle.unwrap_or_else(|| "default".to_string())
548 ));
549 }
550
551 let repos_to_search = if let Some(r) = &request.repo {
552 vec![r.clone()]
553 } else {
554 search_repos
555 };
556
557 let mut found_packages = Vec::new();
558
559 if request.name.contains('/') {
560 let pkg_name = Path::new(&request.name)
561 .file_name()
562 .and_then(|s| s.to_str())
563 .ok_or_else(|| anyhow!("Invalid package path: {}", request.name))?;
564
565 for repo_name in &repos_to_search {
566 let path = registry_db_path
567 .join(repo_name)
568 .join(&request.name)
569 .join(format!("{pkg_name}.pkg.lua"));
570
571 if path.exists()
572 && let Ok(found) = process_found_package(
573 path,
574 repo_name,
575 is_default_registry,
576 ®istry_db_path,
577 quiet
578 )
579 {
580 found_packages.push(found);
581 }
582 }
583 } else {
584 for repo_name in &repos_to_search {
585 let pkg_dir_path =
586 registry_db_path.join(repo_name).join(&request.name);
587 let pkg_file_path =
588 pkg_dir_path.join(format!("{}.pkg.lua", request.name));
589
590 if pkg_file_path.exists()
591 && let Ok(found) = process_found_package(
592 pkg_file_path,
593 repo_name,
594 is_default_registry,
595 ®istry_db_path,
596 quiet
597 )
598 {
599 found_packages.push(found);
600 }
601 }
602 }
603
604 if found_packages.is_empty() {
605 for repo_name in &repos_to_search {
606 let repo_path = registry_db_path.join(repo_name);
607 if !repo_path.is_dir() {
608 continue;
609 }
610 for entry in WalkDir::new(&repo_path)
611 .into_iter()
612 .filter_map(std::result::Result::ok)
613 .filter(|e| {
614 e.file_type().is_file()
615 && e.file_name().to_string_lossy().ends_with(".pkg.lua")
616 })
617 {
618 if let Ok(pkg) = zoi_lua::parser::parse_lua_package(
619 entry.path().to_str().ok_or_else(|| {
620 anyhow!(
621 "Path contains invalid UTF-8 characters: {}",
622 entry.path().display()
623 )
624 })?,
625 None,
626 None,
627 true
628 ) && let Some(provides) = &pkg.provides
629 && provides.iter().any(|p| p == &request.name)
630 {
631 let major_repo = repo_name
632 .split('/')
633 .next()
634 .unwrap_or_default()
635 .to_lowercase();
636 let repo_config =
637 config::read_repo_config(®istry_db_path).ok();
638 let repo_type = if let Some(ref cfg) = repo_config {
639 cfg.repos
640 .iter()
641 .find(|r| r.name == major_repo)
642 .map_or_else(
643 || "unofficial".to_string(),
644 |r| r.repo_type.clone()
645 )
646 } else {
647 "unofficial".to_string()
648 };
649 let source_type =
650 if is_default_registry && repo_type == "official" {
651 SourceType::OfficialRepo
652 } else {
653 SourceType::UntrustedRepo(repo_name.clone())
654 };
655 found_packages.push(FoundPackage {
656 path: entry.path().to_path_buf(),
657 source_type,
658 repo_name: pkg.repo.clone(),
659 repo_type,
660 description: pkg.description,
661 license: pkg.license,
662 size: pkg.installed_size
663 });
664 }
665 }
666 }
667 }
668
669 if found_packages.is_empty() {
670 if let Some(repo) = &request.repo {
671 Err(anyhow!(
672 "Package '{}' not found in repository '@{}'.",
673 request.name,
674 repo
675 ))
676 } else {
677 Err(anyhow!(
678 "Package '{}' not found in any active repositories.",
679 request.name
680 ))
681 }
682 } else if found_packages.len() == 1 {
683 let chosen = found_packages.first().ok_or_else(|| {
684 anyhow!("Found packages list is unexpectedly empty")
685 })?;
686
687 Ok(ResolvedSource {
688 path: chosen.path.clone(),
689 source_type: chosen.source_type.clone(),
690 repo_name: Some(chosen.repo_name.clone()),
691 repo_type: Some(chosen.repo_type.clone()),
692 registry_handle: registry_handle.clone(),
693 sharable_manifest: None,
694 git_sha: None
695 })
696 } else {
697 println!(
698 "Found multiple packages named or providing '{}'. Please choose \
699 one:",
700 request.name.cyan()
701 );
702
703 let mut table = Table::new();
704 table.load_style(UTF8_FULL);
705 table.set_header(vec!["#", "Repo", "License", "Size", "Description"]);
706
707 for (i, p) in found_packages.iter().enumerate() {
708 table.add_row(vec![
709 (i + 1).to_string(),
710 p.repo_name.clone(),
711 p.license.clone(),
712 p.size.map_or_else(
713 || "unknown".to_string(),
714 zoi_core::utils::format_bytes
715 ),
716 p.description.clone(),
717 ]);
718 }
719 println!("{table}");
720
721 let items: Vec<String> = found_packages
722 .iter()
723 .map(|p| format!("@{}", p.repo_name.bold()))
724 .collect();
725
726 let selection = Select::with_theme(&ColorfulTheme::default())
727 .with_prompt("Select a provider")
728 .items(&items)
729 .default(0)
730 .interact()?;
731
732 let chosen = found_packages
733 .get(selection)
734 .ok_or_else(|| anyhow!("Invalid selection"))?;
735 println!(
736 "Selected package '{}' from repo '{}'",
737 request.name, chosen.repo_name
738 );
739
740 Ok(ResolvedSource {
741 path: chosen.path.clone(),
742 source_type: chosen.source_type.clone(),
743 repo_name: Some(chosen.repo_name.clone()),
744 repo_type: Some(chosen.repo_type.clone()),
745 registry_handle: registry_handle.clone(),
746 sharable_manifest: None,
747 git_sha: None
748 })
749 }
750}
751
752fn download_from_url(url: &str) -> Result<ResolvedSource> {
754 let (base_url, expected_hash) = if let Some((base, hash_part)) =
755 url.split_once('#')
756 {
757 if hash_part.starts_with("sha256-") || hash_part.starts_with("sha512-")
758 {
759 (base, Some(hash_part))
760 } else {
761 (url, None)
762 }
763 } else {
764 (url, None)
765 };
766
767 let cache_dir = cache::get_pkgdef_cache_root()?;
768 fs::create_dir_all(&cache_dir)?;
769
770 let mut hasher = Sha256::new();
771 hasher.update(base_url.as_bytes());
772 let url_hash = hex::encode(hasher.finalize());
773 let cache_path = cache_dir.join(format!("{url_hash}.pkg.lua"));
774
775 if cache_path.exists() {
776 if let Some(hash) = expected_hash {
777 let mut file = fs::File::open(&cache_path)?;
778 let mut content = Vec::new();
779 file.read_to_end(&mut content)?;
780 if verify_content_hash(&content, hash)? {
781 return Ok(ResolvedSource {
782 path: cache_path,
783 source_type: SourceType::Url,
784 repo_name: None,
785 repo_type: None,
786 registry_handle: Some("local".to_string()),
787 sharable_manifest: None,
788 git_sha: None
789 });
790 }
791 println!("Cached definition hash mismatch, re-downloading...");
792 fs::remove_file(&cache_path)?;
793 } else {
794 return Ok(ResolvedSource {
795 path: cache_path,
796 source_type: SourceType::Url,
797 repo_name: None,
798 repo_type: None,
799 registry_handle: Some("local".to_string()),
800 sharable_manifest: None,
801 git_sha: None
802 });
803 }
804 }
805
806 println!("Downloading package definition from URL...");
807 let client = zoi_core::utils::get_http_client()?;
808 let mut attempt = 0u32;
809 let mut response = loop {
810 attempt += 1;
811 match client.get(base_url).send() {
812 Ok(resp) => break resp,
813 Err(e) => {
814 if attempt < 3 {
815 eprintln!(
816 "{}: download failed ({}). Retrying...",
817 "Network".yellow(),
818 e
819 );
820 zoi_core::utils::retry_backoff_sleep(attempt);
821 continue;
822 }
823 return Err(anyhow!(
824 "Failed to download file after {attempt} attempts: {e}"
825 ));
826 }
827 }
828 };
829 if !response.status().is_success() {
830 return Err(anyhow!(
831 "Failed to download file (HTTP {}): {}",
832 response.status(),
833 base_url
834 ));
835 }
836
837 let total_size = response.content_length().unwrap_or(0);
838 let pb = ProgressBar::new(total_size);
839 pb.set_style(
840 ProgressStyle::default_bar()
841 .template(
842 "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \
843 {bytes}/{total_bytes} ({bytes_per_sec})"
844 )?
845 .progress_chars("#>-")
846 );
847
848 let mut downloaded_bytes = Vec::new();
849 let mut buffer = [0; 8192];
850 loop {
851 let bytes_read = response.read(&mut buffer)?;
852 if bytes_read == 0 {
853 break;
854 }
855 downloaded_bytes.extend_from_slice(
856 buffer
857 .get(..bytes_read)
858 .ok_or_else(|| anyhow!("Buffer slice out of bounds"))?
859 );
860 pb.inc(bytes_read as u64);
861 }
862 pb.finish_with_message("Download complete.");
863
864 if let Some(hash) = expected_hash {
865 if !verify_content_hash(&downloaded_bytes, hash)? {
866 return Err(anyhow!(
867 "Integrity verification failed for remote package definition."
868 ));
869 }
870 println!("{} Integrity verified.", "::".green());
871 }
872
873 fs::write(&cache_path, &downloaded_bytes)?;
874
875 Ok(ResolvedSource {
876 path: cache_path,
877 source_type: SourceType::Url,
878 repo_name: None,
879 repo_type: None,
880 registry_handle: Some("local".to_string()),
881 sharable_manifest: None,
882 git_sha: None
883 })
884}
885
886fn verify_content_hash(content: &[u8], hash_spec: &str) -> Result<bool> {
888 let (algo, expected_hex) = hash_spec
889 .split_once('-')
890 .ok_or_else(|| anyhow!("Invalid hash format"))?;
891 let actual_hex = match algo {
892 "sha256" => {
893 let mut hasher = Sha256::new();
894 hasher.update(content);
895 hex::encode(hasher.finalize())
896 }
897 "sha512" => {
898 let mut hasher = sha2::Sha512::new();
899 hasher.update(content);
900 hex::encode(hasher.finalize())
901 }
902 _ => return Err(anyhow!("Unsupported hash algorithm: {algo}"))
903 };
904
905 Ok(actual_hex.eq_ignore_ascii_case(expected_hex))
906}
907
908fn download_content_from_url(url: &str) -> Result<String> {
910 println!("Downloading from: {}", url.cyan());
911 let client = zoi_core::utils::get_http_client()?;
912 let mut attempt = 0u32;
913 let response = loop {
914 attempt += 1;
915 match client.get(url).send() {
916 Ok(resp) => break resp,
917 Err(e) => {
918 if attempt < 3 {
919 eprintln!(
920 "{}: download failed ({}). Retrying...",
921 "Network".yellow(),
922 e
923 );
924 zoi_core::utils::retry_backoff_sleep(attempt);
925 continue;
926 }
927 return Err(anyhow!(
928 "Failed to download from {url} after {attempt} attempts: \
929 {e}"
930 ));
931 }
932 }
933 };
934
935 if !response.status().is_success() {
936 return Err(anyhow!(
937 "Failed to download from {} (HTTP {}). Content: {}",
938 url,
939 response.status(),
940 response
941 .text()
942 .unwrap_or_else(|_| "Could not read response body".to_string())
943 ));
944 }
945
946 Ok(response.text()?)
947}
948
949pub fn resolve_version_from_url(url: &str, channel: &str) -> Result<String> {
955 println!(
956 "Resolving version for channel '{}' from {}",
957 channel.cyan(),
958 url.cyan()
959 );
960 let client = zoi_core::utils::get_http_client()?;
961 let mut attempt = 0u32;
962 let resp = loop {
963 attempt += 1;
964 match client.get(url).send() {
965 Ok(r) => match r.text() {
966 Ok(t) => break t,
967 Err(e) => {
968 if attempt < 3 {
969 eprintln!(
970 "{}: read failed ({}). Retrying...",
971 "Network".yellow(),
972 e
973 );
974 zoi_core::utils::retry_backoff_sleep(attempt);
975 continue;
976 }
977 return Err(anyhow!(
978 "Failed to read response after {attempt} attempts: {e}"
979 ));
980 }
981 },
982 Err(e) => {
983 if attempt < 3 {
984 eprintln!(
985 "{}: fetch failed ({}). Retrying...",
986 "Network".yellow(),
987 e
988 );
989 zoi_core::utils::retry_backoff_sleep(attempt);
990 continue;
991 }
992 return Err(anyhow!(
993 "Failed to fetch after {attempt} attempts: {e}"
994 ));
995 }
996 }
997 };
998 let json: serde_json::Value = serde_json::from_str(&resp)?;
999
1000 if let Some(version) = json
1001 .get("versions")
1002 .and_then(|v| v.get(channel))
1003 .and_then(|c| c.as_str())
1004 {
1005 return Ok(version.to_string());
1006 }
1007
1008 Err(anyhow!(
1009 "Failed to extract version for channel '{channel}' from JSON URL: \
1010 {url}"
1011 ))
1012}
1013
1014pub fn resolve_channel<S: ::std::hash::BuildHasher>(
1020 versions: &HashMap<String, String, S>,
1021 channel: &str
1022) -> Result<String> {
1023 if let Some(url_or_version) = versions.get(channel) {
1024 if url_or_version.starts_with("http") {
1025 resolve_version_from_url(url_or_version, channel)
1026 } else {
1027 Ok(url_or_version.clone())
1028 }
1029 } else {
1030 Err(anyhow!("Channel '@{channel}' not found in versions map."))
1031 }
1032}
1033
1034pub fn get_default_version(
1040 pkg: &types::Package,
1041 registry_handle: Option<&str>
1042) -> Result<String> {
1043 if let Some(handle) = registry_handle {
1044 let source = format!("#{}@{}", handle, pkg.repo);
1045
1046 if let Some(pinned_version) = pin::get_pinned_version(&source)? {
1047 println!(
1048 "Using pinned version '{}' for {}.",
1049 pinned_version.yellow(),
1050 source.cyan()
1051 );
1052 return if pinned_version.starts_with('@') {
1053 let channel = pinned_version.trim_start_matches('@');
1054 let versions = pkg.versions.as_ref().ok_or_else(|| {
1055 anyhow!(
1056 "Package '{}' has no 'versions' map to resolve pinned \
1057 channel '{}'.",
1058 pkg.name,
1059 pinned_version
1060 )
1061 })?;
1062 resolve_channel(versions, channel)
1063 } else {
1064 Ok(pinned_version)
1065 };
1066 }
1067 }
1068
1069 if let Some(versions) = &pkg.versions {
1070 if versions.contains_key("stable") {
1071 return resolve_channel(versions, "stable");
1072 }
1073 let mut channels: Vec<_> = versions.keys().collect();
1074 channels.sort();
1075 if let Some(channel) = channels.first() {
1076 println!(
1077 "No 'stable' channel found, using first available channel: \
1078 '@{}'",
1079 channel.cyan()
1080 );
1081 return resolve_channel(versions, channel);
1082 }
1083 return Err(anyhow!(
1084 "Package has a 'versions' map but no versions were found in it."
1085 ));
1086 }
1087
1088 if let Some(ver) = &pkg.version {
1089 if ver.starts_with("http") {
1090 let client = zoi_core::utils::get_http_client()?;
1091 let mut attempt = 0u32;
1092 let resp = loop {
1093 attempt += 1;
1094 match client.get(ver).send() {
1095 Ok(r) => match r.text() {
1096 Ok(t) => break t,
1097 Err(e) => {
1098 if attempt < 3 {
1099 eprintln!(
1100 "{}: read failed ({}). Retrying...",
1101 "Network".yellow(),
1102 e
1103 );
1104 zoi_core::utils::retry_backoff_sleep(attempt);
1105 continue;
1106 }
1107 return Err(anyhow!(
1108 "Failed to read response after {attempt} \
1109 attempts: {e}"
1110 ));
1111 }
1112 },
1113 Err(e) => {
1114 if attempt < 3 {
1115 eprintln!(
1116 "{}: fetch failed ({}). Retrying...",
1117 "Network".yellow(),
1118 e
1119 );
1120 zoi_core::utils::retry_backoff_sleep(attempt);
1121 continue;
1122 }
1123 return Err(anyhow!(
1124 "Failed to fetch after {attempt} attempts: {e}"
1125 ));
1126 }
1127 }
1128 };
1129 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&resp) {
1130 if let Some(version) = json
1131 .get("versions")
1132 .and_then(|v| v.get("stable"))
1133 .and_then(|s| s.as_str())
1134 {
1135 return Ok(version.to_string());
1136 }
1137
1138 if let Some(tag) = json
1139 .get("latest")
1140 .and_then(|l| l.get("production"))
1141 .and_then(|p| p.get("tag"))
1142 .and_then(|t| t.as_str())
1143 {
1144 return Ok(tag.to_string());
1145 }
1146 return Err(anyhow!(
1147 "Could not determine a version from the JSON content at \
1148 {ver}"
1149 ));
1150 }
1151 return Ok(resp.trim().to_string());
1152 }
1153 return Ok(ver.clone());
1154 }
1155
1156 Err(anyhow!(
1157 "Could not determine a version for package '{}'.",
1158 pkg.name
1159 ))
1160}
1161
1162fn get_version_for_install(
1164 pkg: &types::Package,
1165 version_spec: Option<&String>,
1166 registry_handle: Option<&str>
1167) -> Result<String> {
1168 if let Some(spec) = version_spec {
1169 if spec.starts_with('@') {
1170 let channel = spec.trim_start_matches('@');
1171 let versions = pkg.versions.as_ref().ok_or_else(|| {
1172 anyhow!(
1173 "Package '{}' has no 'versions' map to resolve channel \
1174 '@{}'.",
1175 pkg.name,
1176 channel
1177 )
1178 })?;
1179 return resolve_channel(versions, channel);
1180 }
1181
1182 if let Some(versions) = &pkg.versions
1183 && versions.contains_key(spec)
1184 {
1185 println!("Found '{}' as a channel, resolving...", spec.cyan());
1186 return resolve_channel(versions, spec);
1187 }
1188
1189 return Ok(spec.clone());
1190 }
1191
1192 get_default_version(pkg, registry_handle)
1193}
1194
1195pub fn resolve_requested_version_spec(
1202 source_str: &str,
1203 scope: Option<types::Scope>,
1204 quiet: bool,
1205 yes: bool
1206) -> Result<Option<String>> {
1207 let request = parse_source_string(source_str)?;
1208 let Some(_) = request.version_spec else {
1209 return Ok(None);
1210 };
1211
1212 let resolved_source = resolve_source(source_str, scope, quiet, yes)?;
1213 let mut pkg = zoi_lua::parser::parse_lua_package(
1214 resolved_source.path.to_str().ok_or_else(|| {
1215 anyhow!(
1216 "Path contains invalid UTF-8 characters: {}",
1217 resolved_source.path.display()
1218 )
1219 })?,
1220 None,
1221 scope,
1222 quiet
1223 )?;
1224
1225 if let Some(repo_name) = resolved_source.repo_name {
1226 pkg.repo = repo_name;
1227 }
1228
1229 get_version_for_install(
1230 &pkg,
1231 request.version_spec.as_ref(),
1232 resolved_source.registry_handle.as_deref()
1233 )
1234 .map(Some)
1235}
1236
1237pub fn resolve_source(
1252 source: &str,
1253 scope: Option<types::Scope>,
1254 quiet: bool,
1255 yes: bool
1256) -> Result<ResolvedSource> {
1257 let config = config::read_config().unwrap_or_default();
1258 let max_depth = config.max_resolution_depth.unwrap_or(7);
1259 let resolved =
1260 resolve_source_recursive(source, 0, max_depth, scope, quiet)?;
1261
1262 if !quiet {
1263 let confirmation_key = match &resolved.source_type {
1264 SourceType::LocalFile => Some(
1265 resolved
1266 .path
1267 .canonicalize()
1268 .unwrap_or_else(|_| resolved.path.clone())
1269 .to_string_lossy()
1270 .to_string()
1271 ),
1272 SourceType::Url => Some(source.to_string()),
1273 _ => None
1274 };
1275
1276 let confirmation_key = if let Some(key) = confirmation_key {
1277 let confirmed =
1278 CONFIRMED_UNTRUSTED_SOURCES.lock().map_err(|e| {
1279 anyhow!("Failed to lock trust confirmation cache: {e}")
1280 })?;
1281 if confirmed.contains(&key) {
1282 None
1283 } else {
1284 Some(key)
1285 }
1286 } else {
1287 None
1288 };
1289
1290 if let Some(key) = confirmation_key {
1291 zoi_core::utils::confirm_untrusted_source(
1292 &resolved.source_type,
1293 yes
1294 )?;
1295 let mut confirmed =
1296 CONFIRMED_UNTRUSTED_SOURCES.lock().map_err(|e| {
1297 anyhow!("Failed to lock trust confirmation cache: {e}")
1298 })?;
1299 confirmed.insert(key);
1300 }
1301 }
1302
1303 if let Ok(_request) = parse_source_string(source)
1304 && !matches!(
1305 &resolved.source_type,
1306 SourceType::LocalFile | SourceType::Url
1307 )
1308 && let Some(_repo_name) = &resolved.repo_name
1309 {}
1310
1311 Ok(resolved)
1312}
1313
1314pub fn resolve_package_and_version(
1320 source_str: &str,
1321 scope: Option<types::Scope>,
1322 quiet: bool,
1323 yes: bool
1324) -> Result<(
1325 types::Package,
1326 String,
1327 Option<types::SharableInstallManifest>,
1328 PathBuf,
1329 Option<String>,
1330 Option<String>,
1331 Option<String>
1332)> {
1333 let request = parse_source_string(source_str)?;
1334 let resolved_source = resolve_source(source_str, scope, quiet, yes)?;
1335 let registry_handle = resolved_source.registry_handle.clone();
1336 let repo_type = resolved_source.repo_type.clone();
1337 let pkg_lua_path = resolved_source.path.clone();
1338 let git_sha = resolved_source.git_sha.clone();
1339
1340 let pkg_template = zoi_lua::parser::parse_lua_package(
1341 resolved_source.path.to_str().ok_or_else(|| {
1342 anyhow!(
1343 "Path contains invalid UTF-8 characters: {}",
1344 resolved_source.path.display()
1345 )
1346 })?,
1347 None,
1348 scope,
1349 quiet
1350 )?;
1351
1352 let mut pkg_with_repo = pkg_template;
1353 if let Some(repo_name) = resolved_source.repo_name.clone() {
1354 pkg_with_repo.repo = repo_name;
1355 }
1356
1357 let version_string = get_version_for_install(
1358 &pkg_with_repo,
1359 request.version_spec.as_ref(),
1360 registry_handle.as_deref()
1361 )?;
1362
1363 let mut pkg = zoi_lua::parser::parse_lua_package(
1364 resolved_source.path.to_str().ok_or_else(|| {
1365 anyhow!(
1366 "Path contains invalid UTF-8 characters: {}",
1367 resolved_source.path.display()
1368 )
1369 })?,
1370 Some(&version_string),
1371 scope,
1372 quiet
1373 )?;
1374 if let Some(repo_name) = resolved_source.repo_name.clone() {
1375 pkg.repo = repo_name;
1376 }
1377 pkg.version = Some(version_string.clone());
1378
1379 let registry_handle = resolved_source.registry_handle.clone();
1380
1381 Ok((
1382 pkg,
1383 version_string,
1384 resolved_source.sharable_manifest,
1385 pkg_lua_path,
1386 registry_handle,
1387 repo_type,
1388 git_sha
1389 ))
1390}
1391
1392fn resolve_source_recursive(
1394 source: &str,
1395 depth: u8,
1396 max_depth: u8,
1397 scope: Option<types::Scope>,
1398 quiet: bool
1399) -> Result<ResolvedSource> {
1400 if max_depth > 0 && depth > max_depth {
1401 let msg = format!(
1402 "Resolution depth {depth} exceeds limit {max_depth}. Potential \
1403 circular 'alt' reference."
1404 );
1405 if quiet
1406 || !zoi_core::utils::ask_for_confirmation(
1407 &format!("{msg} Continue anyway?"),
1408 false
1409 )
1410 {
1411 return Err(anyhow!("Exceeded max resolution depth."));
1412 }
1413 }
1414
1415 if source.ends_with(".manifest.yaml") {
1416 let path = PathBuf::from(source);
1417 if !path.exists() {
1418 return Err(anyhow!("Local file not found at '{source}'"));
1419 }
1420 println!("Using local sharable manifest file: {}", path.display());
1421 let content = fs::read_to_string(&path)?;
1422 let sharable_manifest: types::SharableInstallManifest =
1423 serde_yaml::from_str(&content)?;
1424 let new_source = format!(
1425 "#{}@{}/{}@{}",
1426 sharable_manifest.registry_handle,
1427 sharable_manifest.repo,
1428 sharable_manifest.name,
1429 sharable_manifest.version
1430 );
1431 let mut resolved_source = resolve_source_recursive(
1432 &new_source,
1433 depth + 1,
1434 max_depth,
1435 scope,
1436 quiet
1437 )?;
1438 resolved_source.sharable_manifest = Some(sharable_manifest);
1439 return Ok(resolved_source);
1440 }
1441
1442 let path_part = split_explicit_file_source(source).map(|(path, _, _)| path);
1443
1444 let request = parse_source_string(source)?;
1445
1446 if let Some(handle) = &request.handle
1447 && handle.starts_with("git:")
1448 {
1449 if zoi_core::offline::is_offline() {
1450 return Err(anyhow!(
1451 "Cannot resolve remote git repo '{handle}': Zoi is in offline \
1452 mode."
1453 ));
1454 }
1455 let git_source = handle.strip_prefix("git:").ok_or_else(|| {
1456 anyhow!("Handle '{handle}' unexpectedly missing 'git:' prefix")
1457 })?;
1458 println!(
1459 "Warning: using remote git repo '{}' not from official Zoi \
1460 database.",
1461 git_source.yellow()
1462 );
1463
1464 let (host, repo_path) =
1465 git_source.split_once('/').ok_or_else(|| {
1466 anyhow!("Invalid git source format. Expected host/owner/repo.")
1467 })?;
1468
1469 let (base_url, branch_sep) = match host {
1470 "github.com" => (
1471 format!("https://raw.githubusercontent.com/{repo_path}"),
1472 "/"
1473 ),
1474 "gitlab.com" => {
1475 (format!("https://gitlab.com/{repo_path}/-/raw"), "/")
1476 }
1477 "codeberg.org" => {
1478 (format!("https://codeberg.org/{repo_path}/raw/branch"), "/")
1479 }
1480 _ => return Err(anyhow!("Unsupported git host: {host}"))
1481 };
1482
1483 let (_, branch) = {
1484 let mut last_error = None;
1485 let mut content = None;
1486 for b in ["main", "master"] {
1487 let repo_yaml_url =
1488 format!("{base_url}{branch_sep}{b}/repo.yaml");
1489 match download_content_from_url(&repo_yaml_url) {
1490 Ok(c) => {
1491 content = Some((c, b.to_string()));
1492 break;
1493 }
1494 Err(e) => {
1495 last_error = Some(e);
1496 }
1497 }
1498 }
1499 content.ok_or_else(|| {
1500 last_error.unwrap_or_else(|| {
1501 anyhow!("Could not find repo.yaml on main or master branch")
1502 })
1503 })?
1504 };
1505
1506 let full_pkg_path = if let Some(r) = &request.repo {
1507 format!("{}/{}", r, request.name)
1508 } else {
1509 request.name.clone()
1510 };
1511
1512 let pkg_name = Path::new(&full_pkg_path)
1513 .file_name()
1514 .ok_or_else(|| anyhow!("Invalid package path: {full_pkg_path}"))?
1515 .to_str()
1516 .ok_or_else(|| {
1517 anyhow!("Package name contains invalid UTF-8: {full_pkg_path}")
1518 })?;
1519 let pkg_lua_filename = format!("{pkg_name}.pkg.lua");
1520 let pkg_lua_path_in_repo =
1521 Path::new(&full_pkg_path).join(pkg_lua_filename);
1522
1523 let pkg_lua_url = format!(
1524 "{}{}{}/{}",
1525 base_url,
1526 branch_sep,
1527 branch,
1528 pkg_lua_path_in_repo
1529 .to_str()
1530 .ok_or_else(|| anyhow!("Package path contains invalid UTF-8"))?
1531 .replace('\\', "/")
1532 );
1533
1534 let pkg_lua_content = download_content_from_url(&pkg_lua_url)?;
1535
1536 let cache_dir = cache::get_pkgdef_cache_root()?;
1537 fs::create_dir_all(&cache_dir)?;
1538
1539 let mut hasher = Sha256::new();
1540 hasher.update(pkg_lua_url.as_bytes());
1541 let hash = hex::encode(hasher.finalize());
1542 let cache_path = cache_dir.join(format!("{hash}.pkg.lua"));
1543
1544 fs::write(&cache_path, pkg_lua_content.as_bytes())?;
1545
1546 let repo_name = format!("git:{git_source}");
1547
1548 return Ok(ResolvedSource {
1549 path: cache_path,
1550 source_type: SourceType::GitRepo(repo_name.clone()),
1551 repo_name: Some(repo_name),
1552 repo_type: Some("unofficial".to_string()),
1553 registry_handle: None,
1554 sharable_manifest: None,
1555 git_sha: None
1556 });
1557 }
1558
1559 let resolved_source = if source.starts_with("#git@") {
1560 let full_path_str = source.trim_start_matches("#git@");
1561 let parts: Vec<&str> = full_path_str.split('/').collect();
1562
1563 if parts.len() < 2 {
1564 return Err(anyhow!(
1565 "Invalid git source. Use #git@<repo-name>/<path/to/pkg>"
1566 ));
1567 }
1568
1569 let repo_name =
1570 parts.first().ok_or_else(|| anyhow!("Invalid git source"))?;
1571 let nested_path_parts = parts
1572 .get(1..)
1573 .ok_or_else(|| anyhow!("Invalid git source path"))?;
1574 let pkg_name = nested_path_parts
1575 .last()
1576 .ok_or_else(|| anyhow!("Empty path in git source"))?;
1577
1578 let mut path =
1579 zoi_core::utils::get_git_base_dir(zoi_core::types::Scope::User)?
1580 .join(repo_name);
1581
1582 for part in nested_path_parts.iter().take(nested_path_parts.len() - 1) {
1583 path = path.join(part);
1584 }
1585
1586 path = path.join(format!("{pkg_name}.pkg.lua"));
1587
1588 if !path.exists() {
1589 let nested_path_str = nested_path_parts.join("/");
1590 return Err(anyhow!(
1591 "Package '{}' not found in git repo '{}' (expected: {})",
1592 nested_path_str,
1593 repo_name,
1594 path.display()
1595 ));
1596 }
1597 println!(
1598 "Warning: using external git repo '{}{}' not from official Zoi \
1599 database.",
1600 "#git@".yellow(),
1601 repo_name.yellow()
1602 );
1603 let git_repo_root =
1604 zoi_core::utils::get_git_base_dir(zoi_core::types::Scope::User)?
1605 .join(repo_name);
1606 let git_sha = get_git_head_sha(&git_repo_root);
1607
1608 ResolvedSource {
1609 path,
1610 source_type: SourceType::GitRepo(repo_name.to_string()),
1611 repo_name: Some(format!("git/{repo_name}")),
1612 repo_type: Some("unofficial".to_string()),
1613 registry_handle: Some("local".to_string()),
1614 sharable_manifest: None,
1615 git_sha
1616 }
1617 } else if source.starts_with("http://") || source.starts_with("https://") {
1618 if zoi_core::offline::is_offline() {
1619 return Err(anyhow!(
1620 "Cannot download package definition from URL '{source}': Zoi \
1621 is in offline mode."
1622 ));
1623 }
1624 download_from_url(download_source_for_explicit_path(source, path_part))?
1625 } else if let Some(path_part) = path_part {
1626 let path = zoi_core::utils::expand_tilde(path_part);
1627 if !path.exists() {
1628 return Err(anyhow!(
1629 "Local file not found at '{}'",
1630 path.display()
1631 ));
1632 }
1633 ResolvedSource {
1634 path,
1635 source_type: SourceType::LocalFile,
1636 repo_name: None,
1637 repo_type: None,
1638 registry_handle: Some("local".to_string()),
1639 sharable_manifest: None,
1640 git_sha: None
1641 }
1642 } else if zoi_core::utils::is_mini_mode() {
1643 let set_reg = zoi_core::builtin::registry::get_set()?;
1644 let registry_handle = set_reg
1645 .as_ref()
1646 .map_or_else(|| "default".to_string(), |r| r.handle.clone());
1647
1648 let index = crate::mini_resolve::fetch_registry_index()?;
1649
1650 let (repo, repo_type) = if let Some(r) = &request.repo {
1651 let r_type = index
1652 .packages
1653 .get(&request.name)
1654 .filter(|p| &p.repo == r)
1655 .map_or_else(
1656 || "unofficial".to_string(),
1657 |p| p.repo_type.clone()
1658 );
1659 (r.clone(), r_type)
1660 } else {
1661 let pkg_info =
1662 index.packages.get(&request.name).ok_or_else(|| {
1663 anyhow!(
1664 "Package '{}' not found in registry index",
1665 request.name
1666 )
1667 })?;
1668 (pkg_info.repo.clone(), pkg_info.repo_type.clone())
1669 };
1670
1671 let lua_url =
1672 crate::mini_resolve::get_package_lua_url(&repo, &request.name);
1673 let mut resolved = download_from_url(&lua_url)?;
1674 resolved.repo_name = Some(repo.clone());
1675 resolved.repo_type = Some(repo_type.clone());
1676 resolved.registry_handle = Some(registry_handle);
1677
1678 resolved.source_type = if repo_type == "official" {
1679 SourceType::OfficialRepo
1680 } else {
1681 SourceType::UntrustedRepo(repo)
1682 };
1683 resolved
1684 } else {
1685 find_package_in_db(&request, quiet)?
1686 };
1687
1688 let pkg_for_alt_check = zoi_lua::parser::parse_lua_package(
1689 resolved_source.path.to_str().ok_or_else(|| {
1690 anyhow!(
1691 "Path contains invalid UTF-8 characters: {}",
1692 resolved_source.path.display()
1693 )
1694 })?,
1695 None,
1696 scope,
1697 quiet
1698 )?;
1699
1700 if let Some(alt_source) = pkg_for_alt_check.alt {
1701 println!("Found 'alt' source. Resolving from: {}", alt_source.cyan());
1702
1703 let alt_resolved_source = if alt_source.starts_with("http://")
1704 || alt_source.starts_with("https://")
1705 {
1706 println!("Downloading 'alt' source from: {}", alt_source.cyan());
1707 let client = zoi_core::utils::get_http_client()?;
1708 let mut attempt = 0u32;
1709 let response = loop {
1710 attempt += 1;
1711 match client.get(&alt_source).send() {
1712 Ok(resp) => break resp,
1713 Err(e) => {
1714 if attempt < 3 {
1715 eprintln!(
1716 "{}: download failed ({}). Retrying...",
1717 "Network".yellow(),
1718 e
1719 );
1720 zoi_core::utils::retry_backoff_sleep(attempt);
1721 continue;
1722 }
1723 return Err(anyhow!(
1724 "Failed to download file after {attempt} \
1725 attempts: {e}"
1726 ));
1727 }
1728 }
1729 };
1730 if !response.status().is_success() {
1731 return Err(anyhow!(
1732 "Failed to download alt source (HTTP {}): {}",
1733 response.status(),
1734 alt_source
1735 ));
1736 }
1737
1738 let content = response.text()?;
1739
1740 let cache_dir = cache::get_pkgdef_cache_root()?;
1741 fs::create_dir_all(&cache_dir)?;
1742
1743 let mut hasher = Sha256::new();
1744 hasher.update(alt_source.as_bytes());
1745 let hash = hex::encode(hasher.finalize());
1746 let cache_path = cache_dir.join(format!("{hash}.pkg.lua"));
1747
1748 fs::write(&cache_path, content.as_bytes())?;
1749
1750 resolve_source_recursive(
1751 cache_path.to_str().ok_or_else(|| {
1752 anyhow!(
1753 "Cache path contains invalid UTF-8 characters: {}",
1754 cache_path.display()
1755 )
1756 })?,
1757 depth + 1,
1758 max_depth,
1759 scope,
1760 quiet
1761 )?
1762 } else {
1763 resolve_source_recursive(
1764 &alt_source,
1765 depth + 1,
1766 max_depth,
1767 scope,
1768 quiet
1769 )?
1770 };
1771
1772 return Ok(alt_resolved_source);
1773 }
1774
1775 Ok(resolved_source)
1776}
1777
1778#[cfg(test)]
1779mod tests {
1780 use super::download_source_for_explicit_path;
1781
1782 #[test]
1783 fn test_download_source_for_explicit_http_channel_uses_base_url() {
1784 let source = "http://127.0.0.1:8000/test.pkg.lua@stable";
1785 let path_part = Some("http://127.0.0.1:8000/test.pkg.lua");
1786 assert_eq!(
1787 download_source_for_explicit_path(source, path_part),
1788 "http://127.0.0.1:8000/test.pkg.lua"
1789 );
1790 }
1791
1792 #[test]
1793 fn test_download_source_for_plain_http_source_uses_original() {
1794 let source = "http://127.0.0.1:8000/test.pkg.lua";
1795 assert_eq!(
1796 download_source_for_explicit_path(source, None),
1797 "http://127.0.0.1:8000/test.pkg.lua"
1798 );
1799 }
1800}