Skip to main content

gn_cli/commands/
verify.rs

1use 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    /// Note ID or quick-index to verify
9    pub note_id: Option<String>,
10
11    /// Verify all notes in the repository
12    #[arg(short, long)]
13    pub all: bool,
14
15    /// Filter by namespace (e.g., "comments", "review")
16    #[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(), &note.author);
59            let id_short = &note.id.to_string()[..8];
60            let author_short = note.author.split('<').next().unwrap_or(&note.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(), &note.author);
118
119    let author_short = note.author.split('<').next().unwrap_or(&note.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(), &note.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(), &note.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}