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