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