Skip to main content

zoi_package/
registry.rs

1//! Registry management and metadata generation.
2//!
3//! This module provides functions for initializing registries, adding packages
4//! and security advisories, and generating the optimized JSON index files used
5//! by Zoi.
6
7use std::collections::HashMap;
8use std::fs;
9use std::path::Path;
10
11use anyhow::{Result, anyhow};
12use chrono::Datelike;
13use colored::Colorize;
14use walkdir::WalkDir;
15use zoi_core::types;
16use zoi_lua;
17
18use crate::{doctor as pkg_doctor, init_lsp};
19
20/// Initializes a new Zoi registry at the given path.
21///
22/// This creates the necessary directory structure, `repo.yaml`,
23/// `packages.json`, and `advisories.json`, and sets up LSP support.
24///
25/// # Errors
26///
27/// Returns an error if:
28/// - The registry directory or its subdirectories cannot be created.
29/// - LSP workspace setup fails.
30/// - Any of the initial configuration files cannot be written.
31pub fn init(path: &Path) -> Result<()> {
32    println!(
33        "{} Initializing new Zoi registry at {}...",
34        "::".bold().blue(),
35        path.display()
36    );
37
38    fs::create_dir_all(path)?;
39
40    let dirs = ["core", "main", "community", "test", "archive"];
41    for dir in &dirs {
42        let dir_path = path.join(dir);
43        if !dir_path.exists() {
44            fs::create_dir_all(&dir_path)?;
45        }
46    }
47
48    init_lsp::setup_lsp_workspace(path)?;
49    println!(
50        "{} LSP support initialized. Created .luarc.json and type definitions.",
51        "::".bold().green()
52    );
53
54    let repo_yaml_path = path.join("repo.yaml");
55    if !repo_yaml_path.exists() {
56        let content = r#"# Zoi Registry Configuration
57# For detailed documentation, visit: https://zillowe.qzz.io/docs/zds/zoi/repositories#the-repoyaml-file
58
59name: "My-Registry"
60description: "A custom Zoi package registry"
61handle: "my-registry"
62advisory_prefix: "RSA" # Prefix for security advisories, e.g. RSA-2026-A0001
63
64# Git mirrors for this registry
65# For more info: https://zillowe.qzz.io/docs/zds/zoi/guides/mirroring
66git:
67  - type: main
68    url: "https://github.com/user/my-registry.git"
69
70# Pre-built package mirrors (optional)
71# pkg:
72#   - type: main
73#     url: "https://example.com/pkgs/{repo}/{os}/{arch}/{version}"
74
75# Trusted PGP keys for this registry
76# pgp:
77#   - name: maintainer-key
78#     key: "https://example.com/keys/maintainer.asc"
79
80# Repository tiers
81repos:
82  - name: core
83    type: official
84    active: true
85  - name: main
86    type: official
87    active: true
88  - name: community
89    type: community
90    active: false
91  - name: test
92    type: test
93    active: false
94  - name: archive
95    type: archive
96    active: false
97"#;
98        fs::write(repo_yaml_path, content)?;
99    }
100
101    let packages_json_path = path.join("packages.json");
102    if !packages_json_path.exists() {
103        let content = r#"{
104  "version": "2",
105  "packages": {}
106}"#;
107        fs::write(packages_json_path, content)?;
108    }
109
110    let advisories_json_path = path.join("advisories.json");
111    if !advisories_json_path.exists() {
112        let current_year = chrono::Utc::now().year();
113        let content = format!(
114            r#"{{
115  "version": "2",
116  "advisories": {{}},
117  "last_id": 0,
118  "year": {current_year}
119}}"#,
120        );
121        fs::write(advisories_json_path, content)?;
122    }
123
124    println!("{}", "Registry initialized successfully.".green());
125    println!(
126        "{} Edit 'repo.yaml' to configure your registry mirrors and \
127         authorities.",
128        "Note:".yellow()
129    );
130    Ok(())
131}
132
133/// Adds a new package definition to the registry.
134///
135/// This creates a new directory for the package and a template `.pkg.lua` file.
136/// If `name` or `repo` are not provided, it prompts the user for input.
137///
138/// # Errors
139///
140/// Returns an error if:
141/// - The path is not a Zoi registry.
142/// - Package name or repository tier are not provided.
143/// - The package directory cannot be created.
144/// - The package already exists.
145pub fn add_package(
146    registry_root: &Path,
147    name: Option<&str>,
148    repo: Option<&str>
149) -> Result<()> {
150    use std::io::{Write, stdin, stdout};
151
152    if !registry_root.join("repo.yaml").exists() {
153        return Err(anyhow!(
154            "Not a Zoi registry (missing repo.yaml). Run 'zoi reg init' first."
155        ));
156    }
157
158    let get_input = |prompt: &str| -> String {
159        print!("{prompt}: ");
160        let _ = stdout().flush();
161        let mut input = String::new();
162        let _ = stdin().read_line(&mut input);
163        input.trim().to_string()
164    };
165
166    let name = match name {
167        Some(n) => n.to_string(),
168        None => get_input("Package name")
169    };
170
171    let repo = match repo {
172        Some(r) => r.to_string(),
173        None => get_input("Repository tier (e.g. community, main)")
174    };
175
176    if name.is_empty() || repo.is_empty() {
177        return Err(anyhow!("Package name and repository tier are required."));
178    }
179
180    let pkg_dir = registry_root.join(&repo).join(&name);
181    fs::create_dir_all(&pkg_dir)?;
182
183    let pkg_lua_path = pkg_dir.join(format!("{name}.pkg.lua"));
184    if pkg_lua_path.exists() {
185        return Err(anyhow!(
186            "Package '{name}' already exists in repo '{repo}'.",
187        ));
188    }
189
190    let content = format!(
191        r#"-- Zoi Package Definition: {name}
192-- For detailed documentation, visit: https://zillowe.qzz.io/docs/zds/zoi/creating-packages
193
194metadata({{
195  name = "{name}",
196  repo = "{repo}",
197  version = "1.0.0",
198  revision = "1",
199  description = "A short description of {name}.",
200  website = "https://example.com",
201  license = "Apache-2.0",
202  maintainer = {{ name = "Your Name", email = "you@example.com" }},
203  bins = {{ "{name}" }},
204  types = {{ "pre-compiled" }}, -- Supports "source", "pre-compiled"
205}})
206
207dependencies({{
208  build = {{
209    -- Build-time dependencies
210    -- For format info: https://zillowe.qzz.io/docs/zds/zoi/dependencies
211  }},
212  runtime = {{
213    -- Runtime dependencies
214  }}
215}})
216
217function prepare()
218  -- Fetch source or binaries
219  -- Example: UTILS.EXTRACT("https://example.com/release.tar.gz", "src")
220end
221
222function package()
223  -- Stage files for the package
224  -- Example: zcp("src/{name}", "${{pkgstore}}/bin/{name}")
225end
226
227-- function verify()
228--   -- Security verification
229--   -- return verifyHash("release.tar.gz", "sha256-...")
230-- end
231
232-- function test()
233--   -- Integration tests (run via zoi package test)
234--   -- local _, _, code = cmd(STAGING_DIR .. "/data/pkgstore/bin/{name} --version")
235--   -- return code == 0
236-- end
237
238function uninstall()
239  -- Cleanup outside the package store
240end
241"#,
242    );
243
244    fs::write(pkg_lua_path, content)?;
245    println!(
246        "{} Package '{}' created in repo '{}'.",
247        "::".bold().green(),
248        name.cyan(),
249        repo.cyan()
250    );
251
252    Ok(())
253}
254
255/// Adds a new security advisory to the registry.
256///
257/// This creates a temporary `.sec.yaml` file for the given package.
258/// The ID will be automatically assigned during `generate_metadata`.
259///
260/// # Errors
261///
262/// Returns an error if:
263/// - The path is not a Zoi registry.
264/// - The package is not found in the registry.
265/// - A temporary advisory already exists.
266pub fn add_advisory(
267    registry_root: &Path,
268    package_name: Option<&str>,
269    repo: Option<&str>
270) -> Result<()> {
271    use std::io::{Write, stdin, stdout};
272
273    if !registry_root.join("repo.yaml").exists() {
274        return Err(anyhow!(
275            "Not a Zoi registry (missing repo.yaml). Run 'zoi reg init' first."
276        ));
277    }
278
279    let get_input = |prompt: &str| -> String {
280        print!("{prompt}: ");
281        let _ = stdout().flush();
282        let mut input = String::new();
283        let _ = stdin().read_line(&mut input);
284        input.trim().to_string()
285    };
286
287    let package_name = match package_name {
288        Some(n) => n.to_string(),
289        None => get_input("Package name")
290    };
291
292    let package_name_str = package_name.as_str();
293
294    let pkg_dir = if let Some(r) = repo {
295        let dir = registry_root.join(r).join(package_name_str);
296        if !dir.join(format!("{package_name_str}.pkg.lua")).exists() {
297            return Err(anyhow!(
298                "Package '{package_name_str}' not found in repo '{r}'.",
299            ));
300        }
301        dir
302    } else {
303        let mut found = None;
304        for entry in WalkDir::new(registry_root)
305            .into_iter()
306            .filter_map(Result::ok)
307        {
308            if entry.file_type().is_dir()
309                && entry.file_name().to_string_lossy() == package_name_str
310                && entry
311                    .path()
312                    .join(format!("{package_name_str}.pkg.lua"))
313                    .exists()
314            {
315                found = Some(entry.path().to_path_buf());
316                break;
317            }
318        }
319        found.ok_or_else(|| {
320            anyhow!(
321                "Package '{package_name_str}' not found in registry. Try \
322                 specifying --repo.",
323            )
324        })?
325    };
326
327    let repo_config_str = fs::read_to_string(registry_root.join("repo.yaml"))?;
328    let repo_config: types::RepoConfig =
329        serde_yaml::from_str(&repo_config_str)?;
330    let prefix = repo_config
331        .advisory_prefix
332        .unwrap_or_else(|| "ZSA".to_string());
333
334    let current_year = chrono::Utc::now().year();
335    let adv_file_path =
336        pkg_dir.join(format!("{prefix}-{current_year}-TEMP.sec.yaml"));
337
338    if adv_file_path.exists() {
339        return Err(anyhow!(
340            "A temporary advisory already exists for this package."
341        ));
342    }
343
344    println!(
345        "{} Adding security advisory for package: {}",
346        "::".bold().blue(),
347        package_name_str.cyan()
348    );
349    println!(
350        "For detailed documentation, visit: https://zillowe.qzz.io/docs/zds/zoi/guides/security-advisories\n"
351    );
352
353    let summary = get_input("Summary (short description)");
354    let severity = get_input("Severity (low, medium, high, critical)");
355    let affected_range =
356        get_input("Affected version range (e.g. >=1.0.0, <1.2.3)");
357    let fixed_in = get_input("Fixed in version");
358    let description = get_input("Detailed description");
359    let reference = get_input("Reference URL (optional)");
360
361    let content = format!(
362        r#"# Zoi Security Advisory
363# For schema details: https://zillowe.qzz.io/docs/zds/zoi/guides/security-advisories#advisory-schema
364
365id: "{prefix}-{current_year}-TEMP"
366package: "{package_name}"
367summary: "{summary}"
368severity: "{severity}"
369affected_range: "{affected_range}"
370fixed_in: "{fixed_in}"
371description: |
372  {description}
373references:
374  - "{reference}"
375"#,
376    );
377
378    fs::write(&adv_file_path, content)?;
379    println!(
380        "\n{} Temporary advisory created: {}",
381        "::".bold().green(),
382        adv_file_path.display().to_string().cyan()
383    );
384    println!(
385        "{} ID will be automatically assigned during 'zoi reg gen-meta' or in \
386         CI.",
387        "Note:".yellow()
388    );
389
390    Ok(())
391}
392
393/// Scans the entire registry to generate optimized JSON index files.
394///
395/// This function is the "Build Pipeline" for Zoi registries. It:
396/// - Chronologically assigns permanent IDs to new security advisories.
397/// - Parses every `.pkg.lua` file to extract static metadata.
398/// - Populates `packages.json` (the primary index) and `advisories.json`.
399/// - Enables clients to resolve packages and vulnerabilities without cloning
400///   the entire Git repository or parsing thousands of Lua scripts.
401///
402/// # Errors
403///
404/// Returns an error if:
405/// - The path is not a Zoi registry.
406/// - The registry index files cannot be written.
407/// - Any package or advisory definition is malformed.
408pub fn generate_metadata(registry_root: &Path) -> Result<()> {
409    if !registry_root.join("repo.yaml").exists() {
410        return Err(anyhow!(
411            "Not a Zoi registry (missing repo.yaml). Run 'zoi reg init' first."
412        ));
413    }
414
415    println!("{} Generating registry metadata...", "::".bold().blue());
416
417    let repo_config_str = fs::read_to_string(registry_root.join("repo.yaml"))?;
418    let repo_config: types::RepoConfig =
419        serde_yaml::from_str(&repo_config_str)?;
420    let advisory_prefix = repo_config
421        .advisory_prefix
422        .clone()
423        .unwrap_or_else(|| "ZSA".to_string());
424
425    let advisories_json_path = registry_root.join("advisories.json");
426    let mut adv_registry: types::AdvisoryRegistry =
427        if advisories_json_path.exists() {
428            serde_json::from_str(&fs::read_to_string(&advisories_json_path)?)?
429        } else {
430            types::AdvisoryRegistry::default()
431        };
432
433    let current_year = chrono::Utc::now().year();
434    let current_year_u32 = u32::try_from(current_year).unwrap_or(0);
435    if adv_registry.year != current_year_u32 {
436        adv_registry.year = current_year_u32;
437        adv_registry.last_id = 0;
438    }
439
440    for entry in WalkDir::new(registry_root)
441        .into_iter()
442        .filter_map(Result::ok)
443    {
444        let file_name = entry.file_name().to_string_lossy();
445        if file_name.ends_with("-TEMP.sec.yaml") {
446            let path = entry.path();
447            let content_str = fs::read_to_string(path)?;
448            let mut content: serde_yaml::Value =
449                serde_yaml::from_str(&content_str)?;
450            let severity = content
451                .get("severity")
452                .and_then(|v| v.as_str())
453                .unwrap_or("low")
454                .to_lowercase();
455            let sev_char = match severity.as_str() {
456                "medium" => "B",
457                "high" => "C",
458                "critical" => "D",
459                _ => "A"
460            };
461
462            adv_registry.last_id += 1;
463            let final_id = format!(
464                "{advisory_prefix}-{current_year}-{sev_char}{:04}",
465                adv_registry.last_id
466            );
467
468            if let Some(mapping) = content.as_mapping_mut() {
469                mapping.insert(
470                    serde_yaml::Value::String("id".to_string()),
471                    serde_yaml::Value::String(final_id.clone())
472                );
473            }
474
475            let final_path =
476                path.with_file_name(format!("{final_id}.sec.yaml"));
477            fs::write(&final_path, serde_yaml::to_string(&content)?)?;
478            fs::remove_file(path)?;
479            println!("Assigned ID {} to {}", final_id.green(), path.display());
480        }
481    }
482
483    let mut advisories_map = std::collections::BTreeMap::new();
484    let mut max_id = adv_registry.last_id;
485
486    for entry in WalkDir::new(registry_root)
487        .into_iter()
488        .filter_map(Result::ok)
489    {
490        let file_name = entry.file_name().to_string_lossy();
491        if file_name.ends_with(".sec.yaml")
492            && !file_name.ends_with("-TEMP.sec.yaml")
493        {
494            let content_str = fs::read_to_string(entry.path())?;
495            let content: types::Advisory = serde_yaml::from_str(&content_str)?;
496            if let Some(last_part) = content.id.split('-').next_back() {
497                let id_num_str = if last_part.len() > 4 {
498                    &last_part[1..]
499                } else {
500                    last_part
501                };
502                if let Ok(id_num) = id_num_str.parse::<u32>() {
503                    if id_num > max_id {
504                        max_id = id_num;
505                    }
506                    let rel_path = entry.path().strip_prefix(registry_root)?;
507                    advisories_map.insert(
508                        content.id.clone(),
509                        rel_path.to_string_lossy().to_string()
510                    );
511                }
512            }
513        }
514    }
515
516    adv_registry.last_id = max_id;
517    adv_registry.advisories = advisories_map;
518    adv_registry.version = "2".to_string();
519    fs::write(
520        &advisories_json_path,
521        serde_json::to_string_pretty(&adv_registry)?
522    )?;
523
524    let mut packages_map = std::collections::BTreeMap::new();
525    let repo_types: HashMap<String, String> = repo_config
526        .repos
527        .iter()
528        .map(|r| (r.name.clone(), r.repo_type.clone()))
529        .collect();
530
531    for entry in WalkDir::new(registry_root)
532        .into_iter()
533        .filter_map(Result::ok)
534    {
535        if entry.file_type().is_file()
536            && entry.file_name().to_string_lossy().ends_with(".pkg.lua")
537        {
538            let path = entry.path();
539            let path_str = path.to_string_lossy();
540            if let Ok(pkg) =
541                zoi_lua::parser::parse_lua_package(&path_str, None, None, true)
542            {
543                let rel_path = path.strip_prefix(registry_root)?;
544                let mut repo_parts: Vec<_> = rel_path
545                    .components()
546                    .map(|c| c.as_os_str().to_string_lossy().to_string())
547                    .collect();
548                repo_parts.pop();
549                repo_parts.pop();
550                let repo_path = repo_parts.join("/");
551
552                let major_repo =
553                    repo_path.split('/').next().unwrap_or_default();
554                let repo_type = repo_types
555                    .get(major_repo)
556                    .cloned()
557                    .unwrap_or_else(|| "unofficial".to_string());
558
559                let version = pkg
560                    .version
561                    .clone()
562                    .or_else(|| {
563                        pkg.versions
564                            .as_ref()
565                            .and_then(|v| v.get("stable").cloned())
566                    })
567                    .unwrap_or_else(|| "unknown".to_string());
568
569                let mut vulns = Vec::new();
570                let pkg_dir = path.parent().ok_or_else(|| {
571                    anyhow!("Package path has no parent directory")
572                })?;
573                if let Ok(sec_entries) = fs::read_dir(pkg_dir) {
574                    for sec_entry in sec_entries.flatten() {
575                        if sec_entry
576                            .file_name()
577                            .to_string_lossy()
578                            .ends_with(".sec.yaml")
579                        {
580                            let sec_content_str =
581                                fs::read_to_string(sec_entry.path())?;
582                            if let Ok(adv) =
583                                serde_yaml::from_str::<types::Advisory>(
584                                    &sec_content_str
585                                )
586                            {
587                                vulns.push(
588                                    zoi_core::types::MiniVulnerability {
589                                        id: adv.id,
590                                        severity: format!("{:?}", adv.severity)
591                                            .to_lowercase(),
592                                        affected_range: adv.affected_range,
593                                        fixed_in: adv.fixed_in,
594                                        summary: adv.summary
595                                    }
596                                );
597                            }
598                        }
599                    }
600                }
601
602                let packages_key =
603                    format!("@{repo_path}/{name}", name = pkg.name);
604
605                let dependencies_v2 = pkg.dependencies.map(|deps| {
606                    let mut runtime = Vec::new();
607                    if let Some(r) = deps.runtime {
608                        runtime = match r {
609                            types::DependencyGroup::Simple(d) => d,
610                            types::DependencyGroup::Complex(c) => {
611                                let mut all = c.required;
612                                all.extend(c.optional);
613                                for opt in c.options {
614                                    all.extend(opt.depends);
615                                }
616                                all
617                            }
618                        };
619                    }
620
621                    let mut build = Vec::new();
622                    if let Some(b) = deps.build {
623                        match b {
624                            types::BuildDependencies::Group(g) => {
625                                let packages = match g {
626                                    types::DependencyGroup::Simple(d) => d,
627                                    types::DependencyGroup::Complex(c) => {
628                                        let mut all = c.required;
629                                        all.extend(c.optional);
630                                        for opt in c.options {
631                                            all.extend(opt.depends);
632                                        }
633                                        all
634                                    }
635                                };
636                                build.push(types::BuildDependencyV2 {
637                                    build_type: "source".to_string(),
638                                    packages
639                                });
640                            }
641                            types::BuildDependencies::Typed(t) => {
642                                for (bt, g) in t.types {
643                                    let packages = match g {
644                                        types::DependencyGroup::Simple(d) => d,
645                                        types::DependencyGroup::Complex(c) => {
646                                            let mut all = c.required;
647                                            all.extend(c.optional);
648                                            for opt in c.options {
649                                                all.extend(opt.depends);
650                                            }
651                                            all
652                                        }
653                                    };
654                                    build.push(types::BuildDependencyV2 {
655                                        build_type: bt,
656                                        packages
657                                    });
658                                }
659                            }
660                        }
661                    }
662
663                    let mut test = Vec::new();
664                    if let Some(t) = deps.test {
665                        test = match t {
666                            types::DependencyGroup::Simple(d) => d,
667                            types::DependencyGroup::Complex(c) => {
668                                let mut all = c.required;
669                                all.extend(c.optional);
670                                for opt in c.options {
671                                    all.extend(opt.depends);
672                                }
673                                all
674                            }
675                        };
676                    }
677
678                    types::DependenciesV2 {
679                        runtime,
680                        build,
681                        test
682                    }
683                });
684
685                packages_map.insert(
686                    packages_key,
687                    types::PurlPackageIndexV2 {
688                        repo: repo_path,
689                        repo_type,
690                        version,
691                        epoch: pkg.epoch,
692                        revision: pkg.revision.clone(),
693                        description: pkg.description,
694                        scope: Some(pkg.scope),
695                        scopes: pkg.scopes.clone(),
696                        dependencies: dependencies_v2,
697                        sub_packages: pkg.sub_packages.unwrap_or_default(),
698                        main_sub_packages: pkg.main_subs.unwrap_or_default(),
699                        vuln: vulns
700                    }
701                );
702            }
703        }
704    }
705
706    let index = types::RegistryIndexV2 {
707        version: "2".to_string(),
708        packages: packages_map
709    };
710    fs::write(
711        registry_root.join("packages.json"),
712        serde_json::to_string_pretty(&index)?
713    )?;
714
715    println!("{}", "Metadata generation complete.".green());
716
717    Ok(())
718}
719
720/// Checks the integrity of all package definitions in the registry.
721///
722/// This runs `zoi doctor` on every `.pkg.lua` file in the registry.
723///
724/// # Errors
725///
726/// Returns an error if any package fails the health check.
727pub fn check(registry_root: &Path) -> Result<()> {
728    if !registry_root.join("repo.yaml").exists() {
729        return Err(anyhow!(
730            "Not a Zoi registry (missing repo.yaml). Run 'zoi reg init' first."
731        ));
732    }
733
734    println!("{} Checking registry integrity...", "::".bold().blue());
735
736    let mut errors = 0;
737    let mut warnings = 0;
738
739    for entry in WalkDir::new(registry_root)
740        .into_iter()
741        .filter_map(Result::ok)
742    {
743        if entry.file_type().is_file()
744            && entry.file_name().to_string_lossy().ends_with(".pkg.lua")
745        {
746            println!(
747                "  Checking {}...",
748                entry.path().display().to_string().cyan()
749            );
750            match pkg_doctor::run(entry.path(), None, None) {
751                Ok(report) => {
752                    for error in &report.errors {
753                        eprintln!("    {} {error}", "Error:".red().bold());
754                        errors += 1;
755                    }
756                    for warning in &report.warnings {
757                        println!(
758                            "    {} {warning}",
759                            "Warning:".yellow().bold()
760                        );
761                        warnings += 1;
762                    }
763                }
764                Err(e) => {
765                    eprintln!(
766                        "    {} Failed to parse package: {e}",
767                        "Error:".red().bold(),
768                    );
769                    errors += 1;
770                }
771            }
772        }
773    }
774
775    if errors > 0 {
776        return Err(anyhow!(
777            "Registry check failed with {errors} error(s) and {warnings} \
778             warning(s).",
779        ));
780    }
781
782    println!(
783        "{} Registry check passed with {} warning(s).",
784        "::".bold().green(),
785        warnings
786    );
787    Ok(())
788}