codedefender-api 0.2.8

Blocking client library for the CodeDefender binary obfuscation API.
Documentation
//! High-level client interface for interacting with the CodeDefender SaaS API.
//!
//! This module provides functions to upload files, analyze binaries, initiate
//! obfuscation, and poll for obfuscation results via blocking HTTP requests.
//!
//! All endpoints require a valid API key, passed via the `Authorization` header
//! using the `ApiKey` scheme.
use codedefender_config::{AnalysisResult, Config};
use reqwest::{StatusCode, blocking::Client};
use std::collections::HashMap;
use std::error::Error;
use std::io;
use once_cell::sync::Lazy;

pub use codedefender_config;
pub use serde_json;

/// Changing the BASE_URL env variable allows you to specify a different backend like staging or local.
pub static BASE_URL: Lazy<String> = Lazy::new(|| {
    std::env::var("BASE_URL").unwrap_or_else(|_| "https://app.codedefender.io".into())
});

pub static GET_UPLOAD_URL_EP: Lazy<String> =
    Lazy::new(|| format!("{}/api/get-upload-url", *BASE_URL));

pub static ANALYZE_EP: Lazy<String> =
    Lazy::new(|| format!("{}/api/analyze", *BASE_URL));

pub static ANALYZE_STATUS_EP: Lazy<String> =
    Lazy::new(|| format!("{}/api/analyze-status", *BASE_URL));

pub static DEFEND_EP: Lazy<String> =
    Lazy::new(|| format!("{}/api/defend", *BASE_URL));

pub static DOWNLOAD_EP: Lazy<String> =
    Lazy::new(|| format!("{}/api/download", *BASE_URL));

pub enum Status {
    Ready(String),
    Processing,
    Failed(Box<dyn Error>),
}

/// Gets the presigned upload URL and file ID for uploading a file.
///
/// # Arguments
///
/// * `file_size` - The size of the file to upload in bytes.
/// * `file_name` - Optional custom file name. If provided, it will be used; otherwise, a random UUID will be generated by the server.
/// * `client` - A preconfigured `reqwest::blocking::Client`.
/// * `api_key` - Your CodeDefender API key.
///
/// # Returns
///
/// A `Result<(String, String), Box<dyn Error>>` containing the file ID and presigned upload URL on success.
///
/// # Errors
///
/// Returns an error if the request fails or if the server responds with a non-success status code.
pub fn get_upload_info(
    file_size: usize,
    file_name: Option<String>,
    client: &Client,
    api_key: &str,
) -> Result<(String, String), Box<dyn Error>> {
    let mut query_params = HashMap::new();
    query_params.insert("fileSize".to_string(), file_size.to_string());
    if let Some(name) = file_name {
        query_params.insert("fileName".to_string(), name);
    }

    let response = client
        .get(&*GET_UPLOAD_URL_EP)
        .header("Authorization", format!("ApiKey {}", api_key))
        .query(&query_params)
        .send()?
        .error_for_status()?;

    let json: HashMap<String, String> = response.json()?;
    let upload_url = json.get("uploadUrl").cloned().ok_or("Missing uploadUrl")?;
    let file_id = json.get("fileId").cloned().ok_or("Missing fileId")?;
    Ok((file_id, upload_url))
}

/// Uploads file bytes to the presigned S3 URL.
///
/// # Arguments
///
/// * `upload_url` - The presigned S3 upload URL.
/// * `file_bytes` - The raw contents of the file to upload.
/// * `client` - A preconfigured `reqwest::blocking::Client`.
///
/// # Returns
///
/// `Ok(())` on success.
///
/// # Errors
///
/// Returns an error if the upload fails or the server responds with a non-success status code.
pub fn upload_to_s3(
    upload_url: &str,
    file_bytes: Vec<u8>,
    client: &Client,
) -> Result<(), Box<dyn Error>> {
    client
        .put(upload_url)
        .header("Content-Type", "application/octet-stream")
        .header("Content-Length", file_bytes.len().to_string())
        .body(file_bytes)
        .send()?
        .error_for_status()?;
    Ok(())
}

/// Uploads raw data bytes to CodeDefender with a specific filename and returns the file ID.
///
/// # Arguments
///
/// * `data` - The raw bytes to upload.
/// * `filename` - The specific filename to use for the upload.
/// * `client` - A preconfigured `reqwest::blocking::Client`.
/// * `api_key` - Your CodeDefender API key.
///
/// # Returns
///
/// A `Result<String, Box<dyn Error>>` containing the file ID on success.
///
/// # Errors
///
/// Returns an error if the request fails or if the server responds with a non-success status code.
pub fn upload_data(
    data: Vec<u8>,
    filename: String,
    client: &Client,
    api_key: &str,
) -> Result<String, Box<dyn Error>> {
    let file_size = data.len();
    let (file_id, upload_url) = get_upload_info(file_size, Some(filename), client, api_key)?;
    upload_to_s3(&upload_url, data, client)?;
    Ok(file_id)
}

/// Uploads a binary file to CodeDefender and returns a UUID representing the uploaded file.
///
/// # Arguments
///
/// * `file_bytes` - The raw contents of the binary file to upload.
/// * `client` - A preconfigured `reqwest::blocking::Client`.
/// * `api_key` - Your CodeDefender API key.
///
/// # Returns
///
/// A `Result<String, Box<dyn Error>>` containing the UUID on success, or an error if the upload failed.
///
/// # Errors
///
/// Returns an error if the request fails or if the server responds with a non-success status code (not in 200..=299).
pub fn upload_file(
    file_bytes: Vec<u8>,
    client: &Client,
    api_key: &str,
) -> Result<String, Box<dyn Error>> {
    let file_size = file_bytes.len();
    let (file_id, upload_url) = get_upload_info(file_size, None, client, api_key)?;
    upload_to_s3(&upload_url, file_bytes, client)?;
    Ok(file_id)
}

/// Starts analysis of a previously uploaded binary file and optionally its PDB file.
///
/// # Arguments
///
/// * `file_id` - UUID of the uploaded binary file.
/// * `pdb_file_id` - Optional UUID of the associated PDB file.
/// * `client` - A preconfigured `reqwest::blocking::Client`.
/// * `api_key` - Your CodeDefender API key.
///
/// # Returns
///
/// A `Result<String, Box<dyn Error>>` containing the execution ID for polling.
///
/// # Errors
///
/// Returns an error if the request fails or the server responds with a non-success status.
pub fn start_analyze(
    file_id: String,
    pdb_file_id: Option<String>,
    client: &Client,
    api_key: &str,
) -> Result<String, Box<dyn Error>> {
    let mut query_params = HashMap::new();
    query_params.insert("fileId".to_string(), file_id);
    if let Some(pdb_id) = pdb_file_id {
        query_params.insert("pdbFileId".to_string(), pdb_id);
    }
    let response = client
        .put(&*ANALYZE_EP)
        .header("Authorization", format!("ApiKey {}", api_key))
        .query(&query_params)
        .send()?
        .error_for_status()?;
    let json: HashMap<String, String> = response.json()?;
    let execution_id = json
        .get("executionId")
        .cloned()
        .ok_or("Missing executionId")?;
    Ok(execution_id)
}

/// Polls the analysis status.
///
/// This endpoint should be called periodically until the analysis is complete.
///
/// # Arguments
///
/// * `execution_id` - The execution ID returned by [`start_analyze`].
/// * `client` - A preconfigured `reqwest::blocking::Client`.
/// * `api_key` - Your CodeDefender API key.
///
/// # Returns
///
/// An [`Status`] enum indicating whether the analysis is ready (with presigned URL), still processing, or failed.
pub fn get_analyze_status(execution_id: String, client: &Client, api_key: &str) -> Status {
    let mut query_params = HashMap::new();
    query_params.insert("executionId".to_string(), execution_id);
    let result = client
        .get(&*ANALYZE_STATUS_EP)
        .header("Authorization", format!("ApiKey {}", api_key))
        .query(&query_params)
        .send();
    let Ok(resp) = result else {
        return Status::Failed(Box::new(result.unwrap_err()));
    };
    let status = resp.status();
    if status == StatusCode::ACCEPTED {
        Status::Processing
    } else if status == StatusCode::OK {
        let json: serde_json::Value = match resp.json() {
            Ok(v) => v,
            Err(e) => return Status::Failed(Box::new(e)),
        };

        match json["analysisUrl"]
            .as_str()
            .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "missing analysisUrl"))
        {
            Ok(value) => Status::Ready(value.to_string()),
            Err(e) => Status::Failed(Box::new(e)),
        }
    } else {
        Status::Failed(Box::new(io::Error::new(
            io::ErrorKind::Other,
            format!("Internal error!"),
        )))
    }
}

/// Downloads and deserializes the analysis result from the presigned URL.
///
/// # Arguments
///
/// * `analysis_url` - The presigned URL returned by [`get_analyze_status`] when ready.
/// * `client` - A preconfigured `reqwest::blocking::Client`.
///
/// # Returns
///
/// An `AnalysisResult` containing metadata about the uploaded binary.
///
/// # Errors
///
/// Returns an error if the download fails, the server responds with a non-success status, or deserialization fails.
pub fn download_analysis_result(
    analysis_url: &str,
    client: &Client,
) -> Result<AnalysisResult, Box<dyn Error>> {
    let response = client.get(analysis_url).send()?.error_for_status()?;
    let result_bytes = response.bytes()?;
    let analysis_result: AnalysisResult = serde_json::from_slice(&result_bytes)?;
    Ok(analysis_result)
}

/// Starts the obfuscation process for a given file using the provided configuration.
///
/// # Arguments
///
/// * `uuid` - UUID of the uploaded binary file (not the PDB).
/// * `config` - Obfuscation configuration as a `CDConfig`.
/// * `client` - A preconfigured `reqwest::blocking::Client`.
/// * `api_key` - Your CodeDefender API key.
///
/// # Returns
///
/// A `Result<String, reqwest::Error>` containing the `execution_id` used for polling.
///
/// # Errors
///
/// Returns an error if the request fails or the server returns a non-success status.
pub fn defend(
    uuid: String,
    config: Config,
    client: &Client,
    api_key: &str,
) -> Result<String, reqwest::Error> {
    let body = serde_json::to_string(&config).expect("Failed to serialize CDConfig");
    let mut query_params = HashMap::new();
    query_params.insert("fileId", uuid);

    let response = client
        .post(&*DEFEND_EP)
        .header("Authorization", format!("ApiKey {}", api_key))
        .header("Content-Type", "application/json")
        .query(&query_params)
        .body(body)
        .send()?
        .error_for_status()?;

    response.text()
}

/// Polls the obfuscation status.
///
/// This endpoint should be called every 500 milliseconds until the obfuscation is complete.
///
/// ⚠️ Note: This endpoint is rate-limited to **200 requests per minute**.
///
/// # Arguments
///
/// * `execution_id` - The execution ID returned by [`defend`].
/// * `client` - A preconfigured `reqwest::blocking::Client`.
/// * `api_key` - Your CodeDefender API key.
///
/// # Returns
///
/// A [`Status`] enum indicating whether the file is ready (with presigned URL), still processing, or failed.
pub fn download(execution_id: String, client: &Client, api_key: &str) -> Status {
    let mut query_params = HashMap::new();
    query_params.insert("executionId".to_string(), execution_id);
    let result = client
        .get(&*DOWNLOAD_EP)
        .header("Authorization", format!("ApiKey {}", api_key))
        .query(&query_params)
        .send();
    let Ok(resp) = result else {
        return Status::Failed(Box::new(result.unwrap_err()));
    };
    let status = resp.status();
    if status == StatusCode::ACCEPTED {
        Status::Processing
    } else if status == StatusCode::OK {
        let json: serde_json::Value = match resp.json() {
            Ok(v) => v,
            Err(e) => return Status::Failed(Box::new(e)),
        };

        match json["downloadUrl"]
            .as_str()
            .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "missing downloadUrl"))
        {
            Ok(value) => Status::Ready(value.to_string()),
            Err(e) => Status::Failed(Box::new(e)),
        }
    } else {
        Status::Failed(Box::new(io::Error::new(
            io::ErrorKind::Other,
            format!("Internal error!"),
        )))
    }
}

/// Downloads the obfuscated file from the presigned URL.
///
/// # Arguments
///
/// * `download_url` - The presigned URL returned by [`download`] when ready.
/// * `client` - A preconfigured `reqwest::blocking::Client`.
///
/// # Returns
///
/// A `Result<Vec<u8>, Box<dyn Error>>` containing the obfuscated file bytes.
///
/// # Errors
///
/// Returns an error if the download fails or the server responds with a non-success status.
pub fn download_obfuscated_file(
    download_url: &str,
    client: &Client,
) -> Result<Vec<u8>, Box<dyn Error>> {
    let response = client.get(download_url).send()?.error_for_status()?;
    let bytes = response.bytes()?;
    Ok(bytes.to_vec())
}