1use std::collections::HashMap;
2#[cfg(feature = "local")]
3use std::path::Path as StdPath;
4
5use alien_error::{AlienError, Context, IntoAlienError};
6use bytes::Bytes;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9#[cfg(feature = "local")]
10use tokio::{fs, io::AsyncReadExt};
11
12#[cfg(feature = "openapi")]
13use utoipa::ToSchema;
14
15use crate::error::{ErrorData, Result};
16
17pub const PRESIGNED_RESPONSE_TOO_LARGE_MESSAGE: &str =
22 "presigned response exceeded the caller's maximum size";
23
24#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28#[cfg_attr(feature = "openapi", derive(ToSchema))]
29pub struct PresignedRequest {
30 pub backend: PresignedRequestBackend,
32 pub expiration: DateTime<Utc>,
34 pub operation: PresignedOperation,
36 pub path: String,
38}
39
40#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
42#[serde(tag = "type", rename_all = "camelCase")]
43#[cfg_attr(feature = "openapi", derive(ToSchema))]
44pub enum PresignedRequestBackend {
45 #[serde(rename_all = "camelCase")]
47 Http {
48 url: String,
49 method: String,
50 headers: HashMap<String, String>,
51 },
52 #[serde(rename_all = "camelCase")]
54 Local {
55 file_path: String,
56 operation: LocalOperation,
57 },
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63#[cfg_attr(feature = "openapi", derive(ToSchema))]
64pub enum PresignedOperation {
65 Put,
67 Get,
69 Delete,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76#[cfg_attr(feature = "openapi", derive(ToSchema))]
77pub enum LocalOperation {
78 Put,
79 Get,
80 Delete,
81}
82
83#[derive(Debug)]
85pub struct PresignedResponse {
86 pub status_code: u16,
88 pub headers: HashMap<String, String>,
90 pub body: Option<Bytes>,
92}
93
94pub fn redact_url_for_error(raw: &str) -> String {
101 if let Ok(mut parsed) = url::Url::parse(raw) {
102 let _ = parsed.set_username("");
103 let _ = parsed.set_password(None);
104 parsed.set_query(None);
105 parsed.set_fragment(None);
106 return parsed.to_string();
107 }
108
109 if raw.starts_with('/') && !raw.starts_with("//") {
110 return raw
111 .split(['?', '#'])
112 .next()
113 .filter(|value| !value.is_empty())
114 .unwrap_or("<invalid-url>")
115 .to_string();
116 }
117
118 "<invalid-url>".to_string()
119}
120
121impl PresignedRequest {
122 pub fn new_http(
124 url: String,
125 method: String,
126 headers: HashMap<String, String>,
127 operation: PresignedOperation,
128 path: String,
129 expiration: DateTime<Utc>,
130 ) -> Self {
131 Self {
132 backend: PresignedRequestBackend::Http {
133 url,
134 method,
135 headers,
136 },
137 expiration,
138 operation,
139 path,
140 }
141 }
142
143 pub fn new_local(
145 file_path: String,
146 operation: PresignedOperation,
147 path: String,
148 expiration: DateTime<Utc>,
149 ) -> Self {
150 let local_op = match operation {
151 PresignedOperation::Put => LocalOperation::Put,
152 PresignedOperation::Get => LocalOperation::Get,
153 PresignedOperation::Delete => LocalOperation::Delete,
154 };
155
156 Self {
157 backend: PresignedRequestBackend::Local {
158 file_path,
159 operation: local_op,
160 },
161 expiration,
162 operation,
163 path,
164 }
165 }
166
167 pub async fn execute(&self, body: Option<Bytes>) -> Result<PresignedResponse> {
171 let client = reqwest::Client::new();
172 self.execute_with_client(&client, body).await
173 }
174
175 pub async fn execute_with_response_limit(
178 &self,
179 body: Option<Bytes>,
180 max_response_bytes: usize,
181 ) -> Result<PresignedResponse> {
182 let client = reqwest::Client::new();
183 self.execute_with_client_inner(&client, body, Some(max_response_bytes))
184 .await
185 }
186
187 pub async fn execute_with_client(
192 &self,
193 client: &reqwest::Client,
194 body: Option<Bytes>,
195 ) -> Result<PresignedResponse> {
196 self.execute_with_client_inner(client, body, None).await
197 }
198
199 async fn execute_with_client_inner(
200 &self,
201 client: &reqwest::Client,
202 body: Option<Bytes>,
203 max_response_bytes: Option<usize>,
204 ) -> Result<PresignedResponse> {
205 match &self.backend {
206 PresignedRequestBackend::Http {
207 url,
208 method,
209 headers,
210 } => {
211 self.execute_http(client, url, method, headers, body, max_response_bytes)
212 .await
213 }
214 PresignedRequestBackend::Local {
215 file_path,
216 operation,
217 } => {
218 #[cfg(feature = "local")]
219 {
220 self.execute_local(file_path, *operation, body, max_response_bytes)
221 .await
222 }
223 #[cfg(not(feature = "local"))]
224 {
225 let _ = (file_path, operation);
226 Err(AlienError::new(ErrorData::FeatureNotEnabled {
227 feature: "local".to_string(),
228 }))
229 }
230 }
231 }
232 }
233
234 pub fn url(&self) -> String {
238 match &self.backend {
239 PresignedRequestBackend::Http { url, .. } => url.clone(),
240 PresignedRequestBackend::Local { file_path, .. } => {
241 format!("local://{}", file_path)
242 }
243 }
244 }
245
246 pub fn is_expired(&self) -> bool {
248 Utc::now() > self.expiration
249 }
250
251 pub fn method(&self) -> &str {
253 match &self.backend {
254 PresignedRequestBackend::Http { method, .. } => method,
255 PresignedRequestBackend::Local { operation, .. } => match operation {
256 LocalOperation::Put => "PUT",
257 LocalOperation::Get => "GET",
258 LocalOperation::Delete => "DELETE",
259 },
260 }
261 }
262
263 pub fn headers(&self) -> HashMap<String, String> {
265 match &self.backend {
266 PresignedRequestBackend::Http { headers, .. } => headers.clone(),
267 _ => HashMap::new(),
268 }
269 }
270
271 async fn execute_http(
272 &self,
273 client: &reqwest::Client,
274 url: &str,
275 method: &str,
276 headers: &HashMap<String, String>,
277 body: Option<Bytes>,
278 max_response_bytes: Option<usize>,
279 ) -> Result<PresignedResponse> {
280 if self.is_expired() {
281 return Err(AlienError::new(ErrorData::PresignedRequestExpired {
282 path: self.path.clone(),
283 expired_at: self.expiration,
284 }));
285 }
286
287 let mut request = match method {
288 "PUT" => client.put(url),
289 "GET" => client.get(url),
290 "DELETE" => client.delete(url),
291 _ => {
292 return Err(AlienError::new(ErrorData::OperationNotSupported {
293 operation: format!("HTTP method: {}", method),
294 reason: "Only PUT, GET, and DELETE are supported".to_string(),
295 }))
296 }
297 };
298
299 for (key, value) in headers {
301 request = request.header(key, value);
302 }
303
304 if let Some(data) = body {
306 request = request.body(data);
307 }
308
309 let safe_url = redact_url_for_error(url);
310 let mut response = request
311 .send()
312 .await
313 .map_err(reqwest::Error::without_url)
314 .into_alien_error()
315 .context(ErrorData::HttpRequestFailed {
316 url: safe_url.clone(),
317 method: method.to_string(),
318 })?;
319
320 let status_code = response.status().as_u16();
321 let response_headers = response
322 .headers()
323 .iter()
324 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
325 .collect();
326
327 let response_body = if max_response_bytes.is_some() && !(200..300).contains(&status_code) {
328 None
331 } else if matches!(self.operation, PresignedOperation::Get) {
332 if let Some(max_bytes) = max_response_bytes {
333 if response
334 .content_length()
335 .is_some_and(|length| length > max_bytes as u64)
336 {
337 return Err(presigned_response_too_large());
338 }
339
340 let mut bytes = Vec::with_capacity(
341 response
342 .content_length()
343 .unwrap_or_default()
344 .min(max_bytes as u64) as usize,
345 );
346 while let Some(chunk) = response
347 .chunk()
348 .await
349 .map_err(reqwest::Error::without_url)
350 .into_alien_error()
351 .context(ErrorData::HttpRequestFailed {
352 url: safe_url.clone(),
353 method: method.to_string(),
354 })?
355 {
356 if chunk.len() > max_bytes.saturating_sub(bytes.len()) {
357 return Err(presigned_response_too_large());
358 }
359 bytes.extend_from_slice(&chunk);
360 }
361 Some(Bytes::from(bytes))
362 } else {
363 Some(
364 response
365 .bytes()
366 .await
367 .map_err(reqwest::Error::without_url)
368 .into_alien_error()
369 .context(ErrorData::HttpRequestFailed {
370 url: safe_url,
371 method: method.to_string(),
372 })?,
373 )
374 }
375 } else {
376 None
377 };
378
379 Ok(PresignedResponse {
380 status_code,
381 headers: response_headers,
382 body: response_body,
383 })
384 }
385
386 #[cfg(feature = "local")]
387 async fn execute_local(
388 &self,
389 file_path: &str,
390 operation: LocalOperation,
391 body: Option<Bytes>,
392 max_response_bytes: Option<usize>,
393 ) -> Result<PresignedResponse> {
394 if self.is_expired() {
395 return Err(AlienError::new(ErrorData::PresignedRequestExpired {
396 path: self.path.clone(),
397 expired_at: self.expiration,
398 }));
399 }
400
401 let path = StdPath::new(file_path);
402
403 match operation {
404 LocalOperation::Put => {
405 let data = body.ok_or_else(|| {
406 AlienError::new(ErrorData::OperationNotSupported {
407 operation: "Local PUT without body".to_string(),
408 reason: "PUT operations require body data".to_string(),
409 })
410 })?;
411
412 if let Some(parent) = path.parent() {
414 fs::create_dir_all(parent)
415 .await
416 .into_alien_error()
417 .context(ErrorData::LocalFilesystemError {
418 path: file_path.to_string(),
419 operation: "create_parent_dirs".to_string(),
420 })?;
421 }
422
423 let write_result: std::io::Result<()> = fs::write(path, data.as_ref()).await;
424 write_result
425 .into_alien_error()
426 .context(ErrorData::LocalFilesystemError {
427 path: file_path.to_string(),
428 operation: "write".to_string(),
429 })?;
430
431 Ok(PresignedResponse {
432 status_code: 200,
433 headers: HashMap::new(),
434 body: None,
435 })
436 }
437 LocalOperation::Get => {
438 let data = if let Some(max_bytes) = max_response_bytes {
439 let file = fs::File::open(path).await.into_alien_error().context(
440 ErrorData::LocalFilesystemError {
441 path: file_path.to_string(),
442 operation: "open".to_string(),
443 },
444 )?;
445 let mut data = Vec::with_capacity(max_bytes.min(64 * 1024));
446 file.take(max_bytes.saturating_add(1) as u64)
447 .read_to_end(&mut data)
448 .await
449 .into_alien_error()
450 .context(ErrorData::LocalFilesystemError {
451 path: file_path.to_string(),
452 operation: "read".to_string(),
453 })?;
454 if data.len() > max_bytes {
455 return Err(presigned_response_too_large());
456 }
457 data
458 } else {
459 fs::read(path).await.into_alien_error().context(
460 ErrorData::LocalFilesystemError {
461 path: file_path.to_string(),
462 operation: "read".to_string(),
463 },
464 )?
465 };
466
467 Ok(PresignedResponse {
468 status_code: 200,
469 headers: HashMap::new(),
470 body: Some(Bytes::from(data)),
471 })
472 }
473 LocalOperation::Delete => {
474 fs::remove_file(path).await.into_alien_error().context(
475 ErrorData::LocalFilesystemError {
476 path: file_path.to_string(),
477 operation: "delete".to_string(),
478 },
479 )?;
480
481 Ok(PresignedResponse {
482 status_code: 200,
483 headers: HashMap::new(),
484 body: None,
485 })
486 }
487 }
488 }
489}
490
491fn presigned_response_too_large() -> AlienError<ErrorData> {
492 AlienError::new(ErrorData::GenericError {
493 message: PRESIGNED_RESPONSE_TOO_LARGE_MESSAGE.to_string(),
494 })
495}
496
497#[cfg(test)]
498mod tests {
499 use super::redact_url_for_error;
500
501 #[test]
502 fn redacts_query_fragment_and_user_info_from_diagnostic_urls() {
503 let secret = "do-not-log-this-token";
504 let sanitized = redact_url_for_error(&format!(
505 "https://user:{secret}@storage.example.com/object?X-Amz-Signature={secret}#fragment"
506 ));
507
508 assert_eq!(sanitized, "https://storage.example.com/object");
509 assert!(!sanitized.contains(secret));
510 }
511
512 #[test]
513 fn redacts_query_from_relative_urls() {
514 assert_eq!(
515 redact_url_for_error("/v1/commands/cmd/response?response_token=secret"),
516 "/v1/commands/cmd/response"
517 );
518 }
519
520 #[test]
521 fn does_not_echo_unparseable_urls() {
522 let secret = "do-not-log-this-token";
523 assert_eq!(
524 redact_url_for_error(&format!("not a URL containing {secret}")),
525 "<invalid-url>"
526 );
527 }
528}