1use hmac::{Hmac, Mac};
4use serde::{Deserialize, Serialize};
5use sha2::Sha256;
6use std::collections::HashMap;
7
8use crate::error::{CloudError, Result};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Credentials {
13 pub access_key: String,
15 pub secret_key: String,
17 pub session_token: Option<String>,
19 pub extra: HashMap<String, String>,
21}
22
23impl Credentials {
24 #[must_use]
26 pub fn new(access_key: String, secret_key: String) -> Self {
27 Self {
28 access_key,
29 secret_key,
30 session_token: None,
31 extra: HashMap::new(),
32 }
33 }
34
35 #[must_use]
37 pub fn with_session_token(
38 access_key: String,
39 secret_key: String,
40 session_token: String,
41 ) -> Self {
42 Self {
43 access_key,
44 secret_key,
45 session_token: Some(session_token),
46 extra: HashMap::new(),
47 }
48 }
49
50 pub fn validate(&self) -> Result<()> {
52 if self.access_key.is_empty() {
53 return Err(CloudError::InvalidConfig("Access key is empty".to_string()));
54 }
55 if self.secret_key.is_empty() {
56 return Err(CloudError::InvalidConfig("Secret key is empty".to_string()));
57 }
58 Ok(())
59 }
60
61 #[must_use]
63 pub fn is_temporary(&self) -> bool {
64 self.session_token.is_some()
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct EncryptionConfig {
71 pub algorithm: EncryptionAlgorithm,
73 pub kms_config: Option<KmsConfig>,
75 pub customer_key: Option<Vec<u8>>,
77}
78
79impl Default for EncryptionConfig {
80 fn default() -> Self {
81 Self {
82 algorithm: EncryptionAlgorithm::AES256,
83 kms_config: None,
84 customer_key: None,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum EncryptionAlgorithm {
92 AES256,
94 AwsKms,
96 AzureKeyVault,
98 GcpKms,
100}
101
102#[derive(Debug, Clone)]
104pub struct KmsConfig {
105 pub key_id: String,
107 pub endpoint: Option<String>,
109 pub context: HashMap<String, String>,
111}
112
113impl KmsConfig {
114 #[must_use]
116 pub fn new(key_id: String) -> Self {
117 Self {
118 key_id,
119 endpoint: None,
120 context: HashMap::new(),
121 }
122 }
123
124 pub fn add_context(&mut self, key: String, value: String) {
126 self.context.insert(key, value);
127 }
128}
129
130#[derive(Debug, Clone)]
132pub struct IamRoleConfig {
133 pub role_arn: String,
135 pub session_name: String,
137 pub external_id: Option<String>,
139 pub duration_secs: u32,
141}
142
143impl IamRoleConfig {
144 #[must_use]
146 pub fn new(role_arn: String, session_name: String) -> Self {
147 Self {
148 role_arn,
149 session_name,
150 external_id: None,
151 duration_secs: 3600, }
153 }
154
155 #[must_use]
157 pub fn with_external_id(mut self, external_id: String) -> Self {
158 self.external_id = Some(external_id);
159 self
160 }
161
162 #[must_use]
164 pub fn with_duration(mut self, duration_secs: u32) -> Self {
165 self.duration_secs = duration_secs;
166 self
167 }
168}
169
170#[derive(Debug, Clone)]
172pub struct ServicePrincipalConfig {
173 pub tenant_id: String,
175 pub client_id: String,
177 pub client_secret: String,
179}
180
181impl ServicePrincipalConfig {
182 #[must_use]
184 pub fn new(tenant_id: String, client_id: String, client_secret: String) -> Self {
185 Self {
186 tenant_id,
187 client_id,
188 client_secret,
189 }
190 }
191}
192
193#[derive(Debug, Clone)]
195pub struct ServiceAccountConfig {
196 pub project_id: String,
198 pub email: String,
200 pub private_key: String,
202}
203
204impl ServiceAccountConfig {
205 #[must_use]
207 pub fn new(project_id: String, email: String, private_key: String) -> Self {
208 Self {
209 project_id,
210 email,
211 private_key,
212 }
213 }
214}
215
216pub struct CredentialRotation {
218 current: Credentials,
220 rotation_interval_secs: u64,
222 last_rotation: std::time::Instant,
224}
225
226impl CredentialRotation {
227 #[must_use]
229 pub fn new(credentials: Credentials, rotation_interval_secs: u64) -> Self {
230 Self {
231 current: credentials,
232 rotation_interval_secs,
233 last_rotation: std::time::Instant::now(),
234 }
235 }
236
237 #[must_use]
239 pub fn needs_rotation(&self) -> bool {
240 self.last_rotation.elapsed().as_secs() >= self.rotation_interval_secs
241 }
242
243 #[must_use]
245 pub fn current(&self) -> &Credentials {
246 &self.current
247 }
248
249 pub fn rotate(&mut self, new_credentials: Credentials) {
251 self.current = new_credentials;
252 self.last_rotation = std::time::Instant::now();
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum Acl {
259 Private,
261 PublicRead,
263 PublicReadWrite,
265 AuthenticatedRead,
267 BucketOwnerRead,
269 BucketOwnerFullControl,
271}
272
273impl Acl {
274 #[must_use]
276 pub fn to_s3_string(&self) -> &str {
277 match self {
278 Acl::Private => "private",
279 Acl::PublicRead => "public-read",
280 Acl::PublicReadWrite => "public-read-write",
281 Acl::AuthenticatedRead => "authenticated-read",
282 Acl::BucketOwnerRead => "bucket-owner-read",
283 Acl::BucketOwnerFullControl => "bucket-owner-full-control",
284 }
285 }
286}
287
288type HmacSha256 = Hmac<Sha256>;
291
292pub struct SignedUrl;
298
299impl SignedUrl {
300 #[must_use]
317 pub fn generate(bucket: &str, key: &str, expiry_secs: u64, secret: &[u8]) -> String {
318 let epoch = std::time::SystemTime::now()
319 .duration_since(std::time::UNIX_EPOCH)
320 .map(|d| d.as_secs())
321 .unwrap_or(0);
322
323 let string_to_sign = format!("{bucket}\n{key}\n{expiry_secs}\n{epoch}");
324
325 let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
326 mac.update(string_to_sign.as_bytes());
327 let signature = hex::encode(mac.finalize().into_bytes());
328
329 let encoded_key = urlencoding::encode(key);
331 let encoded_bucket = urlencoding::encode(bucket);
332
333 format!(
334 "https://s3.amazonaws.com/{encoded_bucket}/{encoded_key}\
335 ?X-Amz-Expires={expiry_secs}\
336 &X-Amz-Date={epoch}\
337 &X-Amz-SignedHeaders=host\
338 &X-Amz-Signature={signature}"
339 )
340 }
341
342 #[must_use]
346 pub fn verify(url: &str, secret: &[u8]) -> bool {
347 let (base, query) = match url.split_once('?') {
349 Some(pair) => pair,
350 None => return false,
351 };
352
353 let path = base.trim_start_matches("https://s3.amazonaws.com/");
355 let (encoded_bucket, encoded_key) = match path.split_once('/') {
356 Some(pair) => pair,
357 None => return false,
358 };
359 let bucket = urlencoding::decode(encoded_bucket).unwrap_or_default();
360 let key = urlencoding::decode(encoded_key).unwrap_or_default();
361
362 let mut expiry_secs: Option<u64> = None;
364 let mut date_epoch: Option<u64> = None;
365 let mut provided_sig: Option<String> = None;
366
367 for param in query.split('&') {
368 if let Some(val) = param.strip_prefix("X-Amz-Expires=") {
369 expiry_secs = val.parse().ok();
370 } else if let Some(val) = param.strip_prefix("X-Amz-Date=") {
371 date_epoch = val.parse().ok();
372 } else if let Some(val) = param.strip_prefix("X-Amz-Signature=") {
373 provided_sig = Some(val.to_string());
374 }
375 }
376
377 let (Some(expiry), Some(epoch), Some(sig)) = (expiry_secs, date_epoch, provided_sig) else {
378 return false;
379 };
380
381 let now = std::time::SystemTime::now()
383 .duration_since(std::time::UNIX_EPOCH)
384 .map(|d| d.as_secs())
385 .unwrap_or(0);
386 if now > epoch.saturating_add(expiry) {
387 return false;
388 }
389
390 let string_to_sign = format!("{bucket}\n{key}\n{expiry}\n{epoch}");
392 let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
393 mac.update(string_to_sign.as_bytes());
394 let expected_sig = hex::encode(mac.finalize().into_bytes());
395
396 expected_sig == sig
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn test_credentials_validation() {
406 let valid = Credentials::new("access".to_string(), "secret".to_string());
407 assert!(valid.validate().is_ok());
408
409 let invalid = Credentials::new("".to_string(), "secret".to_string());
410 assert!(invalid.validate().is_err());
411 }
412
413 #[test]
414 fn test_credentials_temporary() {
415 let permanent = Credentials::new("access".to_string(), "secret".to_string());
416 assert!(!permanent.is_temporary());
417
418 let temporary = Credentials::with_session_token(
419 "access".to_string(),
420 "secret".to_string(),
421 "token".to_string(),
422 );
423 assert!(temporary.is_temporary());
424 }
425
426 #[test]
427 fn test_kms_config() {
428 let mut kms = KmsConfig::new("key-id".to_string());
429 kms.add_context("env".to_string(), "prod".to_string());
430 assert_eq!(kms.context.len(), 1);
431 }
432
433 #[test]
434 fn test_iam_role_config() {
435 let role = IamRoleConfig::new(
436 "arn:aws:iam::123456789012:role/test".to_string(),
437 "session".to_string(),
438 )
439 .with_external_id("external".to_string())
440 .with_duration(7200);
441
442 assert_eq!(role.external_id, Some("external".to_string()));
443 assert_eq!(role.duration_secs, 7200);
444 }
445
446 #[test]
447 fn test_credential_rotation() {
448 let creds = Credentials::new("access".to_string(), "secret".to_string());
449 let mut rotation = CredentialRotation::new(creds.clone(), 60);
450
451 assert!(!rotation.needs_rotation());
452
453 let new_creds = Credentials::new("new_access".to_string(), "new_secret".to_string());
454 rotation.rotate(new_creds);
455 assert_eq!(rotation.current().access_key, "new_access");
456 }
457
458 #[test]
459 fn test_acl_to_string() {
460 assert_eq!(Acl::Private.to_s3_string(), "private");
461 assert_eq!(Acl::PublicRead.to_s3_string(), "public-read");
462 }
463
464 #[test]
467 fn test_signed_url_contains_required_params() {
468 let url = SignedUrl::generate("my-bucket", "videos/file.mp4", 3600, b"supersecret");
469 assert!(
470 url.contains("X-Amz-Expires=3600"),
471 "URL must include expiry"
472 );
473 assert!(url.contains("X-Amz-Date="), "URL must include date");
474 assert!(
475 url.contains("X-Amz-Signature="),
476 "URL must include signature"
477 );
478 assert!(
479 url.contains("X-Amz-SignedHeaders=host"),
480 "URL must include signed headers"
481 );
482 }
483
484 #[test]
485 fn test_signed_url_contains_bucket_and_key() {
486 let url = SignedUrl::generate("my-bucket", "path/to/object.mp4", 300, b"key");
487 assert!(url.contains("my-bucket"), "URL must include bucket");
488 assert!(url.contains("path"), "URL must include key path");
490 }
491
492 #[test]
493 fn test_signed_url_different_secrets_produce_different_signatures() {
494 let url1 = SignedUrl::generate("bucket", "key", 300, b"secret1");
495 let url2 = SignedUrl::generate("bucket", "key", 300, b"secret2");
496 let sig1 = url1.split("X-Amz-Signature=").nth(1).unwrap_or("");
498 let sig2 = url2.split("X-Amz-Signature=").nth(1).unwrap_or("");
499 assert_ne!(
500 sig1, sig2,
501 "Different secrets must produce different signatures"
502 );
503 }
504
505 #[test]
506 fn test_signed_url_verify_valid() {
507 let secret = b"test-signing-secret";
508 let url = SignedUrl::generate("bucket", "key/file.mp4", 3600, secret);
509 assert!(
510 SignedUrl::verify(&url, secret),
511 "Freshly generated URL must verify"
512 );
513 }
514
515 #[test]
516 fn test_signed_url_verify_wrong_secret_fails() {
517 let url = SignedUrl::generate("bucket", "key.mp4", 3600, b"correct-secret");
518 assert!(
519 !SignedUrl::verify(&url, b"wrong-secret"),
520 "Wrong secret must not verify"
521 );
522 }
523
524 #[test]
525 fn test_signed_url_verify_malformed_url_fails() {
526 assert!(!SignedUrl::verify("not-a-valid-url", b"secret"));
527 assert!(!SignedUrl::verify(
528 "https://s3.amazonaws.com/bucket/key",
529 b"secret"
530 ));
531 }
532}