Skip to main content

alien_core/
presigned.rs

1use crate::error::{ErrorData, Result};
2use alien_error::{AlienError, Context, IntoAlienError};
3use bytes::Bytes;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[cfg(feature = "openapi")]
9use utoipa::ToSchema;
10
11/// A presigned request that can be serialized, stored, and executed later.
12/// Hides implementation details for different storage backends.
13#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15#[cfg_attr(feature = "openapi", derive(ToSchema))]
16pub struct PresignedRequest {
17    /// The storage backend this request targets
18    pub backend: PresignedRequestBackend,
19    /// When this presigned request expires
20    pub expiration: DateTime<Utc>,
21    /// The operation this request performs
22    pub operation: PresignedOperation,
23    /// The path this request operates on
24    pub path: String,
25}
26
27/// Storage backend representation for different presigned request types
28#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "camelCase")]
30#[cfg_attr(feature = "openapi", derive(ToSchema))]
31pub enum PresignedRequestBackend {
32    /// HTTP-based request (AWS S3, GCP GCS, Azure Blob)
33    #[serde(rename_all = "camelCase")]
34    Http {
35        url: String,
36        method: String,
37        headers: HashMap<String, String>,
38    },
39    /// Local filesystem operation
40    #[serde(rename_all = "camelCase")]
41    Local {
42        file_path: String,
43        operation: LocalOperation,
44    },
45}
46
47/// The type of operation a presigned request performs
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50#[cfg_attr(feature = "openapi", derive(ToSchema))]
51pub enum PresignedOperation {
52    /// Upload/put operation
53    Put,
54    /// Download/get operation  
55    Get,
56    /// Delete operation
57    Delete,
58}
59
60/// Local filesystem operations
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63#[cfg_attr(feature = "openapi", derive(ToSchema))]
64pub enum LocalOperation {
65    Put,
66    Get,
67    Delete,
68}
69
70/// Response from executing a presigned request
71#[derive(Debug)]
72pub struct PresignedResponse {
73    /// HTTP status code (200, 404, etc.) or equivalent
74    pub status_code: u16,
75    /// Response headers
76    pub headers: HashMap<String, String>,
77    /// Response body (for GET operations)
78    pub body: Option<Bytes>,
79}
80
81/// Remove credentials from a URL before storing it in an error or log record.
82///
83/// Presigned storage URLs and command response URLs carry bearer-equivalent
84/// query values. User info, query parameters, and fragments are therefore
85/// never safe diagnostic context; the origin and path are enough to identify
86/// the failed endpoint.
87pub fn redact_url_for_error(raw: &str) -> String {
88    if let Ok(mut parsed) = url::Url::parse(raw) {
89        let _ = parsed.set_username("");
90        let _ = parsed.set_password(None);
91        parsed.set_query(None);
92        parsed.set_fragment(None);
93        return parsed.to_string();
94    }
95
96    if raw.starts_with('/') && !raw.starts_with("//") {
97        return raw
98            .split(['?', '#'])
99            .next()
100            .filter(|value| !value.is_empty())
101            .unwrap_or("<invalid-url>")
102            .to_string();
103    }
104
105    "<invalid-url>".to_string()
106}
107
108impl PresignedRequest {
109    /// Create a new HTTP-based presigned request
110    pub fn new_http(
111        url: String,
112        method: String,
113        headers: HashMap<String, String>,
114        operation: PresignedOperation,
115        path: String,
116        expiration: DateTime<Utc>,
117    ) -> Self {
118        Self {
119            backend: PresignedRequestBackend::Http {
120                url,
121                method,
122                headers,
123            },
124            expiration,
125            operation,
126            path,
127        }
128    }
129
130    /// Create a new local filesystem presigned request
131    pub fn new_local(
132        file_path: String,
133        operation: PresignedOperation,
134        path: String,
135        expiration: DateTime<Utc>,
136    ) -> Self {
137        let local_op = match operation {
138            PresignedOperation::Put => LocalOperation::Put,
139            PresignedOperation::Get => LocalOperation::Get,
140            PresignedOperation::Delete => LocalOperation::Delete,
141        };
142
143        Self {
144            backend: PresignedRequestBackend::Local {
145                file_path,
146                operation: local_op,
147            },
148            expiration,
149            operation,
150            path,
151        }
152    }
153
154    /// Execute this presigned request with optional body data.
155    /// For PUT operations, body should contain the data to upload.
156    /// For GET/DELETE operations, body is typically None.
157    pub async fn execute(&self, body: Option<Bytes>) -> Result<PresignedResponse> {
158        let client = reqwest::Client::new();
159        self.execute_with_client(&client, body).await
160    }
161
162    /// Execute this presigned request with a caller-owned HTTP client.
163    ///
164    /// Multi-step protocols should use this form so retries and adjacent HTTP
165    /// operations share one connection pool. Local requests ignore the client.
166    pub async fn execute_with_client(
167        &self,
168        client: &reqwest::Client,
169        body: Option<Bytes>,
170    ) -> Result<PresignedResponse> {
171        match &self.backend {
172            PresignedRequestBackend::Http {
173                url,
174                method,
175                headers,
176            } => self.execute_http(client, url, method, headers, body).await,
177            PresignedRequestBackend::Local {
178                file_path,
179                operation,
180            } => {
181                #[cfg(feature = "local")]
182                {
183                    self.execute_local(file_path, *operation, body).await
184                }
185                #[cfg(not(feature = "local"))]
186                {
187                    let _ = (file_path, operation);
188                    Err(AlienError::new(ErrorData::FeatureNotEnabled {
189                        feature: "local".to_string(),
190                    }))
191                }
192            }
193        }
194    }
195
196    /// Get a URL representation of this presigned request.
197    /// For local storage, returns a local:// URL.
198    /// For cloud storage, returns the actual presigned URL.
199    pub fn url(&self) -> String {
200        match &self.backend {
201            PresignedRequestBackend::Http { url, .. } => url.clone(),
202            PresignedRequestBackend::Local { file_path, .. } => {
203                format!("local://{}", file_path)
204            }
205        }
206    }
207
208    /// Check if this presigned request has expired
209    pub fn is_expired(&self) -> bool {
210        Utc::now() > self.expiration
211    }
212
213    /// Get the HTTP method for this request (PUT, GET, DELETE)
214    pub fn method(&self) -> &str {
215        match &self.backend {
216            PresignedRequestBackend::Http { method, .. } => method,
217            PresignedRequestBackend::Local { operation, .. } => match operation {
218                LocalOperation::Put => "PUT",
219                LocalOperation::Get => "GET",
220                LocalOperation::Delete => "DELETE",
221            },
222        }
223    }
224
225    /// Get any headers that should be included with this request
226    pub fn headers(&self) -> HashMap<String, String> {
227        match &self.backend {
228            PresignedRequestBackend::Http { headers, .. } => headers.clone(),
229            _ => HashMap::new(),
230        }
231    }
232
233    async fn execute_http(
234        &self,
235        client: &reqwest::Client,
236        url: &str,
237        method: &str,
238        headers: &HashMap<String, String>,
239        body: Option<Bytes>,
240    ) -> Result<PresignedResponse> {
241        if self.is_expired() {
242            return Err(AlienError::new(ErrorData::PresignedRequestExpired {
243                path: self.path.clone(),
244                expired_at: self.expiration,
245            }));
246        }
247
248        let mut request = match method {
249            "PUT" => client.put(url),
250            "GET" => client.get(url),
251            "DELETE" => client.delete(url),
252            _ => {
253                return Err(AlienError::new(ErrorData::OperationNotSupported {
254                    operation: format!("HTTP method: {}", method),
255                    reason: "Only PUT, GET, and DELETE are supported".to_string(),
256                }))
257            }
258        };
259
260        // Add headers
261        for (key, value) in headers {
262            request = request.header(key, value);
263        }
264
265        // Add body for PUT requests
266        if let Some(data) = body {
267            request = request.body(data);
268        }
269
270        let safe_url = redact_url_for_error(url);
271        let response = request
272            .send()
273            .await
274            .map_err(reqwest::Error::without_url)
275            .into_alien_error()
276            .context(ErrorData::HttpRequestFailed {
277                url: safe_url.clone(),
278                method: method.to_string(),
279            })?;
280
281        let status_code = response.status().as_u16();
282        let response_headers = response
283            .headers()
284            .iter()
285            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
286            .collect();
287
288        let response_body = if matches!(self.operation, PresignedOperation::Get) {
289            Some(
290                response
291                    .bytes()
292                    .await
293                    .map_err(reqwest::Error::without_url)
294                    .into_alien_error()
295                    .context(ErrorData::HttpRequestFailed {
296                        url: safe_url,
297                        method: method.to_string(),
298                    })?,
299            )
300        } else {
301            None
302        };
303
304        Ok(PresignedResponse {
305            status_code,
306            headers: response_headers,
307            body: response_body,
308        })
309    }
310
311    #[cfg(feature = "local")]
312    async fn execute_local(
313        &self,
314        file_path: &str,
315        operation: LocalOperation,
316        body: Option<Bytes>,
317    ) -> Result<PresignedResponse> {
318        use std::path::Path as StdPath;
319        use tokio::fs;
320
321        if self.is_expired() {
322            return Err(AlienError::new(ErrorData::PresignedRequestExpired {
323                path: self.path.clone(),
324                expired_at: self.expiration,
325            }));
326        }
327
328        let path = StdPath::new(file_path);
329
330        match operation {
331            LocalOperation::Put => {
332                let data = body.ok_or_else(|| {
333                    AlienError::new(ErrorData::OperationNotSupported {
334                        operation: "Local PUT without body".to_string(),
335                        reason: "PUT operations require body data".to_string(),
336                    })
337                })?;
338
339                // Create parent directories if needed
340                if let Some(parent) = path.parent() {
341                    fs::create_dir_all(parent)
342                        .await
343                        .into_alien_error()
344                        .context(ErrorData::LocalFilesystemError {
345                            path: file_path.to_string(),
346                            operation: "create_parent_dirs".to_string(),
347                        })?;
348                }
349
350                let write_result: std::io::Result<()> = fs::write(path, data.as_ref()).await;
351                write_result
352                    .into_alien_error()
353                    .context(ErrorData::LocalFilesystemError {
354                        path: file_path.to_string(),
355                        operation: "write".to_string(),
356                    })?;
357
358                Ok(PresignedResponse {
359                    status_code: 200,
360                    headers: HashMap::new(),
361                    body: None,
362                })
363            }
364            LocalOperation::Get => {
365                let data = fs::read(path).await.into_alien_error().context(
366                    ErrorData::LocalFilesystemError {
367                        path: file_path.to_string(),
368                        operation: "read".to_string(),
369                    },
370                )?;
371
372                Ok(PresignedResponse {
373                    status_code: 200,
374                    headers: HashMap::new(),
375                    body: Some(Bytes::from(data)),
376                })
377            }
378            LocalOperation::Delete => {
379                fs::remove_file(path).await.into_alien_error().context(
380                    ErrorData::LocalFilesystemError {
381                        path: file_path.to_string(),
382                        operation: "delete".to_string(),
383                    },
384                )?;
385
386                Ok(PresignedResponse {
387                    status_code: 200,
388                    headers: HashMap::new(),
389                    body: None,
390                })
391            }
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::redact_url_for_error;
399
400    #[test]
401    fn redacts_query_fragment_and_user_info_from_diagnostic_urls() {
402        let secret = "do-not-log-this-token";
403        let sanitized = redact_url_for_error(&format!(
404            "https://user:{secret}@storage.example.com/object?X-Amz-Signature={secret}#fragment"
405        ));
406
407        assert_eq!(sanitized, "https://storage.example.com/object");
408        assert!(!sanitized.contains(secret));
409    }
410
411    #[test]
412    fn redacts_query_from_relative_urls() {
413        assert_eq!(
414            redact_url_for_error("/v1/commands/cmd/response?response_token=secret"),
415            "/v1/commands/cmd/response"
416        );
417    }
418
419    #[test]
420    fn does_not_echo_unparseable_urls() {
421        let secret = "do-not-log-this-token";
422        assert_eq!(
423            redact_url_for_error(&format!("not a URL containing {secret}")),
424            "<invalid-url>"
425        );
426    }
427}