gn_cli/commands/
verify.rs1use anyhow::{anyhow, Result};
2use clap::Args;
3use gn_core::NotesEngine;
4use super::signing::{verify_signature, SignatureStatus};
5
6#[derive(Args, Debug, Clone)]
7pub struct VerifyArgs {
8 pub note_id: Option<String>,
10
11 #[arg(short, long)]
13 pub all: bool,
14
15 #[arg(short, long)]
17 pub namespace: Option<String>,
18}
19
20pub fn run(args: &VerifyArgs) -> Result<()> {
21 let engine = NotesEngine::new(".");
22 let namespaces = match &args.namespace {
23 Some(ns) => vec![ns.clone()],
24 None => vec![
25 "comments".to_string(),
26 "review".to_string(),
27 "todos".to_string(),
28 ],
29 };
30
31 let mut all_notes = Vec::new();
32 for ns in &namespaces {
33 let namespace_enum = gn_core::Namespace::Custom(ns.clone());
34 if let Ok(notes) = engine.read_notes(&namespace_enum) {
35 all_notes.extend(notes);
36 }
37 }
38
39 if all_notes.is_empty() {
40 println!("No notes found in repository to verify.");
41 return Ok(());
42 }
43
44 if args.all || args.note_id.is_none() {
45 println!("\n\x1b[1;36m🔐 Cryptographic Note Signature Verification\x1b[0m\n");
46 println!(
47 "{:<10} {:<24} {:<28} {}",
48 "ID", "AUTHOR", "STATUS BADGE", "DETAILS"
49 );
50 println!("{}", "─".repeat(80));
51
52 let mut signed_count = 0;
53 let mut unsigned_count = 0;
54 let mut bad_count = 0;
55
56 for note in &all_notes {
57 let payload = note.signing_payload();
58 let result = verify_signature(&payload, note.signature.as_deref(), ¬e.author);
59 let id_short = ¬e.id.to_string()[..8];
60 let author_short = note.author.split('<').next().unwrap_or(¬e.author).trim();
61
62 let badge = match result.status {
63 SignatureStatus::Valid => {
64 signed_count += 1;
65 format!(
66 "\x1b[32m[✔ Signed by {}]\x1b[0m",
67 result.signer.as_deref().unwrap_or(author_short)
68 )
69 }
70 SignatureStatus::Unsigned => {
71 unsigned_count += 1;
72 "\x1b[33m[⚠ Unsigned]\x1b[0m".to_string()
73 }
74 SignatureStatus::Bad => {
75 bad_count += 1;
76 "\x1b[31m[✗ Bad Signature]\x1b[0m".to_string()
77 }
78 };
79
80 let details = result.details.unwrap_or_default();
81 println!(
82 "{:<10} {:<24} {:<38} {}",
83 id_short, author_short, badge, details
84 );
85 }
86
87 println!("\nSummary: \x1b[32m{} valid\x1b[0m, \x1b[33m{} unsigned\x1b[0m, \x1b[31m{} invalid\x1b[0m (Total: {})",
88 signed_count, unsigned_count, bad_count, all_notes.len()
89 );
90
91 if bad_count > 0 {
92 return Err(anyhow!("One or more notes failed cryptographic signature verification."));
93 }
94
95 return Ok(());
96 }
97
98 let raw_id = args.note_id.as_ref().unwrap();
99 let target = raw_id.trim().trim_start_matches('#');
100 let found_note = if target.eq_ignore_ascii_case("latest") || target == "^" {
101 all_notes.last().cloned()
102 } else if let Ok(idx) = target.parse::<usize>() {
103 if idx >= 1 && idx <= all_notes.len() {
104 Some(all_notes[idx - 1].clone())
105 } else {
106 None
107 }
108 } else {
109 all_notes
110 .iter()
111 .find(|n| n.id.to_string().starts_with(target))
112 .cloned()
113 };
114
115 let note = found_note.ok_or_else(|| anyhow!("Target note '{}' not found", raw_id))?;
116 let payload = note.signing_payload();
117 let result = verify_signature(&payload, note.signature.as_deref(), ¬e.author);
118
119 let author_short = note.author.split('<').next().unwrap_or(¬e.author).trim();
120 let badge = match result.status {
121 SignatureStatus::Valid => {
122 format!(
123 "\x1b[32m[✔ Signed by {}]\x1b[0m",
124 result.signer.as_deref().unwrap_or(author_short)
125 )
126 }
127 SignatureStatus::Unsigned => "\x1b[33m[⚠ Unsigned]\x1b[0m".to_string(),
128 SignatureStatus::Bad => "\x1b[31m[✗ Bad Signature]\x1b[0m".to_string(),
129 };
130
131 println!("\nNote: {}", note.id);
132 println!("Author: {}", note.author);
133 println!("Status: {}", badge);
134 if let Some(details) = result.details {
135 println!("Details: {}", details);
136 }
137 if let Some(ref sig) = note.signature {
138 println!("\nSignature:\n{}", sig.trim());
139 }
140 println!();
141
142 if result.status == SignatureStatus::Bad {
143 return Err(anyhow!("Note {} has a bad cryptographic signature.", note.id));
144 }
145
146 Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::commands::signing::{verify_signature, SignatureStatus};
153 use gn_core::note::Note;
154 use gn_core::Namespace;
155
156 #[test]
157 fn test_verify_unsigned_note() {
158 let note = Note::new(
159 "dummy_sha".to_string(),
160 Some("main.rs".to_string()),
161 Some(10),
162 Some(12),
163 "Unsigned review comment".to_string(),
164 "Alice <alice@example.com>".to_string(),
165 Namespace::Comments,
166 );
167
168 let payload = note.signing_payload();
169 let res = verify_signature(&payload, note.signature.as_deref(), ¬e.author);
170 assert_eq!(res.status, SignatureStatus::Unsigned);
171 }
172
173 #[test]
174 fn test_verify_bad_signature_format() {
175 let mut note = Note::new(
176 "dummy_sha".to_string(),
177 None,
178 None,
179 None,
180 "Tampered note".to_string(),
181 "Bob <bob@example.com>".to_string(),
182 Namespace::Comments,
183 );
184 note.signature = Some("not-a-valid-armor-signature".to_string());
185
186 let payload = note.signing_payload();
187 let res = verify_signature(&payload, note.signature.as_deref(), ¬e.author);
188 assert_eq!(res.status, SignatureStatus::Bad);
189 }
190
191 #[test]
192 fn test_verify_args_parsing() {
193 let args = VerifyArgs {
194 note_id: Some("1".to_string()),
195 all: false,
196 namespace: Some("comments".to_string()),
197 };
198 assert_eq!(args.note_id.as_deref(), Some("1"));
199 assert!(!args.all);
200 }
201}