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