Skip to main content

zoi_purl/
lib.rs

1//! Package URL (PURL) resolution for Zoi.
2//!
3//! This crate implements the resolution of PURLs in the `pkg:zoi/` namespace.
4//! It allows Zoi to discover, resolve, and fetch package definitions from
5//! decentralized Git-backed registries.
6
7use 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
15/// Returns the default version ("1") for the central database.
16fn default_version() -> String {
17    "1".to_string()
18}
19
20/// Returns the default revision ("1") for a package index.
21fn default_revision() -> String {
22    "1".to_string()
23}
24
25/// Specification for the Central Registry Database.
26#[derive(Debug, Serialize, Deserialize, Clone)]
27pub struct CentralDbSpec {
28    /// Version of the database format.
29    #[serde(default = "default_version")]
30    pub version: String,
31    /// Map of registry handles to their connection information.
32    #[serde(flatten)]
33    pub registries: HashMap<String, RegistryInfo>
34}
35
36/// Connection information for a Zoi package registry.
37#[derive(Debug, Serialize, Deserialize, Clone)]
38pub struct RegistryInfo {
39    /// Human-readable name of the registry.
40    pub name: String,
41    /// Brief description of the registry's purpose or content.
42    pub description: String,
43    /// URL to the Git repository containing the package definitions.
44    pub git: String,
45    /// Branch name to use when fetching data from the Git repository.
46    pub branch: String
47}
48
49/// Index entry for a specific package in a registry.
50#[derive(Debug, Serialize, Deserialize, Clone)]
51pub struct PurlPackageIndex {
52    /// Repository path within the registry (e.g. "base", "extra").
53    pub repo: String,
54    /// Type of the repository.
55    pub repo_type: String,
56    /// Latest version of the package.
57    pub version: String,
58    /// Revision of the package version.
59    #[serde(default = "default_revision")]
60    pub revision: String,
61    /// Brief description of the package.
62    pub description: String,
63    /// List of sub-packages included in this package.
64    pub sub_packages: Vec<String>,
65    /// List of main sub-packages.
66    pub main_sub_packages: Vec<String>,
67    /// Known vulnerabilities for this package.
68    pub vuln: Vec<MiniVulnerability>,
69    /// Dependencies required by this package.
70    pub dependencies: Option<zoi_core::types::Dependencies>
71}
72
73/// The full index of a Zoi registry.
74#[derive(Debug, Serialize, Deserialize, Clone)]
75pub struct RegistryIndex {
76    /// Version of the registry index format.
77    pub version: String,
78    /// Map of package identifiers to their index entries.
79    pub packages: BTreeMap<String, PurlPackageIndex>
80}
81
82/// Fetches the central Zoi registry database from a remote URL or local file.
83///
84/// The URL can be overridden by the `ZOI_PURL_DB_URL` environment variable.
85///
86/// # Errors
87/// Returns an error if the database cannot be fetched, verified, or parsed.
88pub 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
118/// Constructs a raw content URL for a file in a Git repository.
119///
120/// Supports GitHub, GitLab, and Codeberg.
121///
122/// # Errors
123/// Returns an error if the Git provider is unsupported.
124pub 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
150/// Fetches the `packages.json` index from a specific registry.
151///
152/// # Errors
153/// Returns an error if the index cannot be fetched or parsed.
154pub fn fetch_registry_index(registry: &RegistryInfo) -> Result<RegistryIndex> {
155    let data = if registry.git.starts_with("http") {
156        let url = construct_raw_url(
157            &registry.git,
158            &registry.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(&registry.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
186/// Fetches the `.pkg.lua` definition for a package from a registry.
187///
188/// # Errors
189/// Returns an error if the file cannot be fetched or read.
190pub 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(&registry.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(&registry.git, &registry.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/// Details of a successfully resolved PURL.
225#[derive(Debug)]
226pub struct ResolvedPurl {
227    /// The handle of the registry where the package was found.
228    pub registry_handle: String,
229    /// Connection info for the registry.
230    pub registry: RegistryInfo,
231    /// Path to the package within the registry.
232    pub package_path: String,
233    /// Index entry for the package.
234    pub package_info: PurlPackageIndex,
235    /// The specific version resolved.
236    pub version: String,
237    /// The full registry index.
238    pub index: RegistryIndex
239}
240
241/// Resolves a Zoi PURL string to its registry and package information.
242///
243/// Expected format: `pkg:zoi/[registry-handle]/[repo]/[package]`
244///
245/// # Errors
246/// Returns an error if the PURL is invalid, unsupported, or cannot be found in
247/// the registry.
248pub 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
311/// Fetches a package and all its Zoi dependencies by PURL and stores them
312/// locally.
313///
314/// # Errors
315/// Returns an error if resolution, fetching, or local storage fails.
316pub 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
339/// Recursively fetches and stores package definitions.
340///
341/// # Errors
342/// Returns an error if fetching or writing to disk fails.
343fn 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}