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}