Skip to main content

alien_core/
presigned.rs

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
17/// Stable message used when a bounded presigned GET exceeds its caller's
18/// explicit limit. This uses the existing generic error variant so adding the
19/// bounded API does not add a variant to the exhaustively matchable public
20/// [`ErrorData`] enum.
21pub const PRESIGNED_RESPONSE_TOO_LARGE_MESSAGE: &str =
22    "presigned response exceeded the caller's maximum size";
23
24/// A presigned request that can be serialized, stored, and executed later.
25/// Hides implementation details for different storage backends.
26#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28#[cfg_attr(feature = "openapi", derive(ToSchema))]
29pub struct PresignedRequest {
30    /// The storage backend this request targets
31    pub backend: PresignedRequestBackend,
32    /// When this presigned request expires
33    pub expiration: DateTime<Utc>,
34    /// The operation this request performs
35    pub operation: PresignedOperation,
36    /// The path this request operates on
37    pub path: String,
38}
39
40/// Storage backend representation for different presigned request types
41#[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    /// HTTP-based request (AWS S3, GCP GCS, Azure Blob)
46    #[serde(rename_all = "camelCase")]
47    Http {
48        url: String,
49        method: String,
50        headers: HashMap<String, String>,
51    },
52    /// Local filesystem operation
53    #[serde(rename_all = "camelCase")]
54    Local {
55        file_path: String,
56        operation: LocalOperation,
57    },
58}
59
60/// The type of operation a presigned request performs
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63#[cfg_attr(feature = "openapi", derive(ToSchema))]
64pub enum PresignedOperation {
65    /// Upload/put operation
66    Put,
67    /// Download/get operation
68    Get,
69    /// Delete operation
70    Delete,
71}
72
73/// Local filesystem operations
74#[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/// Response from executing a presigned request
84#[derive(Debug)]
85pub struct PresignedResponse {
86    /// HTTP status code (200, 404, etc.) or equivalent
87    pub status_code: u16,
88    /// Response headers
89    pub headers: HashMap<String, String>,
90    /// Response body (for GET operations)
91    pub body: Option<Bytes>,
92}
93
94/// Remove credentials from a URL before storing it in an error or log record.
95///
96/// Presigned storage URLs and command response URLs carry bearer-equivalent
97/// query values. User info, query parameters, and fragments are therefore
98/// never safe diagnostic context; the origin and path are enough to identify
99/// the failed endpoint.
100pub 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    /// Create a new HTTP-based presigned request
123    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    /// Create a new local filesystem presigned request
144    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    /// Execute this presigned request with optional body data.
168    /// For PUT operations, body should contain the data to upload.
169    /// For GET/DELETE operations, body is typically None.
170    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    /// Execute this request while bounding a GET response body before it is
176    /// buffered. PUT and DELETE requests do not have response bodies.
177    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    /// Execute this presigned request with a caller-owned HTTP client.
188    ///
189    /// Multi-step protocols should use this form so retries and adjacent HTTP
190    /// operations share one connection pool. Local requests ignore the client.
191    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    /// Get a URL representation of this presigned request.
235    /// For local storage, returns a local:// URL.
236    /// For cloud storage, returns the actual presigned URL.
237    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    /// Check if this presigned request has expired
247    pub fn is_expired(&self) -> bool {
248        Utc::now() > self.expiration
249    }
250
251    /// Get the HTTP method for this request (PUT, GET, DELETE)
252    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    /// Get any headers that should be included with this request
264    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        // Add headers
300        for (key, value) in headers {
301            request = request.header(key, value);
302        }
303
304        // Add body for PUT requests
305        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            // Bounded consumers need the status, not an untrusted error body.
329            // Do not buffer error responses that cannot be valid GET data.
330            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                // Create parent directories if needed
413                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}