Skip to main content

packc/cli/
info_cmd.rs

1//! Dispatcher for the `greentic-pack info` subcommand.
2//!
3//! Reads a `.gtpack` archive, projects it into an
4//! [`InfoReport`](super::info::InfoReport), and prints either a human-readable
5//! summary or a JSON document (sorted keys, pretty-printed).
6//!
7//! Exit-code mapping (applied by [`run_with_cli`](super::run_with_cli) when
8//! this dispatcher returns an error):
9//! * `2` — path validation failed (missing file, not a regular file, or the
10//!   extension is not `.gtpack`).
11//! * `3` — strict mode requested but the pack is unsigned or its signature is
12//!   invalid.
13//! * `1` — any other failure (archive corruption, I/O error, ...).
14//!
15//! Errors returned from this module use stable message prefixes so the caller
16//! can cheaply classify them. The prefixes come from the `cli.info.error.*`
17//! i18n keys (English template), which keeps the programmatic check aligned
18//! with the user-facing text.
19
20use std::path::Path;
21
22use anyhow::{Result, anyhow};
23use greentic_pack::{SigningPolicy, open_pack};
24use serde_json::Value;
25
26use super::info::InfoReport;
27use super::info::report::{SignatureInfo, SignatureStatus};
28use super::inspect::InspectFormat;
29
30/// Stable error-message prefix emitted when the user-supplied path is not a
31/// readable `.gtpack` file. Matched in `run_with_cli` to set exit code `2`.
32pub(crate) const ERR_NOT_A_PACK: &str = "not a .gtpack file";
33
34/// Stable error-message prefix emitted when `--strict` rejected an unsigned or
35/// invalidly-signed pack. Matched in `run_with_cli` to set exit code `3`.
36pub(crate) const ERR_STRICT_UNSIGNED: &str = "Signature verification failed";
37
38/// Entry point for `greentic-pack info <PATH> [--format human|json] [--strict]`.
39pub fn handle(path: &Path, format: InspectFormat, strict: bool) -> Result<()> {
40    validate_path(path)?;
41
42    let policy = if strict {
43        SigningPolicy::Strict
44    } else {
45        SigningPolicy::DevOk
46    };
47
48    let load = open_pack(path, policy).map_err(|e| {
49        // `open_pack` in strict mode rejects unsigned packs for the legacy
50        // manifest variant — re-emit with the strict-unsigned prefix so the
51        // caller maps to exit code 3.
52        if strict {
53            anyhow!("{ERR_STRICT_UNSIGNED}: {}", e.message)
54        } else {
55            anyhow!("Failed to read pack: {}", e.message)
56        }
57    })?;
58
59    // The newer `Gpack` manifest branch in `open_pack_inner` does not enforce
60    // `SigningPolicy::Strict` — it downgrades to a warning and leaves
61    // `signature_ok == false`. Make `--strict` behave consistently across both
62    // manifest variants by rejecting any unsigned / invalidly-signed load here.
63    if strict && !load.report.signature_ok {
64        return Err(anyhow!(
65            "{ERR_STRICT_UNSIGNED}: pack is unsigned or signature is invalid"
66        ));
67    }
68
69    // TODO(upstream): distinguish Invalid from Unsigned signature states.
70    // The underlying VerifyReport exposes only `signature_ok: bool` and a free-text
71    // `warnings: Vec<String>`. A present-but-invalid signature (bad digest / wrong key)
72    // is currently indistinguishable from a missing signature via the public library
73    // API, so we collapse both into Unsigned here. When greentic-pack-lib grows a
74    // typed signature-state (e.g. `SignatureOutcome::{Ok, Missing, Invalid{..}}`),
75    // map the Invalid arm onto `SignatureStatus::Invalid` and populate key_id from
76    // the same source. A5's human-formatter already handles Invalid correctly
77    // (crates/packc/src/cli/info/human.rs::signature_line).
78    let sig = if load.report.signature_ok {
79        SignatureInfo {
80            status: SignatureStatus::Signed,
81            key_id: None,
82        }
83    } else {
84        SignatureInfo {
85            status: SignatureStatus::Unsigned,
86            key_id: None,
87        }
88    };
89
90    let report = InfoReport::from_pack_meta_and_signature(&load.manifest.meta, sig);
91
92    match format {
93        InspectFormat::Json => {
94            let value: Value = serde_json::to_value(&report)?;
95            let sorted = super::inspect::sort_json(value);
96            println!("{}", serde_json::to_string_pretty(&sorted)?);
97        }
98        InspectFormat::Human => {
99            print!("{}", super::info::human::render(&report));
100        }
101    }
102
103    Ok(())
104}
105
106/// Validate the user-supplied path before opening the archive. Returns an
107/// error whose message begins with [`ERR_NOT_A_PACK`] on any failure so the
108/// caller can map the outcome to exit code 2.
109fn validate_path(path: &Path) -> Result<()> {
110    if !path.exists() {
111        return Err(anyhow!(
112            "{ERR_NOT_A_PACK}: {} (no such file)",
113            path.display()
114        ));
115    }
116    if !path.is_file() {
117        return Err(anyhow!(
118            "{ERR_NOT_A_PACK}: {} (not a regular file)",
119            path.display()
120        ));
121    }
122    let ext_ok = path
123        .extension()
124        .and_then(|s| s.to_str())
125        .map(|ext| ext.eq_ignore_ascii_case("gtpack"))
126        .unwrap_or(false);
127    if !ext_ok {
128        return Err(anyhow!(
129            "{ERR_NOT_A_PACK}: {} (expected .gtpack extension)",
130            path.display()
131        ));
132    }
133    Ok(())
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn validate_path_rejects_missing() {
142        let err = validate_path(Path::new("/definitely/not/here.gtpack")).unwrap_err();
143        assert!(err.to_string().starts_with(ERR_NOT_A_PACK));
144    }
145
146    #[test]
147    fn validate_path_rejects_wrong_extension() {
148        // Cargo.toml exists inside the crate, so the `exists()` + `is_file()`
149        // checks pass and we exercise the extension branch.
150        let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
151        let err = validate_path(&manifest).unwrap_err();
152        let msg = err.to_string();
153        assert!(msg.starts_with(ERR_NOT_A_PACK));
154        assert!(msg.contains("expected .gtpack extension"));
155    }
156}