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}