Skip to main content

ggen_cli_lib/cmds/
receipt.rs

1//! Receipt Commands
2//!
3//! Exposes cryptographic receipt verification via the `ggen receipt` noun:
4//!   - `ggen receipt verify <receipt-path> [--public-key <key-path>]`
5//!   - `ggen receipt info <receipt-path>`
6
7use clap_noun_verb::{NounVerbError, Result};
8use clap_noun_verb_macros::verb;
9use ed25519_dalek::VerifyingKey;
10use ggen_core::receipt::Receipt;
11use serde::Serialize;
12use std::{fs, path::PathBuf};
13
14// ============================================================================
15// Output Types
16// ============================================================================
17
18/// Output from `ggen receipt verify`
19#[derive(Serialize)]
20pub struct VerifyOutput {
21    pub receipt_file: String,
22    pub is_valid: bool,
23    pub message: String,
24    pub operation_id: Option<String>,
25    pub timestamp: Option<String>,
26    pub input_hashes: Option<usize>,
27    pub output_hashes: Option<usize>,
28    pub chain_position: Option<String>,
29}
30
31/// Output from `ggen receipt info`
32#[derive(Serialize)]
33pub struct InfoOutput {
34    pub receipt_file: String,
35    pub operation_id: String,
36    pub timestamp: String,
37    pub input_hashes: usize,
38    pub output_hashes: usize,
39    pub has_previous: bool,
40    pub signature_present: bool,
41}
42
43// ============================================================================
44// Domain helpers (keep verbs thin for Poka-Yoke complexity gate FM-1.1)
45// ============================================================================
46
47fn load_verifying_key(key_path: &PathBuf) -> std::result::Result<VerifyingKey, String> {
48    let key_content = fs::read_to_string(key_path)
49        .map_err(|e| format!("Failed to read public key: {}", e))?;
50    let key_bytes = hex::decode(key_content.trim())
51        .map_err(|e| format!("Failed to decode public key hex: {}", e))?;
52    let key_array: [u8; 32] = key_bytes
53        .as_slice()
54        .try_into()
55        .map_err(|_| "Public key must be exactly 32 bytes".to_string())?;
56    VerifyingKey::from_bytes(&key_array)
57        .map_err(|e| format!("Invalid verifying key: {}", e))
58}
59
60fn load_receipt(path: &PathBuf) -> std::result::Result<Receipt, String> {
61    let content = fs::read_to_string(path)
62        .map_err(|e| format!("Failed to read receipt: {}", e))?;
63    serde_json::from_str(&content)
64        .map_err(|e| format!("Failed to parse receipt JSON: {}", e))
65}
66
67fn resolve_key_path(public_key: Option<String>) -> Option<PathBuf> {
68    public_key.map(PathBuf::from).or_else(|| {
69        let d = PathBuf::from(".ggen/keys/public.pem");
70        d.exists().then_some(d)
71    })
72}
73
74fn do_verify(receipt_path: String, public_key: Option<String>) -> std::result::Result<VerifyOutput, NounVerbError> {
75    let receipt_file = PathBuf::from(&receipt_path);
76
77    if !receipt_file.exists() {
78        return Ok(VerifyOutput {
79            receipt_file: receipt_path,
80            is_valid: false,
81            message: format!("Receipt file not found: {}", receipt_file.display()),
82            operation_id: None,
83            timestamp: None,
84            input_hashes: None,
85            output_hashes: None,
86            chain_position: None,
87        });
88    }
89
90    let key_path = match resolve_key_path(public_key) {
91        Some(p) => p,
92        None => {
93            return Ok(VerifyOutput {
94                receipt_file: receipt_path,
95                is_valid: false,
96                message: "Public key required: pass --public-key <path> or ensure .ggen/keys/public.pem exists".to_string(),
97                operation_id: None,
98                timestamp: None,
99                input_hashes: None,
100                output_hashes: None,
101                chain_position: None,
102            });
103        }
104    };
105
106    let receipt = load_receipt(&receipt_file)
107        .map_err(|e| NounVerbError::execution_error(e))?;
108    let verifying_key = load_verifying_key(&key_path)
109        .map_err(|e| NounVerbError::execution_error(e))?;
110    let is_valid = receipt.verify(&verifying_key).is_ok();
111
112    Ok(VerifyOutput {
113        receipt_file: receipt_path,
114        is_valid,
115        message: if is_valid {
116            "Receipt signature verified successfully".to_string()
117        } else {
118            "Signature verification failed".to_string()
119        },
120        operation_id: Some(receipt.operation_id.clone()),
121        timestamp: Some(receipt.timestamp.to_rfc3339()),
122        input_hashes: Some(receipt.input_hashes.len()),
123        output_hashes: Some(receipt.output_hashes.len()),
124        chain_position: receipt
125            .previous_receipt_hash
126            .as_ref()
127            .map(|_| "chained".to_string()),
128    })
129}
130
131fn do_info(receipt_path: String) -> std::result::Result<InfoOutput, NounVerbError> {
132    let receipt_file = PathBuf::from(&receipt_path);
133    if !receipt_file.exists() {
134        return Err(NounVerbError::execution_error(format!(
135            "Receipt file not found: {}",
136            receipt_file.display()
137        )));
138    }
139    let receipt = load_receipt(&receipt_file)
140        .map_err(|e| NounVerbError::execution_error(e))?;
141    Ok(InfoOutput {
142        receipt_file: receipt_path,
143        operation_id: receipt.operation_id,
144        timestamp: receipt.timestamp.to_rfc3339(),
145        input_hashes: receipt.input_hashes.len(),
146        output_hashes: receipt.output_hashes.len(),
147        has_previous: receipt.previous_receipt_hash.is_some(),
148        signature_present: !receipt.signature.is_empty(),
149    })
150}
151
152// ============================================================================
153// Verbs (thin wrappers; complexity ≤ 5 per Poka-Yoke gate FM-1.1)
154// ============================================================================
155
156/// Verify the cryptographic Ed25519 signature on a receipt file.
157///
158/// Pass `--public-key <path>` to supply the verifying key; falls back to
159/// `.ggen/keys/public.pem`. Returns `is_valid: true` on a valid signature.
160#[verb]
161pub fn verify(receipt_path: String, public_key: Option<String>) -> Result<VerifyOutput> {
162    do_verify(receipt_path, public_key)
163}
164
165/// Print human-readable fields from a receipt file without signature verification.
166#[verb]
167pub fn info(receipt_path: String) -> Result<InfoOutput> {
168    do_info(receipt_path)
169}