unified_verify.rs
1 //! Unified verify command: verifies a git commit OR an attestation file. 2 3 use anyhow::Result; 4 use clap::Parser; 5 use std::path::{Path, PathBuf}; 6 7 use super::verify_commit::{VerifyCommitCommand, handle_verify_commit}; 8 use crate::commands::device::verify_attestation::{VerifyCommand, handle_verify}; 9 10 /// What kind of target the user provided. 11 pub enum VerifyTarget { 12 GitRef(String), 13 Attestation(String), 14 } 15 16 /// Determine whether `raw_target` is a Git reference or an attestation path. 17 /// 18 /// Rules (evaluated in order): 19 /// 1. "-" → stdin attestation 20 /// 2. Path exists on disk → attestation file 21 /// 3. Contains ".." (range notation) → git ref 22 /// 4. Is "HEAD" or matches ^[0-9a-f]{4,40}$ → git ref 23 /// 5. Otherwise → git ref (assume the user knows what they're typing) 24 /// 25 /// Args: 26 /// * `raw_target` - Raw CLI input string. 27 /// 28 /// Usage: 29 /// ```ignore 30 /// let t = parse_verify_target("HEAD"); 31 /// assert!(matches!(t, VerifyTarget::GitRef(_))); 32 /// ``` 33 pub fn parse_verify_target(raw_target: &str) -> VerifyTarget { 34 if raw_target == "-" { 35 return VerifyTarget::Attestation(raw_target.to_string()); 36 } 37 let path = Path::new(raw_target); 38 if path.exists() { 39 return VerifyTarget::Attestation(raw_target.to_string()); 40 } 41 if raw_target.contains("..") { 42 return VerifyTarget::GitRef(raw_target.to_string()); 43 } 44 if raw_target.eq_ignore_ascii_case("HEAD") { 45 return VerifyTarget::GitRef(raw_target.to_string()); 46 } 47 // 4-40 hex chars → commit hash 48 let is_hex = raw_target.len() >= 4 49 && raw_target.len() <= 40 50 && raw_target.chars().all(|c| c.is_ascii_hexdigit()); 51 if is_hex { 52 return VerifyTarget::GitRef(raw_target.to_string()); 53 } 54 // Fallback: treat as git ref. The execution layer (handle_verify_commit) will 55 // return a clear error if the ref doesn't resolve in the git repo, so no 56 // silent data loss occurs from a typoed filename being misclassified. 57 VerifyTarget::GitRef(raw_target.to_string()) 58 } 59 60 /// Unified verify command: verifies a signed commit or an attestation. 61 #[derive(Parser, Debug, Clone)] 62 #[command(about = "Verify a signed commit or attestation.")] 63 pub struct UnifiedVerifyCommand { 64 /// Git ref, commit hash, range (e.g. HEAD, abc1234, main..HEAD), 65 /// or path to an attestation JSON file / "-" for stdin. 66 #[arg(default_value = "HEAD")] 67 pub target: String, 68 69 /// Path to allowed signers file (commit verification). 70 #[arg(long, default_value = ".auths/allowed_signers")] 71 pub allowed_signers: PathBuf, 72 73 /// Path to identity bundle JSON (for CI/CD stateless commit verification). 74 #[arg(long, value_parser)] 75 pub identity_bundle: Option<PathBuf>, 76 77 /// Issuer public key in hex format (attestation verification). 78 #[arg(long = "issuer-pk")] 79 pub issuer_pk: Option<String>, 80 81 /// Issuer identity ID for attestation trust-based key resolution. 82 #[arg(long = "issuer-did", visible_alias = "issuer")] 83 pub issuer_did: Option<String>, 84 85 /// Path to witness receipts JSON file. 86 #[arg(long)] 87 pub witness_receipts: Option<PathBuf>, 88 89 /// Witness quorum threshold. 90 #[arg(long, default_value = "1")] 91 pub witness_threshold: usize, 92 93 /// Witness public keys as DID:hex pairs. 94 #[arg(long, num_args = 1..)] 95 pub witness_keys: Vec<String>, 96 } 97 98 /// Handle the unified verify command. 99 /// 100 /// Routes to commit verification or attestation verification based on target type. 101 /// 102 /// Args: 103 /// * `cmd` - Parsed UnifiedVerifyCommand. 104 pub async fn handle_verify_unified(cmd: UnifiedVerifyCommand) -> Result<()> { 105 match parse_verify_target(&cmd.target) { 106 VerifyTarget::GitRef(ref_str) => { 107 let commit_cmd = VerifyCommitCommand { 108 commit: ref_str, 109 allowed_signers: cmd.allowed_signers, 110 identity_bundle: cmd.identity_bundle, 111 witness_receipts: cmd.witness_receipts, 112 witness_threshold: cmd.witness_threshold, 113 witness_keys: cmd.witness_keys, 114 }; 115 handle_verify_commit(commit_cmd).await 116 } 117 VerifyTarget::Attestation(path_str) => { 118 let verify_cmd = VerifyCommand { 119 attestation: path_str, 120 issuer_pk: cmd.issuer_pk, 121 issuer_did: cmd.issuer_did, 122 trust: None, 123 roots_file: None, 124 require_capability: None, 125 witness_receipts: cmd.witness_receipts, 126 witness_threshold: cmd.witness_threshold, 127 witness_keys: cmd.witness_keys, 128 }; 129 handle_verify(verify_cmd).await 130 } 131 } 132 } 133 134 impl crate::commands::executable::ExecutableCommand for UnifiedVerifyCommand { 135 fn execute(&self, _ctx: &crate::config::CliConfig) -> anyhow::Result<()> { 136 let rt = tokio::runtime::Runtime::new()?; 137 rt.block_on(handle_verify_unified(self.clone())) 138 } 139 } 140 141 #[cfg(test)] 142 mod tests { 143 use super::*; 144 145 #[test] 146 fn test_parse_verify_target_git_ref() { 147 assert!(matches!( 148 parse_verify_target("HEAD"), 149 VerifyTarget::GitRef(_) 150 )); 151 assert!(matches!( 152 parse_verify_target("abc1234"), 153 VerifyTarget::GitRef(_) 154 )); 155 assert!(matches!( 156 parse_verify_target("main..HEAD"), 157 VerifyTarget::GitRef(_) 158 )); 159 } 160 161 #[test] 162 fn test_parse_verify_target_stdin() { 163 assert!(matches!( 164 parse_verify_target("-"), 165 VerifyTarget::Attestation(_) 166 )); 167 } 168 169 #[test] 170 fn test_parse_verify_target_nonexistent_defaults_to_git_ref() { 171 let target = parse_verify_target("/nonexistent/attestation.json"); 172 assert!(matches!(target, VerifyTarget::GitRef(_))); 173 } 174 175 #[test] 176 fn test_parse_verify_target_file() { 177 use std::fs::File; 178 use tempfile::tempdir; 179 let dir = tempdir().unwrap(); 180 let f = dir.path().join("attestation.json"); 181 File::create(&f).unwrap(); 182 let target = parse_verify_target(f.to_str().unwrap()); 183 assert!(matches!(target, VerifyTarget::Attestation(_))); 184 } 185 }