void-cli 0.0.4

CLI for void — anonymous encrypted source control
//! Show contents of an object by CID.
//!
//! Loads an encrypted blob from the object store, decrypts it with
//! different AAD types to determine its type, then displays the contents.

use std::path::Path;

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

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

/// Command-line arguments for cat-file.
#[derive(Debug)]
pub struct CatFileArgs {
    /// CID of the object to display.
    pub cid: String,
}

/// JSON output for the cat-file command.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CatFileOutput {
    /// The CID of the object.
    pub cid: String,
    /// Detected object type.
    pub object_type: String,
    /// Size of the encrypted blob in bytes.
    pub encrypted_size: usize,
    /// Size of the decrypted data in bytes.
    pub decrypted_size: usize,
    /// Object details (varies by type).
    pub details: ObjectDetails,
}

/// Object-specific details.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum ObjectDetails {
    /// Commit object details.
    Commit {
        message: String,
        timestamp: u64,
        parent_count: usize,
        is_signed: bool,
        metadata_bundle_cid: String,
        /// Schema version
        schema_version: String,
        /// Whether CommitReader::open_with_vault succeeds
        merge_parse_ok: bool,
    },
    /// Metadata bundle details.
    Metadata {
        range_count: usize,
        shard_count: usize,
    },
    /// Shard details.
    Shard {
        compressed_size: usize,
    },
    /// Unknown object type.
    Unknown { aad_tried: Vec<String> },
    /// Raw bytes (when decryption succeeds but parsing fails).
    Raw { aad: String, hex_preview: String },
}

/// Try to decrypt and parse a MetadataBundle from an encrypted blob using the vault.
fn try_parse_metadata(
    vault: &KeyVault,
    encrypted: &[u8],
) -> Result<metadata::MetadataBundle, ()> {
    let aligned = vault
        .decrypt_blob_raw(encrypted, void_core::crypto::AAD_METADATA)
        .map_err(|_| ())?;
    ciborium::from_reader::<metadata::MetadataBundle, _>(&aligned[..])
        .map_err(|_| ())
}

/// Run the cat-file command.
///
/// # Arguments
///
/// * `cwd` - Current working directory
/// * `args` - Cat-file arguments
/// * `opts` - CLI options
pub fn run(cwd: &Path, args: CatFileArgs, opts: &CliOptions) -> Result<(), CliError> {
    run_command("cat-file", opts, |ctx| {
        ctx.progress(format!("Loading object {}...", args.cid));

        let repo = open_repo(cwd)?;
        let vault = repo.vault();
        let objects_dir = repo.void_dir().join("objects");
        let store = FsStore::new(objects_dir).map_err(void_err_to_cli)?;

        // Parse CID
        let parsed_cid = cid::parse(&args.cid)
            .map_err(|e| CliError::invalid_args(format!("invalid CID: {}", e)))?;

        // Load encrypted blob (read as EncryptedCommit since that's tried first;
        // all EncryptedBlob types are just Vec<u8> wrappers at this level)
        let commit_blob: EncryptedCommit = store.get_blob(&parsed_cid).map_err(void_err_to_cli)?;
        let encrypted_size = commit_blob.as_bytes().len();

        // Try decrypting as commit first using CommitReader (handles VD01)
        let mut details = None;
        let mut decrypted_size = 0;
        let mut object_type = "unknown".to_string();
        let mut tried_aads = Vec::new();

        // Try as commit first
        tried_aads.push("commit".to_string());
        if let Ok((decrypted, _reader)) = CommitReader::open_with_vault(vault, &commit_blob) {
            decrypted_size = decrypted.as_bytes().len();
            object_type = "commit".to_string();

            let schema_version = "current";

            // Test if parse succeeds (same check as merge_base)
            let merge_parse_ok = decrypted.parse().is_ok();

            if let Ok(commit) = decrypted.parse() {
                let metadata_cid_str = if !commit.metadata_bundle.as_bytes().is_empty() {
                    commit.metadata_bundle.to_void_cid()
                        .map(|c| c.to_string())
                        .unwrap_or_else(|_| "<invalid>".to_string())
                } else {
                    "<none>".to_string()
                };

                details = Some(ObjectDetails::Commit {
                    message: commit.message.clone(),
                    timestamp: commit.timestamp,
                    parent_count: commit.parents.len(),
                    is_signed: commit.is_signed(),
                    metadata_bundle_cid: metadata_cid_str.clone(),
                    schema_version: schema_version.to_string(),
                    merge_parse_ok,
                });

                // Human-readable output
                if !ctx.use_json() {
                    ctx.info(format!("Type: commit"));
                    ctx.info(format!("Schema: {}", schema_version));
                    ctx.info(format!("Message: {}", commit.message));
                    ctx.info(format!("Timestamp: {}", commit.timestamp));
                    ctx.info(format!("Parents: {}", commit.parents.len()));
                    ctx.info(format!("Signed: {}", commit.is_signed()));
                    ctx.info(format!("Metadata bundle: {}", metadata_cid_str));
                }
            } else {
                let bytes = decrypted.as_bytes();
                let hex_preview = hex::encode(&bytes[..bytes.len().min(64)]);
                details = Some(ObjectDetails::Raw {
                    aad: "commit".to_string(),
                    hex_preview,
                });
            }
        }

        // If not a commit, try decryption paths for metadata/shard
        if details.is_none() {
            let probe_aads: &[(&[u8], &str)] = &[
                (void_core::crypto::AAD_METADATA, "metadata"),
                (void_core::crypto::AAD_SHARD, "shard"),
            ];

            for &(aad, type_name) in probe_aads {
                tried_aads.push(type_name.to_string());
                if let Ok(decrypted) = vault.decrypt_blob(commit_blob.as_bytes(), aad) {
                    decrypted_size = decrypted.len();
                    object_type = type_name.to_string();

                    match type_name {
                        "metadata" => {
                            // Try to parse as MetadataBundle (needs aligned buffer)
                            if let Ok(bundle) = try_parse_metadata(vault, commit_blob.as_bytes()) {
                                let shard_count = bundle
                                    .shard_map
                                    .ranges
                                    .iter()
                                    .filter(|r| r.cid.is_some())
                                    .count();

                                details = Some(ObjectDetails::Metadata {
                                    range_count: bundle.shard_map.ranges.len(),
                                    shard_count,
                                });

                                // Human-readable output
                                if !ctx.use_json() {
                                    ctx.info(format!("Type: metadata"));
                                    ctx.info(format!("Ranges: {}", bundle.shard_map.ranges.len()));
                                    ctx.info(format!("Shards with content: {}", shard_count));
                                }
                            } else {
                                let hex_preview =
                                    hex::encode(&decrypted[..decrypted.len().min(64)]);
                                details = Some(ObjectDetails::Raw {
                                    aad: type_name.to_string(),
                                    hex_preview,
                                });
                            }
                        }
                        "shard" => {
                            details = Some(ObjectDetails::Shard {
                                compressed_size: decrypted.len(),
                            });

                            if !ctx.use_json() {
                                ctx.info(format!("Type: shard"));
                                ctx.info(format!(
                                    "Compressed size: {} bytes (opaque block)",
                                    decrypted.len()
                                ));
                            }
                        }
                        _ => {}
                    }

                    break;
                }
            }
        }

        let details = details.unwrap_or(ObjectDetails::Unknown {
            aad_tried: tried_aads,
        });

        if !ctx.use_json() && matches!(details, ObjectDetails::Unknown { .. }) {
            ctx.warn("Could not decrypt object with any known AAD type. For VD01-format metadata/shards, use audit-object instead.");
        }

        Ok(CatFileOutput {
            cid: args.cid,
            object_type,
            encrypted_size,
            decrypted_size,
            details,
        })
    })
}