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