codedefender_api/
lib.rs

1//! High-level client interface for interacting with the CodeDefender SaaS API.
2//!
3//! This module provides functions to upload files, analyze binaries, initiate
4//! obfuscation, and poll for obfuscation results via blocking HTTP requests.
5//!
6//! All endpoints require a valid API key, passed via the `Authorization` header
7//! using the `ApiKey` scheme.
8use codedefender_config::{AnalysisResult, Config};
9use reqwest::{StatusCode, blocking::Client};
10use std::collections::HashMap;
11use std::error::Error;
12use std::io;
13use once_cell::sync::Lazy;
14
15pub use codedefender_config;
16pub use serde_json;
17
18/// Changing the BASE_URL env variable allows you to specify a different backend like staging or local.
19pub static BASE_URL: Lazy<String> = Lazy::new(|| {
20    std::env::var("BASE_URL").unwrap_or_else(|_| "https://app.codedefender.io".into())
21});
22
23pub static GET_UPLOAD_URL_EP: Lazy<String> =
24    Lazy::new(|| format!("{}/api/get-upload-url", *BASE_URL));
25
26pub static ANALYZE_EP: Lazy<String> =
27    Lazy::new(|| format!("{}/api/analyze", *BASE_URL));
28
29pub static ANALYZE_STATUS_EP: Lazy<String> =
30    Lazy::new(|| format!("{}/api/analyze-status", *BASE_URL));
31
32pub static DEFEND_EP: Lazy<String> =
33    Lazy::new(|| format!("{}/api/defend", *BASE_URL));
34
35pub static DOWNLOAD_EP: Lazy<String> =
36    Lazy::new(|| format!("{}/api/download", *BASE_URL));
37
38pub enum Status {
39    Ready(String),
40    Processing,
41    Failed(Box<dyn Error>),
42}
43
44/// Gets the presigned upload URL and file ID for uploading a file.
45///
46/// # Arguments
47///
48/// * `file_size` - The size of the file to upload in bytes.
49/// * `file_name` - Optional custom file name. If provided, it will be used; otherwise, a random UUID will be generated by the server.
50/// * `client` - A preconfigured `reqwest::blocking::Client`.
51/// * `api_key` - Your CodeDefender API key.
52///
53/// # Returns
54///
55/// A `Result<(String, String), Box<dyn Error>>` containing the file ID and presigned upload URL on success.
56///
57/// # Errors
58///
59/// Returns an error if the request fails or if the server responds with a non-success status code.
60pub fn get_upload_info(
61    file_size: usize,
62    file_name: Option<String>,
63    client: &Client,
64    api_key: &str,
65) -> Result<(String, String), Box<dyn Error>> {
66    let mut query_params = HashMap::new();
67    query_params.insert("fileSize".to_string(), file_size.to_string());
68    if let Some(name) = file_name {
69        query_params.insert("fileName".to_string(), name);
70    }
71
72    let response = client
73        .get(&*GET_UPLOAD_URL_EP)
74        .header("Authorization", format!("ApiKey {}", api_key))
75        .query(&query_params)
76        .send()?
77        .error_for_status()?;
78
79    let json: HashMap<String, String> = response.json()?;
80    let upload_url = json.get("uploadUrl").cloned().ok_or("Missing uploadUrl")?;
81    let file_id = json.get("fileId").cloned().ok_or("Missing fileId")?;
82
83    Ok((file_id, upload_url))
84}
85
86/// Uploads file bytes to the presigned S3 URL.
87///
88/// # Arguments
89///
90/// * `upload_url` - The presigned S3 upload URL.
91/// * `file_bytes` - The raw contents of the file to upload.
92/// * `client` - A preconfigured `reqwest::blocking::Client`.
93///
94/// # Returns
95///
96/// `Ok(())` on success.
97///
98/// # Errors
99///
100/// Returns an error if the upload fails or the server responds with a non-success status code.
101pub fn upload_to_s3(
102    upload_url: &str,
103    file_bytes: Vec<u8>,
104    client: &Client,
105) -> Result<(), Box<dyn Error>> {
106    client
107        .put(upload_url)
108        .header("Content-Type", "application/octet-stream")
109        .header("Content-Length", file_bytes.len().to_string())
110        .body(file_bytes)
111        .send()?
112        .error_for_status()?;
113    Ok(())
114}
115
116/// Uploads raw data bytes to CodeDefender with a specific filename and returns the file ID.
117///
118/// # Arguments
119///
120/// * `data` - The raw bytes to upload.
121/// * `filename` - The specific filename to use for the upload.
122/// * `client` - A preconfigured `reqwest::blocking::Client`.
123/// * `api_key` - Your CodeDefender API key.
124///
125/// # Returns
126///
127/// A `Result<String, Box<dyn Error>>` containing the file ID on success.
128///
129/// # Errors
130///
131/// Returns an error if the request fails or if the server responds with a non-success status code.
132pub fn upload_data(
133    data: Vec<u8>,
134    filename: String,
135    client: &Client,
136    api_key: &str,
137) -> Result<String, Box<dyn Error>> {
138    let file_size = data.len();
139    let (file_id, upload_url) = get_upload_info(file_size, Some(filename), client, api_key)?;
140    upload_to_s3(&upload_url, data, client)?;
141    Ok(file_id)
142}
143
144/// Uploads a binary file to CodeDefender and returns a UUID representing the uploaded file.
145///
146/// # Arguments
147///
148/// * `file_bytes` - The raw contents of the binary file to upload.
149/// * `client` - A preconfigured `reqwest::blocking::Client`.
150/// * `api_key` - Your CodeDefender API key.
151///
152/// # Returns
153///
154/// A `Result<String, Box<dyn Error>>` containing the UUID on success, or an error if the upload failed.
155///
156/// # Errors
157///
158/// Returns an error if the request fails or if the server responds with a non-success status code (not in 200..=299).
159pub fn upload_file(
160    file_bytes: Vec<u8>,
161    client: &Client,
162    api_key: &str,
163) -> Result<String, Box<dyn Error>> {
164    let file_size = file_bytes.len();
165    let (file_id, upload_url) = get_upload_info(file_size, None, client, api_key)?;
166    upload_to_s3(&upload_url, file_bytes, client)?;
167    Ok(file_id)
168}
169
170/// Starts analysis of a previously uploaded binary file and optionally its PDB file.
171///
172/// # Arguments
173///
174/// * `file_id` - UUID of the uploaded binary file.
175/// * `pdb_file_id` - Optional UUID of the associated PDB file.
176/// * `client` - A preconfigured `reqwest::blocking::Client`.
177/// * `api_key` - Your CodeDefender API key.
178///
179/// # Returns
180///
181/// A `Result<String, Box<dyn Error>>` containing the execution ID for polling.
182///
183/// # Errors
184///
185/// Returns an error if the request fails or the server responds with a non-success status.
186pub fn start_analyze(
187    file_id: String,
188    pdb_file_id: Option<String>,
189    client: &Client,
190    api_key: &str,
191) -> Result<String, Box<dyn Error>> {
192    let mut query_params = HashMap::new();
193    query_params.insert("fileId".to_string(), file_id);
194    if let Some(pdb_id) = pdb_file_id {
195        query_params.insert("pdbFileId".to_string(), pdb_id);
196    }
197    let response = client
198        .put(&*ANALYZE_EP)
199        .header("Authorization", format!("ApiKey {}", api_key))
200        .query(&query_params)
201        .send()?
202        .error_for_status()?;
203    let json: HashMap<String, String> = response.json()?;
204    let execution_id = json
205        .get("executionId")
206        .cloned()
207        .ok_or("Missing executionId")?;
208    Ok(execution_id)
209}
210
211/// Polls the analysis status.
212///
213/// This endpoint should be called periodically until the analysis is complete.
214///
215/// # Arguments
216///
217/// * `execution_id` - The execution ID returned by [`start_analyze`].
218/// * `client` - A preconfigured `reqwest::blocking::Client`.
219/// * `api_key` - Your CodeDefender API key.
220///
221/// # Returns
222///
223/// An [`Status`] enum indicating whether the analysis is ready (with presigned URL), still processing, or failed.
224pub fn get_analyze_status(execution_id: String, client: &Client, api_key: &str) -> Status {
225    let mut query_params = HashMap::new();
226    query_params.insert("executionId".to_string(), execution_id);
227    let result = client
228        .get(&*ANALYZE_STATUS_EP)
229        .header("Authorization", format!("ApiKey {}", api_key))
230        .query(&query_params)
231        .send();
232    let Ok(resp) = result else {
233        return Status::Failed(Box::new(result.unwrap_err()));
234    };
235    let status = resp.status();
236    if status == StatusCode::ACCEPTED {
237        Status::Processing
238    } else if status == StatusCode::OK {
239        let json: serde_json::Value = match resp.json() {
240            Ok(v) => v,
241            Err(e) => return Status::Failed(Box::new(e)),
242        };
243
244        match json["analysisUrl"]
245            .as_str()
246            .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "missing analysisUrl"))
247        {
248            Ok(value) => Status::Ready(value.to_string()),
249            Err(e) => Status::Failed(Box::new(e)),
250        }
251    } else {
252        Status::Failed(Box::new(io::Error::new(
253            io::ErrorKind::Other,
254            format!("Internal error!"),
255        )))
256    }
257}
258
259/// Downloads and deserializes the analysis result from the presigned URL.
260///
261/// # Arguments
262///
263/// * `analysis_url` - The presigned URL returned by [`get_analyze_status`] when ready.
264/// * `client` - A preconfigured `reqwest::blocking::Client`.
265///
266/// # Returns
267///
268/// An `AnalysisResult` containing metadata about the uploaded binary.
269///
270/// # Errors
271///
272/// Returns an error if the download fails, the server responds with a non-success status, or deserialization fails.
273pub fn download_analysis_result(
274    analysis_url: &str,
275    client: &Client,
276) -> Result<AnalysisResult, Box<dyn Error>> {
277    let response = client.get(analysis_url).send()?.error_for_status()?;
278    let result_bytes = response.bytes()?;
279    let analysis_result: AnalysisResult = serde_json::from_slice(&result_bytes)?;
280    Ok(analysis_result)
281}
282
283/// Starts the obfuscation process for a given file using the provided configuration.
284///
285/// # Arguments
286///
287/// * `uuid` - UUID of the uploaded binary file (not the PDB).
288/// * `config` - Obfuscation configuration as a `CDConfig`.
289/// * `client` - A preconfigured `reqwest::blocking::Client`.
290/// * `api_key` - Your CodeDefender API key.
291///
292/// # Returns
293///
294/// A `Result<String, reqwest::Error>` containing the `execution_id` used for polling.
295///
296/// # Errors
297///
298/// Returns an error if the request fails or the server returns a non-success status.
299pub fn defend(
300    uuid: String,
301    config: Config,
302    client: &Client,
303    api_key: &str,
304) -> Result<String, reqwest::Error> {
305    let body = serde_json::to_string(&config).expect("Failed to serialize CDConfig");
306    let mut query_params = HashMap::new();
307    query_params.insert("fileId", uuid);
308
309    let response = client
310        .post(&*DEFEND_EP)
311        .header("Authorization", format!("ApiKey {}", api_key))
312        .header("Content-Type", "application/json")
313        .query(&query_params)
314        .body(body)
315        .send()?
316        .error_for_status()?;
317
318    response.text()
319}
320
321/// Polls the obfuscation status.
322///
323/// This endpoint should be called every 500 milliseconds until the obfuscation is complete.
324///
325/// ⚠️ Note: This endpoint is rate-limited to **200 requests per minute**.
326///
327/// # Arguments
328///
329/// * `execution_id` - The execution ID returned by [`defend`].
330/// * `client` - A preconfigured `reqwest::blocking::Client`.
331/// * `api_key` - Your CodeDefender API key.
332///
333/// # Returns
334///
335/// A [`Status`] enum indicating whether the file is ready (with presigned URL), still processing, or failed.
336pub fn download(execution_id: String, client: &Client, api_key: &str) -> Status {
337    let mut query_params = HashMap::new();
338    query_params.insert("executionId".to_string(), execution_id);
339    let result = client
340        .get(&*DOWNLOAD_EP)
341        .header("Authorization", format!("ApiKey {}", api_key))
342        .query(&query_params)
343        .send();
344    let Ok(resp) = result else {
345        return Status::Failed(Box::new(result.unwrap_err()));
346    };
347    let status = resp.status();
348    if status == StatusCode::ACCEPTED {
349        Status::Processing
350    } else if status == StatusCode::OK {
351        let json: serde_json::Value = match resp.json() {
352            Ok(v) => v,
353            Err(e) => return Status::Failed(Box::new(e)),
354        };
355
356        match json["downloadUrl"]
357            .as_str()
358            .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "missing downloadUrl"))
359        {
360            Ok(value) => Status::Ready(value.to_string()),
361            Err(e) => Status::Failed(Box::new(e)),
362        }
363    } else {
364        Status::Failed(Box::new(io::Error::new(
365            io::ErrorKind::Other,
366            format!("Internal error!"),
367        )))
368    }
369}
370
371/// Downloads the obfuscated file from the presigned URL.
372///
373/// # Arguments
374///
375/// * `download_url` - The presigned URL returned by [`download`] when ready.
376/// * `client` - A preconfigured `reqwest::blocking::Client`.
377///
378/// # Returns
379///
380/// A `Result<Vec<u8>, Box<dyn Error>>` containing the obfuscated file bytes.
381///
382/// # Errors
383///
384/// Returns an error if the download fails or the server responds with a non-success status.
385pub fn download_obfuscated_file(
386    download_url: &str,
387    client: &Client,
388) -> Result<Vec<u8>, Box<dyn Error>> {
389    let response = client.get(download_url).send()?.error_for_status()?;
390    let bytes = response.bytes()?;
391    Ok(bytes.to_vec())
392}