auths-cli 0.1.2

Command-line interface for Auths decentralized identity system
Documentation
//! Unified verify command: verifies a git commit OR an attestation file.

use anyhow::Result;
use clap::Parser;
use std::path::{Path, PathBuf};

use super::verify_commit::{VerifyCommitCommand, handle_verify_commit};
use crate::commands::artifact::verify::handle_verify as handle_artifact_verify;
use crate::commands::device::verify_attestation::{VerifyCommand, handle_verify};

/// What kind of target the user provided.
pub enum VerifyTarget {
    GitRef(String),
    Attestation(String),
    ArtifactFile(PathBuf), // binary artifact, will look up .auths.json sidecar
}

/// Determine whether `raw_target` is a Git reference or an attestation path.
///
/// Rules (evaluated in order):
/// 1. "-" → stdin attestation
/// 2. Path exists on disk and is JSON → attestation file
/// 3. Path exists on disk and is not JSON → artifact file (sidecar lookup)
/// 4. Contains ".." (range notation) → git ref
/// 5. Is "HEAD" or matches ^[0-9a-f]{4,40}$ → git ref
/// 6. Otherwise → git ref (assume the user knows what they're typing)
///
/// Args:
/// * `raw_target` - Raw CLI input string.
///
/// Usage:
/// ```ignore
/// let t = parse_verify_target("HEAD");
/// assert!(matches!(t, VerifyTarget::GitRef(_)));
/// ```
pub fn parse_verify_target(raw_target: &str) -> VerifyTarget {
    if raw_target == "-" {
        return VerifyTarget::Attestation(raw_target.to_string());
    }
    let path = Path::new(raw_target);
    if path.exists() {
        if is_attestation_path(raw_target) {
            return VerifyTarget::Attestation(raw_target.to_string());
        } else {
            return VerifyTarget::ArtifactFile(path.to_path_buf());
        }
    }
    if raw_target.contains("..") {
        return VerifyTarget::GitRef(raw_target.to_string());
    }
    if raw_target.eq_ignore_ascii_case("HEAD") {
        return VerifyTarget::GitRef(raw_target.to_string());
    }
    // 4-40 hex chars → commit hash
    let is_hex = raw_target.len() >= 4
        && raw_target.len() <= 40
        && raw_target.chars().all(|c| c.is_ascii_hexdigit());
    if is_hex {
        return VerifyTarget::GitRef(raw_target.to_string());
    }
    // Fallback: treat as git ref. The execution layer (handle_verify_commit) will
    // return a clear error if the ref doesn't resolve in the git repo, so no
    // silent data loss occurs from a typoed filename being misclassified.
    VerifyTarget::GitRef(raw_target.to_string())
}

/// Returns true if the path looks like an attestation/JSON file rather than a binary artifact.
fn is_attestation_path(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.ends_with(".json")
}

/// Unified verify command: verifies a signed commit or an attestation.
#[derive(Parser, Debug, Clone)]
#[command(
    about = "Verify a signed commit or attestation.",
    after_help = "Examples:
  auths verify HEAD                       # Verify current commit signature
  auths verify main..HEAD                 # Verify range of commits
  auths verify release.tar.gz             # Verify artifact (finds .auths.json sidecar)
  auths verify release.tar.gz.auths.json  # Verify attestation file directly
  auths verify - < artifact.json          # Verify from stdin

Artifact Verification:
  Pass the artifact file directly — auths finds <file>.auths.json automatically.
  Pass --signature to override the default sidecar path.

Related:
  auths sign     — Create signatures
  auths publish  — Sign and publish to registry
  auths trust    — Manage trusted identities"
)]
pub struct UnifiedVerifyCommand {
    /// Git ref, commit hash, range (e.g. HEAD, abc1234, main..HEAD),
    /// or path to an attestation JSON file / "-" for stdin.
    #[arg(default_value = "HEAD")]
    pub target: String,

    /// Path to identity bundle JSON (for CI/CD stateless commit verification).
    #[arg(long, value_parser)]
    pub identity_bundle: Option<PathBuf>,

    /// Signer public key in hex format (attestation verification).
    #[arg(long = "signer-key")]
    pub issuer_pk: Option<String>,

    /// Signer identity ID for attestation trust-based key resolution.
    #[arg(long = "signer", visible_alias = "issuer-did")]
    pub issuer_did: Option<String>,

    /// Path to witness signatures JSON file.
    #[arg(long = "witness-signatures")]
    pub witness_receipts: Option<PathBuf>,

    /// Number of witnesses required.
    #[arg(long = "witnesses-required", default_value = "1")]
    pub witness_threshold: usize,

    /// Witness public keys as DID:hex pairs.
    #[arg(long, num_args = 1..)]
    pub witness_keys: Vec<String>,

    /// Path to signature file. Only used when verifying an artifact file (not a commit).
    /// Defaults to <FILE>.auths.json.
    #[arg(long, value_name = "PATH")]
    pub signature: Option<PathBuf>,

    /// Fetch a signer's KEL from this git remote when it is absent locally
    /// (opt-in). The local registry stays the trusted floor — a remote can only
    /// advance the key-state, never roll it back. Without it, resolution is
    /// local-only (no network).
    #[arg(long)]
    pub remote: Option<String>,

    /// Fetch signer KELs over HTTP from this OOBI base URL (SSRF-hardened:
    /// HTTPS-only, no redirects, private/loopback hosts blocked). Takes
    /// precedence over `--remote`.
    #[arg(long)]
    pub oobi: Option<String>,

    /// Fail verification when the signer's root KEL has not reached witness
    /// quorum (fail-closed). Default: warn and continue.
    #[arg(long = "require-witnesses")]
    pub require_witnesses: bool,
}

/// Handle the unified verify command.
///
/// Routes to commit verification or attestation verification based on target type.
///
/// Args:
/// * `cmd` - Parsed UnifiedVerifyCommand.
pub async fn handle_verify_unified(
    cmd: UnifiedVerifyCommand,
    env_config: &auths_sdk::core_config::EnvironmentConfig,
) -> Result<()> {
    match parse_verify_target(&cmd.target) {
        VerifyTarget::GitRef(ref_str) => {
            let commit_cmd = VerifyCommitCommand {
                commit: ref_str,
                witness_receipts: cmd.witness_receipts,
                witness_threshold: cmd.witness_threshold,
                witness_keys: cmd.witness_keys,
                remote: cmd.remote,
                oobi: cmd.oobi,
                require_witnesses: cmd.require_witnesses,
                // Honor --identity-bundle for commit/git-ref targets (previously dropped
                // here, silently verifying against the wrong/default trust root).
                identity_bundle: cmd.identity_bundle,
            };
            handle_verify_commit(commit_cmd, env_config).await
        }
        VerifyTarget::Attestation(path_str) => {
            let verify_cmd = VerifyCommand {
                attestation: path_str,
                issuer_pk: cmd.issuer_pk,
                issuer_did: cmd.issuer_did,
                trust: None,
                roots_file: None,
                witness_receipts: cmd.witness_receipts,
                witness_threshold: cmd.witness_threshold,
                witness_keys: cmd.witness_keys,
            };
            handle_verify(verify_cmd).await
        }
        VerifyTarget::ArtifactFile(artifact_path) => {
            handle_artifact_verify(
                &artifact_path,
                cmd.signature,
                cmd.identity_bundle,
                cmd.witness_receipts,
                &cmd.witness_keys,
                cmd.witness_threshold,
                false,
            )
            .await
        }
    }
}

impl crate::commands::executable::ExecutableCommand for UnifiedVerifyCommand {
    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
        let rt = tokio::runtime::Runtime::new()?;
        rt.block_on(handle_verify_unified(self.clone(), &ctx.env_config))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_verify_target_git_ref() {
        assert!(matches!(
            parse_verify_target("HEAD"),
            VerifyTarget::GitRef(_)
        ));
        assert!(matches!(
            parse_verify_target("abc1234"),
            VerifyTarget::GitRef(_)
        ));
        assert!(matches!(
            parse_verify_target("main..HEAD"),
            VerifyTarget::GitRef(_)
        ));
    }

    #[test]
    fn test_parse_verify_target_stdin() {
        assert!(matches!(
            parse_verify_target("-"),
            VerifyTarget::Attestation(_)
        ));
    }

    #[test]
    fn test_parse_verify_target_nonexistent_defaults_to_git_ref() {
        let target = parse_verify_target("/nonexistent/attestation.json");
        assert!(matches!(target, VerifyTarget::GitRef(_)));
    }

    #[test]
    fn test_parse_verify_target_file() {
        use std::fs::File;
        use tempfile::tempdir;
        let dir = tempdir().unwrap();
        let f = dir.path().join("attestation.json");
        File::create(&f).unwrap();
        let target = parse_verify_target(f.to_str().unwrap());
        assert!(matches!(target, VerifyTarget::Attestation(_)));
    }

    #[test]
    fn test_parse_verify_target_binary_file_routes_to_artifact() {
        use std::fs::File;
        use tempfile::tempdir;
        let dir = tempdir().unwrap();
        let artifact = dir.path().join("release.tar.gz");
        File::create(&artifact).unwrap();
        let target = parse_verify_target(artifact.to_str().unwrap());
        assert!(matches!(target, VerifyTarget::ArtifactFile(_)));
    }

    #[test]
    fn test_parse_verify_target_json_file_routes_to_attestation() {
        use std::fs::File;
        use tempfile::tempdir;
        let dir = tempdir().unwrap();
        let attest = dir.path().join("release.auths.json");
        File::create(&attest).unwrap();
        let target = parse_verify_target(attest.to_str().unwrap());
        assert!(matches!(target, VerifyTarget::Attestation(_)));
    }

    #[test]
    fn test_parse_verify_target_plain_json_routes_to_attestation() {
        use std::fs::File;
        use tempfile::tempdir;
        let dir = tempdir().unwrap();
        let f = dir.path().join("attestation.json");
        File::create(&f).unwrap();
        let target = parse_verify_target(f.to_str().unwrap());
        assert!(matches!(target, VerifyTarget::Attestation(_)));
    }
}