1use std::collections::{BTreeMap, HashMap};
8use std::path::Path;
9
10use anyhow::{Result, anyhow};
11use purl::GenericPurl;
12use serde::{Deserialize, Serialize};
13use zoi_core::types::MiniVulnerability;
14
15fn default_version() -> String {
17 "1".to_string()
18}
19
20fn default_revision() -> String {
22 "1".to_string()
23}
24
25#[derive(Debug, Serialize, Deserialize, Clone)]
27pub struct CentralDbSpec {
28 #[serde(default = "default_version")]
30 pub version: String,
31 #[serde(flatten)]
33 pub registries: HashMap<String, RegistryInfo>
34}
35
36#[derive(Debug, Serialize, Deserialize, Clone)]
38pub struct RegistryInfo {
39 pub name: String,
41 pub description: String,
43 pub git: String,
45 pub branch: String
47}
48
49#[derive(Debug, Serialize, Deserialize, Clone)]
51pub struct PurlPackageIndex {
52 pub repo: String,
54 pub repo_type: String,
56 pub version: String,
58 #[serde(default = "default_revision")]
60 pub revision: String,
61 pub description: String,
63 pub sub_packages: Vec<String>,
65 pub main_sub_packages: Vec<String>,
67 pub vuln: Vec<MiniVulnerability>,
69 pub dependencies: Option<zoi_core::types::Dependencies>
71}
72
73#[derive(Debug, Serialize, Deserialize, Clone)]
75pub struct RegistryIndex {
76 pub version: String,
78 pub packages: BTreeMap<String, PurlPackageIndex>
80}
81
82pub fn fetch_central_db() -> Result<HashMap<String, RegistryInfo>> {
89 let url = std::env::var("ZOI_PURL_DB_URL").unwrap_or_else(|_| {
90 "https://zillowe.pages.dev/zoi/registries.json".to_string()
91 });
92
93 let is_test = std::env::var("ZOI_TEST").is_ok();
94 let data = if url.starts_with("http") {
95 let trusted_keys = zoi_core::config::get_builtin_authorities();
96 if !trusted_keys.is_empty() && !is_test {
97 zoi_core::config::verify_remote_file(&url, &trusted_keys)?
98 } else {
99 let client = zoi_core::utils::get_http_client()?;
100 let response = client.get(&url).send()?;
101 if !response.status().is_success() {
102 return Err(anyhow!(
103 "Failed to fetch central Zoi registry database: {}",
104 response.status()
105 ));
106 }
107 response.bytes()?.to_vec()
108 }
109 } else {
110 std::fs::read(&url)
111 .map_err(|e| anyhow!("Failed to read central DB from {url}: {e}"))?
112 };
113
114 let spec: CentralDbSpec = serde_json::from_slice(&data)?;
115 Ok(spec.registries)
116}
117
118pub fn construct_raw_url(
125 git_url: &str,
126 branch: &str,
127 file_path: &str
128) -> Result<String> {
129 let url = git_url.trim_end_matches(".git").trim_end_matches('/');
130
131 if let Some(path) = url.strip_prefix("https://github.com/") {
132 Ok(format!(
133 "https://raw.githubusercontent.com/{path}/{branch}/{file_path}"
134 ))
135 } else if let Some(path) = url.strip_prefix("https://gitlab.com/") {
136 Ok(format!(
137 "https://gitlab.com/{path}/-/raw/{branch}/{file_path}"
138 ))
139 } else if let Some(path) = url.strip_prefix("https://codeberg.org/") {
140 Ok(format!(
141 "https://codeberg.org/{path}/raw/branch/{branch}/{file_path}"
142 ))
143 } else {
144 Err(anyhow!(
145 "Unsupported git provider for PURL resolution: {git_url}"
146 ))
147 }
148}
149
150pub fn fetch_registry_index(registry: &RegistryInfo) -> Result<RegistryIndex> {
155 let data = if registry.git.starts_with("http") {
156 let url = construct_raw_url(
157 ®istry.git,
158 ®istry.branch,
159 "packages.json"
160 )?;
161 let client = zoi_core::utils::get_http_client()?;
162 let response = client.get(url).send()?;
163
164 if !response.status().is_success() {
165 return Err(anyhow!(
166 "Failed to fetch packages.json from registry {}: {}",
167 registry.name,
168 response.status()
169 ));
170 }
171 response.bytes()?.to_vec()
172 } else {
173 let path = Path::new(®istry.git).join("packages.json");
174 std::fs::read(&path).map_err(|e| {
175 anyhow!(
176 "Failed to read registry index from {}: {}",
177 path.display(),
178 e
179 )
180 })?
181 };
182
183 Ok(serde_json::from_slice(&data)?)
184}
185
186pub fn fetch_package_lua(
191 registry: &RegistryInfo,
192 repo: &str,
193 name: &str
194) -> Result<String> {
195 let file_path = if repo.is_empty() {
196 format!("{name}/{name}.pkg.lua")
197 } else {
198 format!("{repo}/{name}/{name}.pkg.lua")
199 };
200
201 if !registry.git.starts_with("http") {
202 let path = Path::new(®istry.git).join(&file_path);
203 return std::fs::read_to_string(&path).map_err(|e| {
204 anyhow!("Failed to read pkg.lua from {}: {}", path.display(), e)
205 });
206 }
207
208 let url = construct_raw_url(®istry.git, ®istry.branch, &file_path)?;
209 let client = zoi_core::utils::get_http_client()?;
210 let response = client.get(url).send()?;
211
212 if !response.status().is_success() {
213 return Err(anyhow!(
214 "Failed to fetch pkg.lua for package {} from registry {}: {}",
215 name,
216 registry.name,
217 response.status()
218 ));
219 }
220
221 Ok(response.text()?)
222}
223
224#[derive(Debug)]
226pub struct ResolvedPurl {
227 pub registry_handle: String,
229 pub registry: RegistryInfo,
231 pub package_path: String,
233 pub package_info: PurlPackageIndex,
235 pub version: String,
237 pub index: RegistryIndex
239}
240
241pub fn resolve_purl(purl_str: &str) -> Result<ResolvedPurl> {
249 let purl: GenericPurl<String> =
250 purl_str.parse().map_err(|e| anyhow!("Invalid PURL: {e}"))?;
251
252 if purl.package_type() != "zoi" {
253 return Err(anyhow!(
254 "Unsupported PURL type: {}. Expected 'zoi'.",
255 purl.package_type()
256 ));
257 }
258
259 let namespace = purl
260 .namespace()
261 .ok_or_else(|| anyhow!("PURL missing registry handle in namespace"))?;
262 let mut ns_parts = namespace.split('/');
263 let registry_handle = ns_parts
264 .next()
265 .ok_or_else(|| anyhow!("PURL missing registry handle"))?;
266 let package_path = purl.name();
267 let version = purl.version().unwrap_or("latest");
268
269 let remaining_ns: Vec<&str> = ns_parts.collect();
270 if remaining_ns.is_empty() {
271 return Err(anyhow!(
272 "PURL missing repository path. Expected format: \
273 pkg:zoi/[registry-handle]/[repo]/[package]"
274 ));
275 }
276 let expected_repo = remaining_ns.join("/");
277
278 let central_db = fetch_central_db()?;
279 let registry = central_db.get(registry_handle).ok_or_else(|| {
280 anyhow!(
281 "Registry handle '{registry_handle}' not found in central database"
282 )
283 })?;
284
285 let index = fetch_registry_index(registry)?;
286
287 let packages_key = format!("@{expected_repo}/{package_path}");
288 let package_info = index.packages.get(&packages_key).ok_or_else(|| {
289 anyhow!(
290 "Package '{package_path}' not found in registry \
291 '{registry_handle}' within repository '{expected_repo}'"
292 )
293 })?;
294
295 let resolved_version = if version == "latest" {
296 package_info.version.clone()
297 } else {
298 version.to_string()
299 };
300
301 Ok(ResolvedPurl {
302 registry_handle: registry_handle.to_string(),
303 registry: registry.clone(),
304 package_path: package_path.to_string(),
305 package_info: package_info.clone(),
306 version: resolved_version,
307 index
308 })
309}
310
311pub fn fetch_and_store_purl_package(purl_str: &str) -> Result<String> {
317 let resolved = resolve_purl(purl_str)?;
318 let db_root = zoi_core::utils::get_db_root()?;
319
320 let mut fetched = std::collections::HashSet::new();
321 let packages_key =
322 format!("@{}/{}", resolved.package_info.repo, resolved.package_path);
323 fetch_and_store_recursive(
324 &resolved.registry_handle,
325 &resolved.registry,
326 &resolved.index,
327 &packages_key,
328 &db_root,
329 &mut fetched
330 )?;
331
332 let ident = format!(
333 "#{}@{}@{}",
334 resolved.registry_handle, packages_key, resolved.version
335 );
336 Ok(ident)
337}
338
339fn fetch_and_store_recursive(
344 registry_handle: &str,
345 registry: &RegistryInfo,
346 index: &RegistryIndex,
347 packages_key: &str,
348 db_root: &Path,
349 fetched: &mut std::collections::HashSet<String>
350) -> Result<()> {
351 if fetched.contains(packages_key) {
352 return Ok(());
353 }
354 fetched.insert(packages_key.to_string());
355
356 let pkg_info = index.packages.get(packages_key).ok_or_else(|| {
357 anyhow!(
358 "Dependency '{packages_key}' not found in registry \
359 '{registry_handle}'"
360 )
361 })?;
362
363 let package_name =
364 packages_key.split('/').next_back().unwrap_or(packages_key);
365
366 let lua_content =
367 fetch_package_lua(registry, &pkg_info.repo, package_name)?;
368
369 let mut dest_dir = db_root.join(registry_handle);
370 if !pkg_info.repo.is_empty() {
371 dest_dir = dest_dir.join(&pkg_info.repo);
372 }
373 dest_dir = dest_dir.join(package_name);
374
375 std::fs::create_dir_all(&dest_dir)?;
376 let dest_file = dest_dir.join(format!("{package_name}.pkg.lua"));
377 std::fs::write(&dest_file, lua_content)?;
378
379 if let Some(deps) = &pkg_info.dependencies {
380 let mut to_fetch = Vec::new();
381 if let Some(runtime) = &deps.runtime {
382 match runtime {
383 zoi_core::types::DependencyGroup::Simple(d) => {
384 to_fetch.extend(d.clone());
385 }
386 zoi_core::types::DependencyGroup::Complex(c) => {
387 to_fetch.extend(c.required.clone());
388 to_fetch.extend(c.optional.clone());
389 for opt in &c.options {
390 to_fetch.extend(opt.depends.clone());
391 }
392 }
393 }
394 }
395
396 let current_repo = packages_key
397 .strip_prefix('@')
398 .and_then(|k| k.split_once('/'))
399 .map_or("", |(repo, _)| repo);
400
401 for dep_str in to_fetch {
402 if let Some(zoi_dep) = dep_str.strip_prefix("zoi:") {
403 let found_key = if zoi_dep.starts_with('@') {
404 if index.packages.contains_key(zoi_dep) {
405 Some(zoi_dep.to_string())
406 } else {
407 None
408 }
409 } else {
410 let dep_pkg_name =
411 zoi_dep.split('@').next().unwrap_or(zoi_dep);
412 let scoped = format!("@{current_repo}/{dep_pkg_name}");
413
414 if index.packages.contains_key(&scoped) {
415 Some(scoped)
416 } else {
417 index
418 .packages
419 .keys()
420 .find(|k| k.ends_with(&format!("/{dep_pkg_name}")))
421 .cloned()
422 }
423 };
424
425 if let Some(key) = found_key {
426 let _ = fetch_and_store_recursive(
427 registry_handle,
428 registry,
429 index,
430 &key,
431 db_root,
432 fetched
433 );
434 }
435 }
436 }
437 }
438 Ok(())
439}