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