Skip to main content

brows3r_lib/s3/
presign.rs

1//! Presigned URL generation for S3 objects.
2//!
3//! # Design
4//!
5//! AWS SigV4 presigned URLs embed the credentials directly in the query string
6//! (via `X-Amz-Signature`, `X-Amz-Credential`, `X-Amz-Date`, …) so the
7//! recipient can fetch the object without AWS credentials of their own.
8//!
9//! The URL is generated entirely in Rust — credentials never cross the Tauri
10//! IPC boundary.  The frontend receives an opaque `PresignedUrl` struct and
11//! writes the URL to the clipboard.
12//!
13//! # Expiry limits (AWS SigV4)
14//!
15//! - Minimum: 60 seconds (enforce in this module; 1-second URLs are technically
16//!   valid but useless and confusing).
17//! - Maximum: 604 800 seconds (7 days) — hard AWS limit for SigV4 presigned URLs.
18//!
19//! # OCP
20//!
21//! `PresignedUrl` is intentionally open: `expires_in_secs` and `method` can be
22//! added as optional fields in a future task without breaking the IPC shape.
23//! A `presign_put_object` function would mirror this one with a `PutObject`
24//! builder — no changes to existing callers.
25
26use std::time::{Duration, SystemTime, UNIX_EPOCH};
27
28use aws_sdk_s3::{presigning::PresigningConfig, Client};
29use serde::{Deserialize, Serialize};
30
31use crate::error::AppError;
32
33// ---------------------------------------------------------------------------
34// Expiry limits
35// ---------------------------------------------------------------------------
36
37/// Minimum presigned URL expiry (60 s).  URLs shorter than this are
38/// essentially unusable and would confuse users.
39pub const MIN_EXPIRES_SECS: u64 = 60;
40
41/// Maximum presigned URL expiry (7 days in seconds).
42/// Hard AWS limit for SigV4 presigned GET URLs.
43pub const MAX_EXPIRES_SECS: u64 = 7 * 24 * 3600; // 604_800
44
45// ---------------------------------------------------------------------------
46// PresignedUrl — IPC response type
47// ---------------------------------------------------------------------------
48
49/// Result returned by `object_presign`.
50///
51/// OCP: `expires_in_secs: Option<u64>` and `method: Option<String>` may be
52/// added as optional fields in a future task without breaking existing call
53/// sites.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct PresignedUrl {
57    /// The full presigned URL string.  The frontend copies this to the clipboard.
58    pub url: String,
59    /// Unix timestamp (milliseconds) when the URL expires.
60    pub expires_at: i64,
61}
62
63// ---------------------------------------------------------------------------
64// presign_get_object
65// ---------------------------------------------------------------------------
66
67/// Generate a presigned `GetObject` URL for `bucket/key`.
68///
69/// # Arguments
70///
71/// - `client`       — AWS S3 client (already scoped to the correct region/profile).
72/// - `bucket`       — Bucket name.
73/// - `key`          — Full object key.
74/// - `expires_secs` — URL lifetime in seconds.  Must be in `[60, 604_800]`.
75///
76/// # Errors
77///
78/// - `AppError::Validation { field: "expires_secs", … }` when `expires_secs`
79///   is outside the allowed range.
80/// - `AppError::Internal { … }` when the AWS SDK presigning call fails.
81pub async fn presign_get_object(
82    client: &Client,
83    bucket: &str,
84    key: &str,
85    expires_secs: u64,
86) -> Result<PresignedUrl, AppError> {
87    // ------------------------------------------------------------------
88    // 1. Validate expiry range
89    // ------------------------------------------------------------------
90    if expires_secs < MIN_EXPIRES_SECS {
91        return Err(AppError::Validation {
92            field: "expires_secs".to_string(),
93            hint: format!("expires_secs must be at least {MIN_EXPIRES_SECS} seconds"),
94        });
95    }
96    if expires_secs > MAX_EXPIRES_SECS {
97        return Err(AppError::Validation {
98            field: "expires_secs".to_string(),
99            hint: format!("expires_secs must not exceed {MAX_EXPIRES_SECS} seconds (7 days)"),
100        });
101    }
102
103    // ------------------------------------------------------------------
104    // 2. Build PresigningConfig
105    // ------------------------------------------------------------------
106    let presigning_config = PresigningConfig::expires_in(Duration::from_secs(expires_secs))
107        .map_err(|e| AppError::Internal {
108            trace_id: format!("presigning_config_build_failed:{e}"),
109        })?;
110
111    // ------------------------------------------------------------------
112    // 3. Generate presigned URL via AWS SDK
113    // ------------------------------------------------------------------
114    let presigned_request = client
115        .get_object()
116        .bucket(bucket)
117        .key(key)
118        .presigned(presigning_config)
119        .await
120        .map_err(|e| AppError::Internal {
121            trace_id: format!("presign_get_object_failed:{e}"),
122        })?;
123
124    // ------------------------------------------------------------------
125    // 4. Compute expires_at (Unix ms)
126    // ------------------------------------------------------------------
127    let now_ms = SystemTime::now()
128        .duration_since(UNIX_EPOCH)
129        .unwrap_or_default()
130        .as_millis() as i64;
131
132    let expires_at = now_ms + (expires_secs as i64) * 1_000;
133
134    Ok(PresignedUrl {
135        url: presigned_request.uri().to_string(),
136        expires_at,
137    })
138}
139
140/// Pure validation helper — exposed for unit tests in other modules.
141///
142/// Calling this from `objects_cmd` tests avoids the need for a real S3 client
143/// while still testing the same validation path as `presign_get_object`.
144#[doc(hidden)]
145pub fn presign_get_object_validate_only(expires_secs: u64) -> Result<(), AppError> {
146    validate_expires_secs(expires_secs)
147}
148
149/// Pure validation helper extracted so unit tests can call it without a real
150/// client.  The command and `presign_get_object` both call this inline for
151/// consistency.
152fn validate_expires_secs(expires_secs: u64) -> Result<(), AppError> {
153    if expires_secs < MIN_EXPIRES_SECS {
154        return Err(AppError::Validation {
155            field: "expires_secs".to_string(),
156            hint: format!("expires_secs must be at least {MIN_EXPIRES_SECS} seconds"),
157        });
158    }
159    if expires_secs > MAX_EXPIRES_SECS {
160        return Err(AppError::Validation {
161            field: "expires_secs".to_string(),
162            hint: format!("expires_secs must not exceed {MAX_EXPIRES_SECS} seconds (7 days)"),
163        });
164    }
165    Ok(())
166}
167
168// ---------------------------------------------------------------------------
169// Tests
170// ---------------------------------------------------------------------------
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    // ------------------------------------------------------------------
177    // Expiry boundary validation (unit — no network)
178    // ------------------------------------------------------------------
179
180    #[test]
181    fn expires_secs_below_minimum_returns_validation_error() {
182        // 1 second — below the 60-second minimum.
183        let err = validate_expires_secs(1).unwrap_err();
184        match err {
185            AppError::Validation { field, hint } => {
186                assert_eq!(field, "expires_secs");
187                assert!(hint.contains("60"), "hint must mention the minimum: {hint}");
188            }
189            other => panic!("expected Validation, got {:?}", other),
190        }
191    }
192
193    #[test]
194    fn expires_secs_at_minimum_is_valid() {
195        assert!(validate_expires_secs(MIN_EXPIRES_SECS).is_ok());
196    }
197
198    #[test]
199    fn expires_secs_typical_one_hour_is_valid() {
200        assert!(validate_expires_secs(3_600).is_ok());
201    }
202
203    #[test]
204    fn expires_secs_at_maximum_is_valid() {
205        assert!(validate_expires_secs(MAX_EXPIRES_SECS).is_ok());
206    }
207
208    #[test]
209    fn expires_secs_above_maximum_returns_validation_error() {
210        let err = validate_expires_secs(MAX_EXPIRES_SECS + 1).unwrap_err();
211        match err {
212            AppError::Validation { field, hint } => {
213                assert_eq!(field, "expires_secs");
214                assert!(
215                    hint.contains("604800") || hint.contains("7 days"),
216                    "hint must mention the maximum: {hint}"
217                );
218            }
219            other => panic!("expected Validation, got {:?}", other),
220        }
221    }
222
223    // ------------------------------------------------------------------
224    // PresignedUrl serialisation
225    // ------------------------------------------------------------------
226
227    #[test]
228    fn presigned_url_serialises_camel_case() {
229        let p = PresignedUrl {
230            url: "https://s3.example.com/bucket/key?X-Amz-Signature=abc".to_string(),
231            expires_at: 1_700_000_000_000,
232        };
233        let v = serde_json::to_value(&p).unwrap();
234        assert_eq!(
235            v["url"],
236            "https://s3.example.com/bucket/key?X-Amz-Signature=abc"
237        );
238        assert_eq!(v["expiresAt"], 1_700_000_000_000_i64);
239    }
240
241    // ------------------------------------------------------------------
242    // expires_at is in the future (approximate)
243    // ------------------------------------------------------------------
244
245    #[test]
246    fn expires_at_is_in_the_future_for_valid_secs() {
247        let expires_secs: u64 = 3_600;
248        let now_ms = std::time::SystemTime::now()
249            .duration_since(std::time::UNIX_EPOCH)
250            .unwrap()
251            .as_millis() as i64;
252
253        let expires_at = now_ms + (expires_secs as i64) * 1_000;
254
255        assert!(expires_at > now_ms, "expires_at must be strictly after now");
256        // Allow 1 s of jitter in both directions.
257        let expected_ms = now_ms + 3_600_000_i64;
258        let delta = (expires_at - expected_ms).abs();
259        assert!(
260            delta < 1_000,
261            "expires_at must be within 1 s of now + expires_secs"
262        );
263    }
264}