Skip to main content

zoi_cli/cmd/package/
verify.rs

1//! Implementation of the `package verify` command.
2//!
3//! Verifies the integrity of installed packages (rpm -V style, against the
4//! digests recorded at install time) or of a `.zpa` archive (against its
5//! internal pooled manifest).
6
7use std::path::PathBuf;
8
9use anyhow::Result;
10use clap::ValueEnum;
11use colored::Colorize;
12use serde_json::json;
13
14use crate::pkg::install::verify;
15use crate::pkg::{local, types};
16
17/// What to verify.
18#[derive(ValueEnum, Debug, Clone)]
19pub enum VerifyTarget {
20    /// Verify installed packages.
21    Installed,
22    /// Verify a `.zpa` archive.
23    Archive
24}
25
26/// Arguments for the `zoi package verify` command.
27#[derive(clap::Parser, Debug)]
28pub struct VerifyCommand {
29    /// What to verify.
30    #[arg(default_value = "installed")]
31    pub target: VerifyTarget,
32
33    /// Packages to verify (defaults to every installed package). Only used
34    /// with the `installed` target.
35    #[arg(value_name = "PACKAGES")]
36    pub packages: Vec<String>,
37
38    /// Path to the `.zpa` archive to verify (required with the `archive`
39    /// target).
40    #[arg(long = "file")]
41    pub archive: Option<PathBuf>,
42
43    /// Restrict verification to the given scope (installed target only).
44    #[arg(long, value_enum)]
45    pub scope: Option<crate::cli::SetupScope>,
46
47    /// Output results as JSON.
48    #[arg(long)]
49    pub json: bool
50}
51
52/// Runs the verify command.
53///
54/// # Errors
55///
56/// Returns an error if reading manifests or hashing files fails. Exits with
57/// a non-zero status when any verified item reports a problem.
58pub fn run(cmd: &VerifyCommand) -> Result<()> {
59    match cmd.target {
60        VerifyTarget::Archive => run_archive(cmd),
61        VerifyTarget::Installed => run_installed(cmd)
62    }
63}
64
65/// Runs archive-level verification against the provided file.
66fn run_archive(cmd: &VerifyCommand) -> Result<()> {
67    let Some(archive) = cmd.archive.as_ref() else {
68        return Err(anyhow::anyhow!(
69            "The --file option is required when verifying archives"
70        ));
71    };
72
73    let report = verify::verify_archive(archive)?;
74
75    if cmd.json {
76        println!("{}", serde_json::to_string_pretty(&report)?);
77    } else {
78        println!(
79            "{} Verifying archive {}",
80            "::".bold().blue(),
81            report.archive
82        );
83        if report.signed_embed {
84            println!("  Embedded signature: present");
85        }
86        println!("  Pooled files checked: {}", report.checked);
87
88        if report.ok {
89            println!("{}", "Archive integrity verified.".green());
90        } else {
91            for issue in &report.issues {
92                println!("  {}: {issue}", "PROBLEM".red().bold());
93            }
94        }
95    }
96
97    if !report.ok {
98        std::process::exit(1);
99    }
100    Ok(())
101}
102
103/// Runs installed-package verification for matching packages.
104fn run_installed(cmd: &VerifyCommand) -> Result<()> {
105    let all = local::get_installed_packages()?;
106    let manifests: Vec<_> = if cmd.packages.is_empty() {
107        all
108    } else {
109        let wanted = &cmd.packages;
110        let mut found = Vec::new();
111        for manifest in all {
112            let matches_name = wanted.iter().any(|p| {
113                p.eq_ignore_ascii_case(&manifest.name)
114                    || p.eq_ignore_ascii_case(&manifest.repo)
115                    || manifest.sub_package.as_deref() == Some(p.as_str())
116                    || format!("@{}/{}", manifest.repo, manifest.name)
117                        .eq_ignore_ascii_case(p)
118            });
119            if matches_name {
120                found.push(manifest);
121            } else {
122                eprintln!(
123                    "{}: Package not installed: {}",
124                    "Warning".yellow().bold(),
125                    // Reconstruct which name failed is ambiguous; report the
126                    // unmatched package by its identity instead.
127                    local::installed_manifest_source(&manifest)
128                );
129            }
130        }
131        found
132    };
133
134    let manifests: Vec<_> = manifests
135        .into_iter()
136        .filter(|m| {
137            cmd.scope.is_none_or(|s| {
138                matches!(
139                    (s, m.scope),
140                    (crate::cli::SetupScope::User, types::Scope::User)
141                        | (
142                            crate::cli::SetupScope::System,
143                            types::Scope::System
144                        )
145                )
146            })
147        })
148        .collect();
149
150    if manifests.is_empty() {
151        if !cmd.json {
152            println!("No installed packages matched.");
153        }
154        return Ok(());
155    }
156
157    let mut total_bad = 0usize;
158    let mut json_reports = Vec::new();
159
160    for manifest in &manifests {
161        let statuses = verify::verify_installed(manifest)?;
162        let bad: Vec<_> = statuses
163            .iter()
164            .filter(|s| {
165                !matches!(
166                    s.status,
167                    verify::VerifyStatus::Ok | verify::VerifyStatus::Unverified
168                )
169            })
170            .collect();
171
172        if cmd.json {
173            json_reports.push(json!({
174                "name": manifest.name,
175                "version": manifest.version,
176                "sub_package": manifest.sub_package,
177                "scope": format!("{:?}", manifest.scope).to_lowercase(),
178                "files": statuses,
179                "ok": bad.is_empty()
180            }));
181        } else {
182            let display_name = match &manifest.sub_package {
183                Some(sub) => format!("{}:{sub}", manifest.name),
184                None => manifest.name.clone()
185            };
186            let scope_tag = match manifest.scope {
187                types::Scope::User => "",
188                types::Scope::System => " [system]",
189                types::Scope::Project => " [project]"
190            };
191            println!(
192                "{} {} v{}{scope_tag}",
193                "::".bold().blue(),
194                display_name.cyan(),
195                manifest.version.yellow()
196            );
197
198            for status in &statuses {
199                if !matches!(
200                    status.status,
201                    verify::VerifyStatus::Ok | verify::VerifyStatus::Unverified
202                ) {
203                    println!(
204                        "  {}: {}",
205                        status.status.as_tag().red().bold(),
206                        status.path
207                    );
208                }
209            }
210
211            if bad.is_empty() {
212                let unverified = statuses
213                    .iter()
214                    .filter(|s| s.status == verify::VerifyStatus::Unverified)
215                    .count();
216                if unverified > 0 {
217                    println!(
218                        "  {} ({unverified} file(s) unverified)",
219                        "no problems detected".green()
220                    );
221                } else {
222                    println!("{}", "all files verified".green());
223                }
224            } else {
225                total_bad += bad.len();
226            }
227        }
228    }
229
230    if cmd.json {
231        println!("{}", serde_json::to_string_pretty(&json_reports)?);
232    } else if total_bad > 0 {
233        eprintln!(
234            "{}: {total_bad} file(s) failed verification",
235            "FAILED".red().bold()
236        );
237        std::process::exit(1);
238    }
239
240    Ok(())
241}