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::artifact::verify::handle_verify as handle_artifact_verify;
9use crate::commands::device::verify_attestation::{VerifyCommand, handle_verify};
10
11/// What kind of target the user provided.
12pub enum VerifyTarget {
13    GitRef(String),
14    Attestation(String),
15    ArtifactFile(PathBuf), // binary artifact, will look up .auths.json sidecar
16}
17
18/// Determine whether `raw_target` is a Git reference or an attestation path.
19///
20/// Rules (evaluated in order):
21/// 1. "-" → stdin attestation
22/// 2. Path exists on disk and is JSON → attestation file
23/// 3. Path exists on disk and is not JSON → artifact file (sidecar lookup)
24/// 4. Contains ".." (range notation) → git ref
25/// 5. Is "HEAD" or matches ^[0-9a-f]{4,40}$ → git ref
26/// 6. Otherwise → git ref (assume the user knows what they're typing)
27///
28/// Args:
29/// * `raw_target` - Raw CLI input string.
30///
31/// Usage:
32/// ```ignore
33/// let t = parse_verify_target("HEAD");
34/// assert!(matches!(t, VerifyTarget::GitRef(_)));
35/// ```
36pub fn parse_verify_target(raw_target: &str) -> VerifyTarget {
37    if raw_target == "-" {
38        return VerifyTarget::Attestation(raw_target.to_string());
39    }
40    let path = Path::new(raw_target);
41    if path.exists() {
42        if is_attestation_path(raw_target) {
43            return VerifyTarget::Attestation(raw_target.to_string());
44        } else {
45            return VerifyTarget::ArtifactFile(path.to_path_buf());
46        }
47    }
48    if raw_target.contains("..") {
49        return VerifyTarget::GitRef(raw_target.to_string());
50    }
51    if raw_target.eq_ignore_ascii_case("HEAD") {
52        return VerifyTarget::GitRef(raw_target.to_string());
53    }
54    // 4-40 hex chars → commit hash
55    let is_hex = raw_target.len() >= 4
56        && raw_target.len() <= 40
57        && raw_target.chars().all(|c| c.is_ascii_hexdigit());
58    if is_hex {
59        return VerifyTarget::GitRef(raw_target.to_string());
60    }
61    // Fallback: treat as git ref. The execution layer (handle_verify_commit) will
62    // return a clear error if the ref doesn't resolve in the git repo, so no
63    // silent data loss occurs from a typoed filename being misclassified.
64    VerifyTarget::GitRef(raw_target.to_string())
65}
66
67/// Returns true if the path looks like an attestation/JSON file rather than a binary artifact.
68fn is_attestation_path(path: &str) -> bool {
69    let lower = path.to_lowercase();
70    lower.ends_with(".json")
71}
72
73/// Unified verify command: verifies a signed commit or an attestation.
74#[derive(Parser, Debug, Clone)]
75#[command(
76    about = "Verify a signed commit or attestation.",
77    after_help = "Examples:
78  auths verify HEAD                       # Verify current commit signature
79  auths verify main..HEAD                 # Verify range of commits
80  auths verify release.tar.gz             # Verify artifact (finds .auths.json sidecar)
81  auths verify release.tar.gz.auths.json  # Verify attestation file directly
82  auths verify - < artifact.json          # Verify from stdin
83
84Artifact Verification:
85  Pass the artifact file directly — auths finds <file>.auths.json automatically.
86  Pass --signature to override the default sidecar path.
87
88Exit codes:
89  0  verified
90  1  verification failed (signature, trust, or untrusted/unresolvable signer)
91  2  could not attempt (I/O error, malformed input, missing repository)
92
93Related:
94  auths sign     — Create signatures
95  auths publish  — Sign and publish to registry
96  auths trust    — Manage trusted identities"
97)]
98pub struct UnifiedVerifyCommand {
99    /// Git ref, commit hash, range (e.g. HEAD, abc1234, main..HEAD),
100    /// or path to an attestation JSON file / "-" for stdin.
101    #[arg(default_value = "HEAD")]
102    pub target: String,
103
104    /// Path to identity bundle JSON (for CI/CD stateless commit verification).
105    #[arg(long, value_parser)]
106    pub identity_bundle: Option<PathBuf>,
107
108    /// Signer public key in hex format (attestation verification).
109    #[arg(long = "signer-key")]
110    pub issuer_pk: Option<String>,
111
112    /// Signer identity ID for attestation trust-based key resolution.
113    #[arg(long = "signer", visible_alias = "issuer-did")]
114    pub issuer_did: Option<String>,
115
116    /// Path to witness signatures JSON file.
117    #[arg(long = "witness-signatures")]
118    pub witness_receipts: Option<PathBuf>,
119
120    /// Number of witnesses required.
121    #[arg(long = "witnesses-required", default_value = "1")]
122    pub witness_threshold: usize,
123
124    /// Witness public keys as DID:hex pairs.
125    #[arg(long, num_args = 1..)]
126    pub witness_keys: Vec<String>,
127
128    /// Path to signature file. Only used when verifying an artifact file (not a commit).
129    /// Defaults to <FILE>.auths.json.
130    #[arg(long, value_name = "PATH")]
131    pub signature: Option<PathBuf>,
132
133    /// Override the git remote a signer's KEL is fetched from when it is absent
134    /// locally. Defaults to the repo's own `origin`. The local registry stays the
135    /// trusted floor — a remote can only advance the key-state, never roll it
136    /// back, and nothing is fetched when the KEL is already local.
137    #[arg(long)]
138    pub remote: Option<String>,
139
140    /// Fetch signer KELs over HTTP from this OOBI base URL (SSRF-hardened:
141    /// HTTPS-only, no redirects, private/loopback hosts blocked). Takes
142    /// precedence over `--remote`.
143    #[arg(long)]
144    pub oobi: Option<String>,
145
146    /// Fail verification when the signer's root KEL has not reached witness
147    /// quorum (fail-closed). Default: warn and continue.
148    #[arg(long = "require-witnesses")]
149    pub require_witnesses: bool,
150}
151
152/// Handle the unified verify command.
153///
154/// Routes to commit verification or attestation verification based on target type.
155///
156/// Args:
157/// * `cmd` - Parsed UnifiedVerifyCommand.
158pub async fn handle_verify_unified(
159    cmd: UnifiedVerifyCommand,
160    env_config: &auths_sdk::core_config::EnvironmentConfig,
161) -> Result<()> {
162    match parse_verify_target(&cmd.target) {
163        VerifyTarget::GitRef(ref_str) => {
164            let commit_cmd = VerifyCommitCommand {
165                commit: ref_str,
166                witness_receipts: cmd.witness_receipts,
167                witness_threshold: cmd.witness_threshold,
168                witness_keys: cmd.witness_keys,
169                remote: cmd.remote,
170                oobi: cmd.oobi,
171                require_witnesses: cmd.require_witnesses,
172                // Honor --identity-bundle for commit/git-ref targets (previously dropped
173                // here, silently verifying against the wrong/default trust root).
174                identity_bundle: cmd.identity_bundle,
175            };
176            handle_verify_commit(commit_cmd, env_config).await
177        }
178        VerifyTarget::Attestation(path_str) => {
179            let verify_cmd = VerifyCommand {
180                attestation: path_str,
181                issuer_pk: cmd.issuer_pk,
182                issuer_did: cmd.issuer_did,
183                trust: None,
184                roots_file: None,
185                witness_receipts: cmd.witness_receipts,
186                witness_threshold: cmd.witness_threshold,
187                witness_keys: cmd.witness_keys,
188            };
189            handle_verify(verify_cmd).await
190        }
191        VerifyTarget::ArtifactFile(artifact_path) => {
192            handle_artifact_verify(
193                &artifact_path,
194                crate::commands::artifact::verify::VerifyArtifactArgs {
195                    signature: cmd.signature,
196                    identity_bundle: cmd.identity_bundle,
197                    witness_receipts: cmd.witness_receipts,
198                    witness_keys: cmd.witness_keys,
199                    witness_threshold: cmd.witness_threshold,
200                    verify_commit: false,
201                    ephemeral_anchor: crate::commands::artifact::verify::EphemeralAnchor::Required,
202                    oidc_policy: None,
203                    oidc_policy_did: None,
204                    log_evidence: None,
205                    log_key: None,
206                    expect_signer: None,
207                },
208            )
209            .await
210        }
211    }
212}
213
214impl crate::commands::executable::ExecutableCommand for UnifiedVerifyCommand {
215    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
216        let rt = tokio::runtime::Runtime::new()?;
217        rt.block_on(handle_verify_unified(self.clone(), &ctx.env_config))
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_parse_verify_target_git_ref() {
227        assert!(matches!(
228            parse_verify_target("HEAD"),
229            VerifyTarget::GitRef(_)
230        ));
231        assert!(matches!(
232            parse_verify_target("abc1234"),
233            VerifyTarget::GitRef(_)
234        ));
235        assert!(matches!(
236            parse_verify_target("main..HEAD"),
237            VerifyTarget::GitRef(_)
238        ));
239    }
240
241    #[test]
242    fn test_parse_verify_target_stdin() {
243        assert!(matches!(
244            parse_verify_target("-"),
245            VerifyTarget::Attestation(_)
246        ));
247    }
248
249    #[test]
250    fn test_parse_verify_target_nonexistent_defaults_to_git_ref() {
251        let target = parse_verify_target("/nonexistent/attestation.json");
252        assert!(matches!(target, VerifyTarget::GitRef(_)));
253    }
254
255    #[test]
256    fn test_parse_verify_target_file() {
257        use std::fs::File;
258        use tempfile::tempdir;
259        let dir = tempdir().unwrap();
260        let f = dir.path().join("attestation.json");
261        File::create(&f).unwrap();
262        let target = parse_verify_target(f.to_str().unwrap());
263        assert!(matches!(target, VerifyTarget::Attestation(_)));
264    }
265
266    #[test]
267    fn test_parse_verify_target_binary_file_routes_to_artifact() {
268        use std::fs::File;
269        use tempfile::tempdir;
270        let dir = tempdir().unwrap();
271        let artifact = dir.path().join("release.tar.gz");
272        File::create(&artifact).unwrap();
273        let target = parse_verify_target(artifact.to_str().unwrap());
274        assert!(matches!(target, VerifyTarget::ArtifactFile(_)));
275    }
276
277    #[test]
278    fn test_parse_verify_target_json_file_routes_to_attestation() {
279        use std::fs::File;
280        use tempfile::tempdir;
281        let dir = tempdir().unwrap();
282        let attest = dir.path().join("release.auths.json");
283        File::create(&attest).unwrap();
284        let target = parse_verify_target(attest.to_str().unwrap());
285        assert!(matches!(target, VerifyTarget::Attestation(_)));
286    }
287
288    #[test]
289    fn test_parse_verify_target_plain_json_routes_to_attestation() {
290        use std::fs::File;
291        use tempfile::tempdir;
292        let dir = tempdir().unwrap();
293        let f = dir.path().join("attestation.json");
294        File::create(&f).unwrap();
295        let target = parse_verify_target(f.to_str().unwrap());
296        assert!(matches!(target, VerifyTarget::Attestation(_)));
297    }
298}