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 =
49        fs::read_to_string(key_path).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).map_err(|e| format!("Invalid verifying key: {}", e))
57}
58
59fn load_receipt(path: &PathBuf) -> std::result::Result<Receipt, String> {
60    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read receipt: {}", e))?;
61    serde_json::from_str(&content).map_err(|e| format!("Failed to parse receipt JSON: {}", e))
62}
63
64fn resolve_key_path(public_key: Option<String>) -> Option<PathBuf> {
65    public_key.map(PathBuf::from).or_else(|| {
66        let d = PathBuf::from(".ggen/keys/public.pem");
67        d.exists().then_some(d)
68    })
69}
70
71fn do_verify(
72    receipt_path: String, public_key: Option<String>,
73) -> std::result::Result<VerifyOutput, NounVerbError> {
74    let receipt_file = PathBuf::from(&receipt_path);
75
76    if !receipt_file.exists() {
77        return Ok(VerifyOutput {
78            receipt_file: receipt_path,
79            is_valid: false,
80            message: format!("Receipt file not found: {}", receipt_file.display()),
81            operation_id: None,
82            timestamp: None,
83            input_hashes: None,
84            output_hashes: None,
85            chain_position: None,
86        });
87    }
88
89    let key_path = match resolve_key_path(public_key) {
90        Some(p) => p,
91        None => {
92            return Ok(VerifyOutput {
93                receipt_file: receipt_path,
94                is_valid: false,
95                message: "Public key required: pass --public-key <path> or ensure .ggen/keys/public.pem exists".to_string(),
96                operation_id: None,
97                timestamp: None,
98                input_hashes: None,
99                output_hashes: None,
100                chain_position: None,
101            });
102        }
103    };
104
105    let receipt = load_receipt(&receipt_file).map_err(|e| NounVerbError::execution_error(e))?;
106    let verifying_key =
107        load_verifying_key(&key_path).map_err(|e| NounVerbError::execution_error(e))?;
108    let is_valid = receipt.verify(&verifying_key).is_ok();
109
110    Ok(VerifyOutput {
111        receipt_file: receipt_path,
112        is_valid,
113        message: if is_valid {
114            "Receipt signature verified successfully".to_string()
115        } else {
116            "Signature verification failed".to_string()
117        },
118        operation_id: Some(receipt.operation_id.clone()),
119        timestamp: Some(receipt.timestamp.to_rfc3339()),
120        input_hashes: Some(receipt.input_hashes.len()),
121        output_hashes: Some(receipt.output_hashes.len()),
122        chain_position: receipt
123            .previous_receipt_hash
124            .as_ref()
125            .map(|_| "chained".to_string()),
126    })
127}
128
129fn do_info(receipt_path: String) -> std::result::Result<InfoOutput, NounVerbError> {
130    let receipt_file = PathBuf::from(&receipt_path);
131    if !receipt_file.exists() {
132        return Err(NounVerbError::execution_error(format!(
133            "Receipt file not found: {}",
134            receipt_file.display()
135        )));
136    }
137    let receipt = load_receipt(&receipt_file).map_err(|e| NounVerbError::execution_error(e))?;
138    Ok(InfoOutput {
139        receipt_file: receipt_path,
140        operation_id: receipt.operation_id,
141        timestamp: receipt.timestamp.to_rfc3339(),
142        input_hashes: receipt.input_hashes.len(),
143        output_hashes: receipt.output_hashes.len(),
144        has_previous: receipt.previous_receipt_hash.is_some(),
145        signature_present: !receipt.signature.is_empty(),
146    })
147}
148
149// ============================================================================
150// Verbs (thin wrappers; complexity ≤ 5 per Poka-Yoke gate FM-1.1)
151// ============================================================================
152
153/// Verify the cryptographic Ed25519 signature on a receipt file.
154///
155/// Pass `--public-key <path>` to supply the verifying key; falls back to
156/// `.ggen/keys/public.pem`. Returns `is_valid: true` on a valid signature.
157#[verb]
158pub fn verify(receipt_path: String, public_key: Option<String>) -> Result<VerifyOutput> {
159    do_verify(receipt_path, public_key)
160}
161
162/// Print human-readable fields from a receipt file without signature verification.
163#[verb]
164pub fn info(receipt_path: String) -> Result<InfoOutput> {
165    do_info(receipt_path)
166}