void-cli 0.0.3

CLI for void — anonymous encrypted source control
//! Inspect an encrypted object -- multi-layer decrypt diagnostics.
//!
//! Given a CID, tries to decrypt and identify the object type (commit, metadata, shard)
//! with detailed diagnostics about the encryption format and key derivation.

use std::path::Path;

use serde::Serialize;
use void_core::cid;
use void_core::crypto::{
    AAD_COMMIT, AAD_INDEX, AAD_MANIFEST, AAD_METADATA, AAD_SHARD, EncryptedCommit, KeyVault, MAGIC_V1,
};
use void_core::metadata::{parse_commit, MetadataBundle};
use void_core::store::{FsStore, ObjectStoreExt};

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

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

/// JSON output for the inspect-object command.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InspectObjectOutput {
    /// The CID that was inspected.
    pub cid: String,
    /// Size of the raw encrypted blob in bytes.
    pub raw_size: usize,
    /// Whether the blob has a VD01 envelope header.
    pub has_envelope: bool,
    /// Hex-encoded envelope nonce (16 bytes), if VD01 format.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub envelope_nonce: Option<String>,
    /// Detected object type (commit, metadata, shard, or None if all failed).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detected_type: Option<String>,
    /// Encryption format that succeeded (vd01 or legacy).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub format: Option<String>,
    /// Key type used for successful decryption (derived or root).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub key_type: Option<String>,
    /// AAD label that succeeded.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub aad_used: Option<String>,
    /// Fallback level: 0=envelope+derived+correct AAD, 1=envelope+derived+empty AAD,
    /// 2=legacy+root+correct AAD, 3=legacy+root+empty AAD.
    pub fallback_level: u32,
    /// Human-readable description of the decryption technique that worked.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub technique: Option<String>,
    /// Size of decrypted plaintext in bytes.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub decrypted_size: Option<usize>,
    /// Summary of the decrypted content (type-specific).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content_summary: Option<String>,
    /// Record of each decryption attempt.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub attempts: Vec<AttemptEntry>,
}

/// A single decryption attempt record.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttemptEntry {
    /// AAD label tried (commit, metadata, shard, index, manifest).
    pub aad: String,
    /// Key type tried (derived or root).
    pub key_type: String,
    /// Whether this attempt succeeded.
    pub success: bool,
    /// Error message if the attempt failed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// AAD definitions to iterate over during decryption probing.
struct AadProbe {
    name: &'static str,
    aad: &'static [u8],
}

const AAD_PROBES: &[AadProbe] = &[
    AadProbe {
        name: "commit",
        aad: AAD_COMMIT,
    },
    AadProbe {
        name: "metadata",
        aad: AAD_METADATA,
    },
    AadProbe {
        name: "shard",
        aad: AAD_SHARD,
    },
    AadProbe {
        name: "index",
        aad: AAD_INDEX,
    },
    AadProbe {
        name: "manifest",
        aad: AAD_MANIFEST,
    },
];

/// Determine the fallback level from the decryption result.
///
/// - 0: VD01 envelope, derived key, correct AAD
/// - 1: VD01 envelope, derived key, empty AAD (fallback)
/// - 2: legacy format, root key, correct AAD
/// - 3: legacy format, root key, empty AAD
fn classify_fallback(
    has_envelope: bool,
    nonce: &Option<void_core::crypto::KeyNonce>,
    aad: &[u8],
    vault: &KeyVault,
    blob: &[u8],
) -> u32 {
    // If the successful decrypt returned a nonce, it was VD01 with derived key
    if let Some(n) = nonce {
        // Derive the scoped key and attempt decrypt with the specified AAD only.
        let scope = format!("commit:{}", hex::encode(n.as_bytes()));
        if let Ok(derived_ck) = vault.derive_scoped_key(&scope) {
            // Body starts after the 20-byte envelope header
            if blob.len() > 20 {
                if void_core::crypto::decrypt(derived_ck.as_bytes(), &blob[20..], aad).is_ok() {
                    return 0; // envelope + derived key + correct AAD
                }
            }
        }
        return 1; // envelope + derived key + empty AAD (fallback)
    }

    // No nonce means legacy format (shouldn't happen with current VD01-only codebase)
    if has_envelope { 2 } else { 3 }
}

/// Build a human-readable technique description.
fn describe_technique(fallback_level: u32, aad_name: &str) -> String {
    match fallback_level {
        0 => format!("VD01 envelope, derived key, {} AAD", aad_name),
        1 => format!("VD01 envelope, derived key, empty AAD (fallback)"),
        2 => format!("legacy format, root key, {} AAD", aad_name),
        3 => format!("legacy format, root key, empty AAD (fallback)"),
        _ => "unknown technique".to_string(),
    }
}

/// Try to summarize the decrypted content based on the detected type.
///
/// For commit and shard, we parse the already-decrypted plaintext.
/// For metadata, we re-decrypt via vault then parse with CBOR.
fn summarize_content(aad_name: &str, plaintext: &[u8], encrypted: &[u8], vault: &KeyVault) -> String {
    match aad_name {
        "commit" => summarize_commit(plaintext),
        "metadata" => summarize_metadata(encrypted, vault),
        "shard" => summarize_shard(plaintext),
        _ => format!("Decrypted {} bytes", plaintext.len()),
    }
}

fn summarize_commit(plaintext: &[u8]) -> String {
    match parse_commit(plaintext) {
        Ok(commit) => {
            let schema = "commit";
            let signed = if commit.is_signed() { ", signed" } else { "" };
            format!(
                "Commit ({}): \"{}\", {} parent(s), ts={}{signed}",
                schema,
                commit.message,
                commit.parents.len(),
                commit.timestamp,
            )
        }
        Err(_) => format!(
            "Decrypted {} bytes (commit AAD, parse failed)",
            plaintext.len()
        ),
    }
}

fn summarize_metadata(encrypted: &[u8], vault: &KeyVault) -> String {
    // Decrypt with alignment via vault, then parse with CBOR
    if let Ok(aligned) = vault.decrypt_blob_raw(encrypted, AAD_METADATA) {
        if let Ok(bundle) = ciborium::from_reader::<MetadataBundle, _>(&aligned[..]) {
            let shard_count = bundle
                .shard_map
                .ranges
                .iter()
                .filter(|r| r.cid.is_some())
                .count();
            return format!(
                "Metadata: version={}, {} ranges, {} shards with content",
                bundle.version,
                bundle.shard_map.ranges.len(),
                shard_count,
            );
        }
    }
    "Metadata (decrypted, parse failed)".to_string()
}

fn summarize_shard(plaintext: &[u8]) -> String {
    format!("Shard: {} bytes (compressed, opaque block)", plaintext.len())
}

/// Run the inspect-object command.
///
/// # Arguments
///
/// * `cwd` - Current working directory
/// * `args` - Inspect-object arguments
/// * `opts` - CLI options
pub fn run(cwd: &Path, args: InspectObjectArgs, opts: &CliOptions) -> Result<(), CliError> {
    run_command("inspect-object", opts, |ctx| {
        ctx.progress(format!("Inspecting 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 and load the object
        let parsed_cid = cid::parse(&args.cid)
            .map_err(|e| CliError::invalid_args(format!("invalid CID: {}", e)))?;
        let encrypted_blob: EncryptedCommit = store.get_blob(&parsed_cid).map_err(void_err_to_cli)?;
        let encrypted = encrypted_blob.as_bytes();
        let raw_size = encrypted.len();

        // Detect VD01 envelope
        let has_envelope = raw_size > 20 && encrypted.starts_with(MAGIC_V1);
        let envelope_nonce = if has_envelope {
            let nonce = void_core::crypto::KeyNonce::from_bytes(&encrypted[4..20])
                .expect("slice is exactly 16 bytes");
            Some(nonce.to_string())
        } else {
            None
        };

        ctx.progress(format!(
            "Raw size: {} bytes, envelope: {}",
            raw_size,
            if has_envelope {
                "VD01"
            } else {
                "none (legacy)"
            }
        ));

        // Try each AAD type
        let mut attempts = Vec::new();
        let mut detected_type: Option<String> = None;
        let mut format: Option<String> = None;
        let mut key_type: Option<String> = None;
        let mut aad_used: Option<String> = None;
        let mut fallback_level: u32 = 0;
        let mut technique: Option<String> = None;
        let mut decrypted_size: Option<usize> = None;
        let mut content_summary: Option<String> = None;

        for probe in AAD_PROBES {
            match vault.decrypt_blob(&encrypted, probe.aad) {
                Ok(plaintext) => {
                    // Extract nonce from VD01 header for classification
                    let nonce_opt = if has_envelope {
                        void_core::crypto::KeyNonce::from_bytes(&encrypted[4..20])
                    } else {
                        None
                    };
                    let level =
                        classify_fallback(has_envelope, &nonce_opt, probe.aad, vault, &encrypted);

                    detected_type = Some(probe.name.to_string());
                    format = Some("vd01".to_string());
                    key_type = Some("derived".to_string());
                    aad_used = Some(probe.name.to_string());
                    fallback_level = level;
                    technique = Some(describe_technique(level, probe.name));
                    decrypted_size = Some(plaintext.len());
                    content_summary =
                        Some(summarize_content(probe.name, &plaintext, &encrypted, vault));

                    attempts.push(AttemptEntry {
                        aad: probe.name.to_string(),
                        key_type: "derived".to_string(),
                        success: true,
                        error: None,
                    });

                    ctx.progress(format!(
                        "Decrypted as {} (vd01, {} bytes plaintext)",
                        probe.name,
                        plaintext.len()
                    ));
                    break;
                }
                Err(e) => {
                    let inferred_key_type = if has_envelope { "derived" } else { "root" };
                    attempts.push(AttemptEntry {
                        aad: probe.name.to_string(),
                        key_type: inferred_key_type.to_string(),
                        success: false,
                        error: Some(e.to_string()),
                    });
                }
            }
        }

        // Human-readable output
        if !ctx.use_json() {
            ctx.info(format!("CID: {}", args.cid));
            ctx.info(format!("Raw size: {} bytes", raw_size));
            ctx.info(format!(
                "Envelope: {}",
                if has_envelope {
                    "VD01"
                } else {
                    "none (legacy)"
                }
            ));
            if let Some(ref nonce) = envelope_nonce {
                ctx.info(format!("Envelope nonce: {}", nonce));
            }
            ctx.info(String::new());

            if let Some(ref dtype) = detected_type {
                ctx.info(format!("Detected type: {}", dtype));
                if let Some(ref t) = technique {
                    ctx.info(format!("Technique: {}", t));
                }
                if let Some(size) = decrypted_size {
                    ctx.info(format!("Decrypted size: {} bytes", size));
                }
                if let Some(ref summary) = content_summary {
                    ctx.info(format!("Content: {}", summary));
                }
            } else {
                ctx.warn("Could not decrypt with any known AAD type.");
                ctx.info(format!(
                    "Tried: {}",
                    AAD_PROBES
                        .iter()
                        .map(|p| p.name)
                        .collect::<Vec<_>>()
                        .join(", ")
                ));
            }
        }

        Ok(InspectObjectOutput {
            cid: args.cid,
            raw_size,
            has_envelope,
            envelope_nonce,
            detected_type,
            format,
            key_type,
            aad_used,
            fallback_level,
            technique,
            decrypted_size,
            content_summary,
            attempts,
        })
    })
}