/ crates / auths-cli / src / commands / unified_verify.rs
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  }