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    Ok((file_id, upload_url))
83}
84
85/// Uploads file bytes to the presigned S3 URL.
86///
87/// # Arguments
88///
89/// * `upload_url` - The presigned S3 upload URL.
90/// * `file_bytes` - The raw contents of the file to upload.
91/// * `client` - A preconfigured `reqwest::blocking::Client`.
92///
93/// # Returns
94///
95/// `Ok(())` on success.
96///
97/// # Errors
98///
99/// Returns an error if the upload fails or the server responds with a non-success status code.
100pub fn upload_to_s3(
101    upload_url: &str,
102    file_bytes: Vec<u8>,
103    client: &Client,
104) -> Result<(), Box<dyn Error>> {
105    client
106        .put(upload_url)
107        .header("Content-Type", "application/octet-stream")
108        .header("Content-Length", file_bytes.len().to_string())
109        .body(file_bytes)
110        .send()?
111        .error_for_status()?;
112    Ok(())
113}
114
115/// Uploads raw data bytes to CodeDefender with a specific filename and returns the file ID.
116///
117/// # Arguments
118///
119/// * `data` - The raw bytes to upload.
120/// * `filename` - The specific filename to use for the upload.
121/// * `client` - A preconfigured `reqwest::blocking::Client`.
122/// * `api_key` - Your CodeDefender API key.
123///
124/// # Returns
125///
126/// A `Result<String, Box<dyn Error>>` containing the file ID on success.
127///
128/// # Errors
129///
130/// Returns an error if the request fails or if the server responds with a non-success status code.
131pub fn upload_data(
132    data: Vec<u8>,
133    filename: String,
134    client: &Client,
135    api_key: &str,
136) -> Result<String, Box<dyn Error>> {
137    let file_size = data.len();
138    let (file_id, upload_url) = get_upload_info(file_size, Some(filename), client, api_key)?;
139    upload_to_s3(&upload_url, data, client)?;
140    Ok(file_id)
141}
142
143/// Uploads a binary file to CodeDefender and returns a UUID representing the uploaded file.
144///
145/// # Arguments
146///
147/// * `file_bytes` - The raw contents of the binary file to upload.
148/// * `client` - A preconfigured `reqwest::blocking::Client`.
149/// * `api_key` - Your CodeDefender API key.
150///
151/// # Returns
152///
153/// A `Result<String, Box<dyn Error>>` containing the UUID on success, or an error if the upload failed.
154///
155/// # Errors
156///
157/// Returns an error if the request fails or if the server responds with a non-success status code (not in 200..=299).
158pub fn upload_file(
159    file_bytes: Vec<u8>,
160    client: &Client,
161    api_key: &str,
162) -> Result<String, Box<dyn Error>> {
163    let file_size = file_bytes.len();
164    let (file_id, upload_url) = get_upload_info(file_size, None, client, api_key)?;
165    upload_to_s3(&upload_url, file_bytes, client)?;
166    Ok(file_id)
167}
168
169/// Starts analysis of a previously uploaded binary file and optionally its PDB file.
170///
171/// # Arguments
172///
173/// * `file_id` - UUID of the uploaded binary file.
174/// * `pdb_file_id` - Optional UUID of the associated PDB file.
175/// * `client` - A preconfigured `reqwest::blocking::Client`.
176/// * `api_key` - Your CodeDefender API key.
177///
178/// # Returns
179///
180/// A `Result<String, Box<dyn Error>>` containing the execution ID for polling.
181///
182/// # Errors
183///
184/// Returns an error if the request fails or the server responds with a non-success status.
185pub fn start_analyze(
186    file_id: String,
187    pdb_file_id: Option<String>,
188    client: &Client,
189    api_key: &str,
190) -> Result<String, Box<dyn Error>> {
191    let mut query_params = HashMap::new();
192    query_params.insert("fileId".to_string(), file_id);
193    if let Some(pdb_id) = pdb_file_id {
194        query_params.insert("pdbFileId".to_string(), pdb_id);
195    }
196    let response = client
197        .put(&*ANALYZE_EP)
198        .header("Authorization", format!("ApiKey {}", api_key))
199        .query(&query_params)
200        .send()?
201        .error_for_status()?;
202    let json: HashMap<String, String> = response.json()?;
203    let execution_id = json
204        .get("executionId")
205        .cloned()
206        .ok_or("Missing executionId")?;
207    Ok(execution_id)
208}
209
210/// Polls the analysis status.
211///
212/// This endpoint should be called periodically until the analysis is complete.
213///
214/// # Arguments
215///
216/// * `execution_id` - The execution ID returned by [`start_analyze`].
217/// * `client` - A preconfigured `reqwest::blocking::Client`.
218/// * `api_key` - Your CodeDefender API key.
219///
220/// # Returns
221///
222/// An [`Status`] enum indicating whether the analysis is ready (with presigned URL), still processing, or failed.
223pub fn get_analyze_status(execution_id: String, client: &Client, api_key: &str) -> Status {
224    let mut query_params = HashMap::new();
225    query_params.insert("executionId".to_string(), execution_id);
226    let result = client
227        .get(&*ANALYZE_STATUS_EP)
228        .header("Authorization", format!("ApiKey {}", api_key))
229        .query(&query_params)
230        .send();
231    let Ok(resp) = result else {
232        return Status::Failed(Box::new(result.unwrap_err()));
233    };
234    let status = resp.status();
235    if status == StatusCode::ACCEPTED {
236        Status::Processing
237    } else if status == StatusCode::OK {
238        let json: serde_json::Value = match resp.json() {
239            Ok(v) => v,
240            Err(e) => return Status::Failed(Box::new(e)),
241        };
242
243        match json["analysisUrl"]
244            .as_str()
245            .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "missing analysisUrl"))
246        {
247            Ok(value) => Status::Ready(value.to_string()),
248            Err(e) => Status::Failed(Box::new(e)),
249        }
250    } else {
251        Status::Failed(Box::new(io::Error::new(
252            io::ErrorKind::Other,
253            format!("Internal error!"),
254        )))
255    }
256}
257
258/// Downloads and deserializes the analysis result from the presigned URL.
259///
260/// # Arguments
261///
262/// * `analysis_url` - The presigned URL returned by [`get_analyze_status`] when ready.
263/// * `client` - A preconfigured `reqwest::blocking::Client`.
264///
265/// # Returns
266///
267/// An `AnalysisResult` containing metadata about the uploaded binary.
268///
269/// # Errors
270///
271/// Returns an error if the download fails, the server responds with a non-success status, or deserialization fails.
272pub fn download_analysis_result(
273    analysis_url: &str,
274    client: &Client,
275) -> Result<AnalysisResult, Box<dyn Error>> {
276    let response = client.get(analysis_url).send()?.error_for_status()?;
277    let result_bytes = response.bytes()?;
278    let analysis_result: AnalysisResult = serde_json::from_slice(&result_bytes)?;
279    Ok(analysis_result)
280}
281
282/// Starts the obfuscation process for a given file using the provided configuration.
283///
284/// # Arguments
285///
286/// * `uuid` - UUID of the uploaded binary file (not the PDB).
287/// * `config` - Obfuscation configuration as a `CDConfig`.
288/// * `client` - A preconfigured `reqwest::blocking::Client`.
289/// * `api_key` - Your CodeDefender API key.
290///
291/// # Returns
292///
293/// A `Result<String, reqwest::Error>` containing the `execution_id` used for polling.
294///
295/// # Errors
296///
297/// Returns an error if the request fails or the server returns a non-success status.
298pub fn defend(
299    uuid: String,
300    config: Config,
301    client: &Client,
302    api_key: &str,
303) -> Result<String, reqwest::Error> {
304    let body = serde_json::to_string(&config).expect("Failed to serialize CDConfig");
305    let mut query_params = HashMap::new();
306    query_params.insert("fileId", uuid);
307
308    let response = client
309        .post(&*DEFEND_EP)
310        .header("Authorization", format!("ApiKey {}", api_key))
311        .header("Content-Type", "application/json")
312        .query(&query_params)
313        .body(body)
314        .send()?
315        .error_for_status()?;
316
317    response.text()
318}
319
320/// Polls the obfuscation status.
321///
322/// This endpoint should be called every 500 milliseconds until the obfuscation is complete.
323///
324/// ⚠️ Note: This endpoint is rate-limited to **200 requests per minute**.
325///
326/// # Arguments
327///
328/// * `execution_id` - The execution ID returned by [`defend`].
329/// * `client` - A preconfigured `reqwest::blocking::Client`.
330/// * `api_key` - Your CodeDefender API key.
331///
332/// # Returns
333///
334/// A [`Status`] enum indicating whether the file is ready (with presigned URL), still processing, or failed.
335pub fn download(execution_id: String, client: &Client, api_key: &str) -> Status {
336    let mut query_params = HashMap::new();
337    query_params.insert("executionId".to_string(), execution_id);
338    let result = client
339        .get(&*DOWNLOAD_EP)
340        .header("Authorization", format!("ApiKey {}", api_key))
341        .query(&query_params)
342        .send();
343    let Ok(resp) = result else {
344        return Status::Failed(Box::new(result.unwrap_err()));
345    };
346    let status = resp.status();
347    if status == StatusCode::ACCEPTED {
348        Status::Processing
349    } else if status == StatusCode::OK {
350        let json: serde_json::Value = match resp.json() {
351            Ok(v) => v,
352            Err(e) => return Status::Failed(Box::new(e)),
353        };
354
355        match json["downloadUrl"]
356            .as_str()
357            .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "missing downloadUrl"))
358        {
359            Ok(value) => Status::Ready(value.to_string()),
360            Err(e) => Status::Failed(Box::new(e)),
361        }
362    } else {
363        Status::Failed(Box::new(io::Error::new(
364            io::ErrorKind::Other,
365            format!("Internal error!"),
366        )))
367    }
368}
369
370/// Downloads the obfuscated file from the presigned URL.
371///
372/// # Arguments
373///
374/// * `download_url` - The presigned URL returned by [`download`] when ready.
375/// * `client` - A preconfigured `reqwest::blocking::Client`.
376///
377/// # Returns
378///
379/// A `Result<Vec<u8>, Box<dyn Error>>` containing the obfuscated file bytes.
380///
381/// # Errors
382///
383/// Returns an error if the download fails or the server responds with a non-success status.
384pub fn download_obfuscated_file(
385    download_url: &str,
386    client: &Client,
387) -> Result<Vec<u8>, Box<dyn Error>> {
388    let response = client.get(download_url).send()?.error_for_status()?;
389    let bytes = response.bytes()?;
390    Ok(bytes.to_vec())
391}