Skip to main content

auths_cli/commands/
unified_verify.rs

1//! Unified verify command: verifies a git commit OR an attestation file.
2
3use anyhow::Result;
4use clap::Parser;
5use std::path::{Path, PathBuf};
6
7use super::verify_commit::{VerifyCommitCommand, handle_verify_commit};
8use crate::commands::device::verify_attestation::{VerifyCommand, handle_verify};
9
10/// What kind of target the user provided.
11pub 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/// ```
33pub 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.")]
63pub 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")]
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.
104pub 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
134impl 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)]
142mod 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}