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#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15#[cfg_attr(feature = "openapi", derive(ToSchema))]
16pub struct PresignedRequest {
17 pub backend: PresignedRequestBackend,
19 pub expiration: DateTime<Utc>,
21 pub operation: PresignedOperation,
23 pub path: String,
25}
26
27#[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 #[serde(rename_all = "camelCase")]
34 Http {
35 url: String,
36 method: String,
37 headers: HashMap<String, String>,
38 },
39 #[serde(rename_all = "camelCase")]
41 Local {
42 file_path: String,
43 operation: LocalOperation,
44 },
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50#[cfg_attr(feature = "openapi", derive(ToSchema))]
51pub enum PresignedOperation {
52 Put,
54 Get,
56 Delete,
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 LocalOperation {
65 Put,
66 Get,
67 Delete,
68}
69
70#[derive(Debug)]
72pub struct PresignedResponse {
73 pub status_code: u16,
75 pub headers: HashMap<String, String>,
77 pub body: Option<Bytes>,
79}
80
81pub 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 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 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 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 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 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 pub fn is_expired(&self) -> bool {
210 Utc::now() > self.expiration
211 }
212
213 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 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 for (key, value) in headers {
262 request = request.header(key, value);
263 }
264
265 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 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}