void-cli 0.0.3

CLI for void — anonymous encrypted source control
//! Verify command - verify commit signatures.
//!
//! Loads a commit by reference and verifies its Ed25519 signature if present.

use std::path::Path;

use serde::Serialize;
use void_core::{
    cid,
    crypto::{CommitCid, CommitReader, EncryptedCommit, KeyVault},
    metadata::Commit,
    store::{FsStore, ObjectStoreExt},
};

use crate::context::{open_repo, resolve_ref, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};

/// JSON output for the verify command.
#[derive(Debug, Clone, Serialize)]
pub struct VerifyOutput {
    /// CID of the commit.
    pub commit: String,
    /// Whether the signature is valid (true if unsigned or valid signature).
    pub valid: bool,
    /// Whether the commit has a signature.
    pub signed: bool,
    /// Author public key prefixed with "ed25519:" or null if unsigned.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<String>,
}

/// Read and decrypt a commit from the object store.
fn read_commit(store: &FsStore, vault: &KeyVault, commit_cid: &CommitCid) -> Result<Commit, CliError> {
    let cid_obj =
        cid::from_bytes(commit_cid.as_bytes()).map_err(|e| CliError::internal(format!("invalid CID: {e}")))?;

    let encrypted: EncryptedCommit = store
        .get_blob(&cid_obj)
        .map_err(|e| CliError::not_found(format!("commit not found: {e}")))?;

    let (commit_bytes, _reader) = CommitReader::open_with_vault(vault, &encrypted)
        .map_err(|e| CliError::internal(format!("failed to open commit: {e}")))?;
    let commit = commit_bytes.parse()
        .map_err(|e| CliError::internal(format!("failed to parse commit: {e}")))?;

    Ok(commit)
}

/// Run the verify command.
///
/// Resolves the commit reference, loads the commit, and verifies its signature.
///
/// # Arguments
/// * `cwd` - Current working directory
/// * `commit_ref` - Commit reference (CID, branch, tag, or HEAD)
/// * `opts` - CLI options
pub fn run(cwd: &Path, commit_ref: &str, opts: &CliOptions) -> Result<(), CliError> {
    run_command("verify", opts, |ctx| {
        ctx.progress("Verifying commit signature...");
        ctx.verbose("Reading repository context...");

        let repo = open_repo(cwd)?;

        ctx.verbose(format!("Resolving reference: {}", commit_ref));

        // Resolve the commit reference to CID bytes
        let cid_bytes = resolve_ref(repo.void_dir().as_std_path(), commit_ref)?;

        // Format CID string
        let cid_str = cid::from_bytes(cid_bytes.as_bytes())
            .map(|c| c.to_string())
            .unwrap_or_else(|_| hex::encode(cid_bytes.as_bytes()));

        ctx.verbose(format!(
            "Loading commit {}",
            &cid_str[..12.min(cid_str.len())]
        ));

        // Create object store to read the commit
        let store = repo.store().map_err(void_err_to_cli)?;

        // Read and decrypt the commit
        let commit = read_commit(&store, &repo.vault(), &cid_bytes)?;

        // Verify the signature
        let (valid, signed, author) = match commit.verify() {
            Ok(true) => {
                // Signature is present and valid
                let author_hex = commit.author.map(|a| format!("ed25519:{}", a.to_hex()));
                (true, true, author_hex)
            }
            Ok(false) => {
                // Commit is unsigned (no author and no signature)
                (true, false, None)
            }
            Err(_) => {
                // Signature is present but invalid
                let author_hex = commit.author.map(|a| format!("ed25519:{}", a.to_hex()));
                (false, true, author_hex)
            }
        };

        // Human-readable output
        if !ctx.use_json() {
            if signed {
                if valid {
                    let author_display = author.as_deref().unwrap_or("unknown");
                    ctx.info(format!(
                        "Commit {} is signed by {}",
                        cid_str, author_display
                    ));
                } else {
                    let author_display = author.as_deref().unwrap_or("unknown");
                    ctx.info(format!(
                        "Commit {} has INVALID signature from {}",
                        cid_str, author_display
                    ));
                }
            } else {
                ctx.info(format!("Commit {} is unsigned", cid_str));
            }
        }

        ctx.progress("Verification complete");

        Ok(VerifyOutput {
            commit: cid_str,
            valid,
            signed,
            author,
        })
    })
}

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

    #[test]
    fn test_verify_output_serialization_signed() {
        let output = VerifyOutput {
            commit: "bafytest123".to_string(),
            valid: true,
            signed: true,
            author: Some("ed25519:abcd1234".to_string()),
        };

        let json = serde_json::to_string(&output).unwrap();
        assert!(json.contains("\"commit\":\"bafytest123\""));
        assert!(json.contains("\"valid\":true"));
        assert!(json.contains("\"signed\":true"));
        assert!(json.contains("\"author\":\"ed25519:abcd1234\""));
    }

    #[test]
    fn test_verify_output_serialization_unsigned() {
        let output = VerifyOutput {
            commit: "bafytest456".to_string(),
            valid: true,
            signed: false,
            author: None,
        };

        let json = serde_json::to_string(&output).unwrap();
        assert!(json.contains("\"commit\":\"bafytest456\""));
        assert!(json.contains("\"valid\":true"));
        assert!(json.contains("\"signed\":false"));
        // author should be skipped when None
        assert!(!json.contains("\"author\""));
    }

    #[test]
    fn test_verify_output_serialization_invalid() {
        let output = VerifyOutput {
            commit: "bafytest789".to_string(),
            valid: false,
            signed: true,
            author: Some("ed25519:deadbeef".to_string()),
        };

        let json = serde_json::to_string(&output).unwrap();
        assert!(json.contains("\"commit\":\"bafytest789\""));
        assert!(json.contains("\"valid\":false"));
        assert!(json.contains("\"signed\":true"));
        assert!(json.contains("\"author\":\"ed25519:deadbeef\""));
    }
}