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}