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