proofmode 0.9.0

Capture, share, and preserve verifiable photos and videos
Documentation
use crate::generate_error::{ProofModeError, Result};
use crate::generate_types::ProofData;
use c2pa::{Builder, BuilderIntent, ClaimGeneratorInfo, EphemeralSigner};
use std::collections::HashMap;
use std::io::Cursor;

/// Detect MIME type from metadata and/or raw bytes.
///
/// Uses a layered approach:
/// 1. Explicit `mime_type` key in metadata
/// 2. `mime_guess` from `fileName` key in metadata
/// 3. Magic byte detection
/// 4. Fallback to `image/jpeg`
pub fn detect_mime_type(media_data: &[u8], metadata: &HashMap<String, String>) -> String {
    if let Some(mime) = metadata.get("mime_type") {
        if !mime.is_empty() {
            return mime.clone();
        }
    }

    if let Some(file_name) = metadata.get("fileName") {
        let guess = mime_guess::from_path(file_name);
        if let Some(mime) = guess.first() {
            return mime.to_string();
        }
    }

    detect_mime_from_bytes(media_data)
}

fn detect_mime_from_bytes(data: &[u8]) -> String {
    if data.len() >= 4 {
        // JPEG: FF D8 FF
        if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
            return "image/jpeg".to_string();
        }
        // PNG: 89 50 4E 47
        if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
            return "image/png".to_string();
        }
        // RIFF...WEBP
        if data.len() >= 12
            && data[0] == 0x52
            && data[1] == 0x49
            && data[2] == 0x46
            && data[3] == 0x46
            && data[8] == 0x57
            && data[9] == 0x45
            && data[10] == 0x42
            && data[11] == 0x50
        {
            return "image/webp".to_string();
        }
    }

    if data.len() >= 12 {
        // ftyp box: offset 4 = "ftyp"
        if data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 {
            // Check for HEIC: ftyp heic
            if data[8] == 0x68 && data[9] == 0x65 && data[10] == 0x69 && data[11] == 0x63 {
                return "image/heic".to_string();
            }
            // Generic ftyp -> MP4
            return "video/mp4".to_string();
        }
    }

    "image/jpeg".to_string()
}

/// Derive a file extension from a MIME type string.
fn extension_for_mime(mime_type: &str) -> &str {
    match mime_type {
        "image/jpeg" => "jpg",
        "image/png" => "png",
        "image/heic" => "heic",
        "image/heif" => "heif",
        "image/webp" => "webp",
        "image/avif" => "avif",
        "image/tiff" => "tiff",
        "video/mp4" => "mp4",
        "video/quicktime" => "mov",
        _ => "jpg",
    }
}

/// Embed a C2PA manifest into the given media data.
///
/// Returns the new media bytes (with embedded manifest) on success.
pub fn embed_c2pa_manifest(
    media_data: &[u8],
    mime_type: &str,
    proof_data: &ProofData,
) -> Result<Vec<u8>> {
    let mut builder = Builder::new();

    builder.set_intent(BuilderIntent::Create(
        c2pa::assertions::DigitalSourceType::DigitalCapture,
    ));

    let mut cgi = ClaimGeneratorInfo::new("ProofMode");
    cgi.set_version(env!("CARGO_PKG_VERSION"));
    builder.set_claim_generator_info(cgi);

    let proof_json = serde_json::to_value(proof_data)
        .map_err(|e| ProofModeError::Serialization(e.to_string()))?;
    builder
        .add_assertion_json("org.proofmode.metadata", &proof_json)
        .map_err(|e| ProofModeError::Crypto(format!("C2PA add assertion failed: {}", e)))?;

    let signer = EphemeralSigner::new("proofmode.local")
        .map_err(|e| ProofModeError::Crypto(format!("C2PA signer creation failed: {}", e)))?;

    let mut source = Cursor::new(media_data);
    let mut dest = Cursor::new(Vec::new());

    builder
        .sign(&signer, mime_type, &mut source, &mut dest)
        .map_err(|e| ProofModeError::Crypto(format!("C2PA signing failed: {}", e)))?;

    Ok(dest.into_inner())
}

/// Embed C2PA manifest and save the result via callbacks.
///
/// Returns the file extension used on success.
pub fn embed_and_save_c2pa<C: crate::generate::core::PlatformCallbacks>(
    media_data: &[u8],
    hash: &str,
    proof_data: &ProofData,
    metadata: &HashMap<String, String>,
    callbacks: &C,
) -> Result<String> {
    let mime_type = detect_mime_type(media_data, metadata);
    let embedded = embed_c2pa_manifest(media_data, &mime_type, proof_data)?;
    let ext = extension_for_mime(&mime_type);
    let filename = format!("{}{}.{}", hash, crate::generate_types::C2PA_FILE_TAG, ext);
    callbacks.save_data(hash, &filename, &embedded)?;
    Ok(ext.to_string())
}