1use std::collections::HashMap;
31
32use aws_sdk_s3::{error::SdkError, Client};
33use serde::{Deserialize, Serialize};
34
35use crate::{
36 cache::capability::{CapabilityCache, CapabilityClass},
37 error::AppError,
38 ids::{BucketId, ProfileId},
39};
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52#[serde(tag = "kind", rename_all = "camelCase")]
53pub enum SectionResult<T> {
54 Value { value: T },
56 Denied {
61 #[serde(rename = "iamAction")]
62 iam_action: String,
63 },
64 Unsupported { reason: String },
66 Deferred { reason: String },
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "camelCase")]
77pub enum VersioningStatus {
78 Enabled,
79 Suspended,
80 Disabled,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85#[serde(rename_all = "camelCase")]
86pub struct EncryptionConfig {
87 pub sse_algorithm: Option<String>,
89 pub kms_master_key_id: Option<String>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95#[serde(rename_all = "camelCase")]
96pub struct LifecycleRule {
97 pub id: Option<String>,
98 pub status: String,
99 pub prefix: Option<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105#[serde(rename_all = "camelCase")]
106pub struct ObjectLockConfig {
107 pub object_lock_enabled: bool,
108 pub default_retention_mode: Option<String>,
110 pub default_retention_days: Option<i32>,
112 pub default_retention_years: Option<i32>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118#[serde(rename_all = "camelCase")]
119pub struct PublicAccessBlockConfig {
120 pub block_public_acls: bool,
121 pub ignore_public_acls: bool,
122 pub block_public_policy: bool,
123 pub restrict_public_buckets: bool,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128#[serde(rename_all = "camelCase")]
129pub struct CorsRule {
130 pub allowed_origins: Vec<String>,
131 pub allowed_methods: Vec<String>,
132 pub allowed_headers: Vec<String>,
133 pub expose_headers: Vec<String>,
134 pub max_age_seconds: Option<i32>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
139#[serde(rename_all = "camelCase")]
140pub struct ReplicationConfig {
141 pub role: String,
142 pub destination_buckets: Vec<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148#[serde(rename_all = "camelCase")]
149pub struct LoggingConfig {
150 pub target_bucket: Option<String>,
152 pub target_prefix: Option<String>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
158#[serde(rename_all = "camelCase")]
159pub struct WebsiteConfig {
160 pub index_document: Option<String>,
161 pub error_document: Option<String>,
162 pub redirect_all_requests_to: Option<String>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170#[serde(rename_all = "camelCase")]
171pub struct NotificationConfig {
172 pub lambda_count: usize,
173 pub queue_count: usize,
174 pub topic_count: usize,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
179#[serde(rename_all = "camelCase")]
180pub struct OwnershipControls {
181 pub rule: String,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct BucketInspectorReport {
200 pub region: SectionResult<String>,
201 pub versioning: SectionResult<VersioningStatus>,
202 pub encryption: SectionResult<EncryptionConfig>,
204 pub lifecycle: SectionResult<Vec<LifecycleRule>>,
205 pub object_lock: SectionResult<ObjectLockConfig>,
206 pub public_access_block: SectionResult<PublicAccessBlockConfig>,
207 pub cors: SectionResult<Vec<CorsRule>>,
208 pub tags: SectionResult<HashMap<String, String>>,
209 pub replication: SectionResult<ReplicationConfig>,
210 pub logging: SectionResult<LoggingConfig>,
211 pub website: SectionResult<WebsiteConfig>,
212 pub notifications: SectionResult<NotificationConfig>,
214 pub ownership_controls: SectionResult<OwnershipControls>,
215 pub requester_pays: SectionResult<bool>,
216 pub bucket_policy: SectionResult<()>,
218}
219
220pub async fn inspect_bucket(
237 client: &Client,
238 bucket: &str,
239 capability_cache: &CapabilityCache,
240 profile_id: &ProfileId,
241) -> Result<BucketInspectorReport, AppError> {
242 let bucket_id = BucketId::new(bucket);
243
244 let (
245 region_result,
246 versioning_result,
247 encryption_result,
248 lifecycle_result,
249 object_lock_result,
250 pab_result,
251 cors_result,
252 tags_result,
253 replication_result,
254 logging_result,
255 website_result,
256 notification_result,
257 ownership_result,
258 requester_pays_result,
259 ) = tokio::join!(
260 fetch_region(client, bucket),
261 fetch_versioning(client, bucket),
262 fetch_encryption(client, bucket),
263 fetch_lifecycle(client, bucket),
264 fetch_object_lock(client, bucket),
265 fetch_public_access_block(client, bucket),
266 fetch_cors(client, bucket),
267 fetch_tags(client, bucket),
268 fetch_replication(client, bucket),
269 fetch_logging(client, bucket),
270 fetch_website(client, bucket),
271 fetch_notifications(client, bucket),
272 fetch_ownership_controls(client, bucket),
273 fetch_requester_pays(client, bucket),
274 );
275
276 record_denied(
278 capability_cache,
279 profile_id,
280 &bucket_id,
281 "s3:GetBucketLocation",
282 ®ion_result,
283 );
284 record_denied(
285 capability_cache,
286 profile_id,
287 &bucket_id,
288 "s3:GetBucketVersioning",
289 &versioning_result,
290 );
291 record_denied(
292 capability_cache,
293 profile_id,
294 &bucket_id,
295 "s3:GetEncryptionConfiguration",
296 &encryption_result,
297 );
298 record_denied(
299 capability_cache,
300 profile_id,
301 &bucket_id,
302 "s3:GetLifecycleConfiguration",
303 &lifecycle_result,
304 );
305 record_denied(
306 capability_cache,
307 profile_id,
308 &bucket_id,
309 "s3:GetBucketObjectLockConfiguration",
310 &object_lock_result,
311 );
312 record_denied(
313 capability_cache,
314 profile_id,
315 &bucket_id,
316 "s3:GetBucketPublicAccessBlock",
317 &pab_result,
318 );
319 record_denied(
320 capability_cache,
321 profile_id,
322 &bucket_id,
323 "s3:GetBucketCORS",
324 &cors_result,
325 );
326 record_denied(
327 capability_cache,
328 profile_id,
329 &bucket_id,
330 "s3:GetBucketTagging",
331 &tags_result,
332 );
333 record_denied(
334 capability_cache,
335 profile_id,
336 &bucket_id,
337 "s3:GetReplicationConfiguration",
338 &replication_result,
339 );
340 record_denied(
341 capability_cache,
342 profile_id,
343 &bucket_id,
344 "s3:GetBucketLogging",
345 &logging_result,
346 );
347 record_denied(
348 capability_cache,
349 profile_id,
350 &bucket_id,
351 "s3:GetBucketWebsite",
352 &website_result,
353 );
354 record_denied(
355 capability_cache,
356 profile_id,
357 &bucket_id,
358 "s3:GetBucketNotification",
359 ¬ification_result,
360 );
361 record_denied(
362 capability_cache,
363 profile_id,
364 &bucket_id,
365 "s3:GetBucketOwnershipControls",
366 &ownership_result,
367 );
368 record_denied(
369 capability_cache,
370 profile_id,
371 &bucket_id,
372 "s3:GetBucketRequestPayment",
373 &requester_pays_result,
374 );
375
376 let region = region_result?;
378 let versioning = versioning_result?;
379 let encryption = encryption_result?;
380 let lifecycle = lifecycle_result?;
381 let object_lock = object_lock_result?;
382 let public_access_block = pab_result?;
383 let cors = cors_result?;
384 let tags = tags_result?;
385 let replication = replication_result?;
386 let logging = logging_result?;
387 let website = website_result?;
388 let notifications = notification_result?;
389 let ownership_controls = ownership_result?;
390 let requester_pays = requester_pays_result?;
391
392 Ok(BucketInspectorReport {
393 region,
394 versioning,
395 encryption,
396 lifecycle,
397 object_lock,
398 public_access_block,
399 cors,
400 tags,
401 replication,
402 logging,
403 website,
404 notifications,
405 ownership_controls,
406 requester_pays,
407 bucket_policy: SectionResult::Deferred {
409 reason: "Deferred from v1".to_string(),
410 },
411 })
412}
413
414fn record_denied<T>(
420 cache: &CapabilityCache,
421 profile_id: &ProfileId,
422 bucket_id: &BucketId,
423 iam_action: &str,
424 result: &Result<SectionResult<T>, AppError>,
425) {
426 if let Ok(SectionResult::Denied { .. }) = result {
427 cache.record_capability(
428 profile_id,
429 Some(bucket_id),
430 iam_action,
431 CapabilityClass::Denied {
432 iam_action: Some(iam_action.to_owned()),
433 },
434 );
435 }
436}
437
438fn classify_service_error(code: &str, bucket: &str) -> Option<AppError> {
442 match code {
443 "NoSuchBucket" => Some(AppError::NotFound {
444 resource: bucket.to_string(),
445 }),
446 _ => None,
447 }
448}
449
450fn is_unsupported_code(code: &str) -> bool {
453 matches!(
454 code,
455 "NotImplemented"
456 | "UnsupportedOperation"
457 | "MethodNotAllowed"
458 | "InvalidRequest"
459 | "XNotImplemented"
460 | "NoSuchWebsiteConfiguration"
461 | "NoSuchCORSConfiguration"
462 | "NoSuchLifecycleConfiguration"
463 | "NoSuchReplicationConfiguration"
464 | "ObjectLockConfigurationNotFoundError"
465 | "OwnershipControlsNotFoundError"
466 | "NoSuchPublicAccessBlockConfiguration"
467 )
468}
469
470async fn fetch_region(client: &Client, bucket: &str) -> Result<SectionResult<String>, AppError> {
476 match client.get_bucket_location().bucket(bucket).send().await {
477 Ok(resp) => {
478 let region = resp
479 .location_constraint()
480 .map(|lc| match lc.as_str() {
481 "" => "us-east-1".to_string(),
482 "EU" => "eu-west-1".to_string(),
483 s => s.to_string(),
484 })
485 .unwrap_or_else(|| "us-east-1".to_string());
486 Ok(SectionResult::Value { value: region })
487 }
488 Err(SdkError::ServiceError(ref svc)) => {
489 let code = svc.err().meta().code().unwrap_or("");
490 if code == "AccessDenied" || code == "InvalidClientTokenId" {
491 return Ok(SectionResult::Denied {
492 iam_action: "s3:GetBucketLocation".to_string(),
493 });
494 }
495 if is_unsupported_code(code) {
496 return Ok(SectionResult::Unsupported {
497 reason: code.to_string(),
498 });
499 }
500 if let Some(hard) = classify_service_error(code, bucket) {
501 return Err(hard);
502 }
503 Ok(SectionResult::Unsupported {
504 reason: format!("GetBucketLocation: {code}"),
505 })
506 }
507 Err(e) => Err(AppError::Network {
508 source: format!("GetBucketLocation({bucket}): {e}"),
509 }),
510 }
511}
512
513async fn fetch_versioning(
515 client: &Client,
516 bucket: &str,
517) -> Result<SectionResult<VersioningStatus>, AppError> {
518 match client.get_bucket_versioning().bucket(bucket).send().await {
519 Ok(resp) => {
520 let status = match resp.status() {
521 Some(s) if s.as_str() == "Enabled" => VersioningStatus::Enabled,
522 Some(s) if s.as_str() == "Suspended" => VersioningStatus::Suspended,
523 _ => VersioningStatus::Disabled,
524 };
525 Ok(SectionResult::Value { value: status })
526 }
527 Err(SdkError::ServiceError(ref svc)) => {
528 let code = svc.err().meta().code().unwrap_or("");
529 if code == "AccessDenied" || code == "InvalidClientTokenId" {
530 return Ok(SectionResult::Denied {
531 iam_action: "s3:GetBucketVersioning".to_string(),
532 });
533 }
534 if is_unsupported_code(code) {
535 return Ok(SectionResult::Unsupported {
536 reason: code.to_string(),
537 });
538 }
539 if let Some(hard) = classify_service_error(code, bucket) {
540 return Err(hard);
541 }
542 Ok(SectionResult::Unsupported {
543 reason: format!("GetBucketVersioning: {code}"),
544 })
545 }
546 Err(e) => Err(AppError::Network {
547 source: format!("GetBucketVersioning({bucket}): {e}"),
548 }),
549 }
550}
551
552async fn fetch_encryption(
554 client: &Client,
555 bucket: &str,
556) -> Result<SectionResult<EncryptionConfig>, AppError> {
557 match client.get_bucket_encryption().bucket(bucket).send().await {
558 Ok(resp) => {
559 let (sse_algorithm, kms_master_key_id) = resp
560 .server_side_encryption_configuration()
561 .and_then(|c| c.rules().first())
562 .and_then(|rule| rule.apply_server_side_encryption_by_default())
563 .map(|def| {
564 (
565 Some(def.sse_algorithm().as_str().to_string()),
566 def.kms_master_key_id().map(|k| k.to_string()),
567 )
568 })
569 .unwrap_or((None, None));
570 Ok(SectionResult::Value {
571 value: EncryptionConfig {
572 sse_algorithm,
573 kms_master_key_id,
574 },
575 })
576 }
577 Err(SdkError::ServiceError(ref svc)) => {
578 let code = svc.err().meta().code().unwrap_or("");
579 if code == "AccessDenied" || code == "InvalidClientTokenId" {
580 return Ok(SectionResult::Denied {
581 iam_action: "s3:GetEncryptionConfiguration".to_string(),
582 });
583 }
584 if code == "ServerSideEncryptionConfigurationNotFoundError" {
587 return Ok(SectionResult::Value {
588 value: EncryptionConfig {
589 sse_algorithm: None,
590 kms_master_key_id: None,
591 },
592 });
593 }
594 if is_unsupported_code(code) {
595 return Ok(SectionResult::Unsupported {
596 reason: code.to_string(),
597 });
598 }
599 if let Some(hard) = classify_service_error(code, bucket) {
600 return Err(hard);
601 }
602 Ok(SectionResult::Unsupported {
603 reason: format!("GetBucketEncryption: {code}"),
604 })
605 }
606 Err(e) => Err(AppError::Network {
607 source: format!("GetBucketEncryption({bucket}): {e}"),
608 }),
609 }
610}
611
612async fn fetch_lifecycle(
614 client: &Client,
615 bucket: &str,
616) -> Result<SectionResult<Vec<LifecycleRule>>, AppError> {
617 match client
618 .get_bucket_lifecycle_configuration()
619 .bucket(bucket)
620 .send()
621 .await
622 {
623 Ok(resp) => {
624 let rules: Vec<LifecycleRule> = resp
625 .rules()
626 .iter()
627 .map(|r| LifecycleRule {
628 id: r.id().map(|s| s.to_string()),
629 status: r.status().as_str().to_string(),
630 prefix: r.filter().and_then(|f| f.prefix()).map(|s| s.to_string()),
631 })
632 .collect();
633 Ok(SectionResult::Value { value: rules })
634 }
635 Err(SdkError::ServiceError(ref svc)) => {
636 let code = svc.err().meta().code().unwrap_or("");
637 if code == "AccessDenied" || code == "InvalidClientTokenId" {
638 return Ok(SectionResult::Denied {
639 iam_action: "s3:GetLifecycleConfiguration".to_string(),
640 });
641 }
642 if code == "NoSuchLifecycleConfiguration" {
643 return Ok(SectionResult::Value { value: vec![] });
644 }
645 if is_unsupported_code(code) {
646 return Ok(SectionResult::Unsupported {
647 reason: code.to_string(),
648 });
649 }
650 if let Some(hard) = classify_service_error(code, bucket) {
651 return Err(hard);
652 }
653 Ok(SectionResult::Unsupported {
654 reason: format!("GetBucketLifecycleConfiguration: {code}"),
655 })
656 }
657 Err(e) => Err(AppError::Network {
658 source: format!("GetBucketLifecycleConfiguration({bucket}): {e}"),
659 }),
660 }
661}
662
663async fn fetch_object_lock(
665 client: &Client,
666 bucket: &str,
667) -> Result<SectionResult<ObjectLockConfig>, AppError> {
668 match client
669 .get_object_lock_configuration()
670 .bucket(bucket)
671 .send()
672 .await
673 {
674 Ok(resp) => {
675 let (mode, days, years) = resp
676 .object_lock_configuration()
677 .and_then(|c| c.rule())
678 .and_then(|r| r.default_retention())
679 .map(|ret| {
680 (
681 ret.mode().map(|m| m.as_str().to_string()),
682 ret.days(),
683 ret.years(),
684 )
685 })
686 .unwrap_or((None, None, None));
687 let enabled = resp
688 .object_lock_configuration()
689 .and_then(|c| c.object_lock_enabled())
690 .map(|e| e.as_str() == "Enabled")
691 .unwrap_or(false);
692 Ok(SectionResult::Value {
693 value: ObjectLockConfig {
694 object_lock_enabled: enabled,
695 default_retention_mode: mode,
696 default_retention_days: days,
697 default_retention_years: years,
698 },
699 })
700 }
701 Err(SdkError::ServiceError(ref svc)) => {
702 let code = svc.err().meta().code().unwrap_or("");
703 if code == "AccessDenied" || code == "InvalidClientTokenId" {
704 return Ok(SectionResult::Denied {
705 iam_action: "s3:GetBucketObjectLockConfiguration".to_string(),
706 });
707 }
708 if code == "ObjectLockConfigurationNotFoundError" {
709 return Ok(SectionResult::Value {
710 value: ObjectLockConfig {
711 object_lock_enabled: false,
712 default_retention_mode: None,
713 default_retention_days: None,
714 default_retention_years: None,
715 },
716 });
717 }
718 if is_unsupported_code(code) {
719 return Ok(SectionResult::Unsupported {
720 reason: code.to_string(),
721 });
722 }
723 if let Some(hard) = classify_service_error(code, bucket) {
724 return Err(hard);
725 }
726 Ok(SectionResult::Unsupported {
727 reason: format!("GetObjectLockConfiguration: {code}"),
728 })
729 }
730 Err(e) => Err(AppError::Network {
731 source: format!("GetObjectLockConfiguration({bucket}): {e}"),
732 }),
733 }
734}
735
736async fn fetch_public_access_block(
738 client: &Client,
739 bucket: &str,
740) -> Result<SectionResult<PublicAccessBlockConfig>, AppError> {
741 match client.get_public_access_block().bucket(bucket).send().await {
742 Ok(resp) => {
743 let cfg = resp.public_access_block_configuration();
744 Ok(SectionResult::Value {
745 value: PublicAccessBlockConfig {
746 block_public_acls: cfg.and_then(|c| c.block_public_acls()).unwrap_or(false),
747 ignore_public_acls: cfg.and_then(|c| c.ignore_public_acls()).unwrap_or(false),
748 block_public_policy: cfg.and_then(|c| c.block_public_policy()).unwrap_or(false),
749 restrict_public_buckets: cfg
750 .and_then(|c| c.restrict_public_buckets())
751 .unwrap_or(false),
752 },
753 })
754 }
755 Err(SdkError::ServiceError(ref svc)) => {
756 let code = svc.err().meta().code().unwrap_or("");
757 if code == "AccessDenied" || code == "InvalidClientTokenId" {
758 return Ok(SectionResult::Denied {
759 iam_action: "s3:GetBucketPublicAccessBlock".to_string(),
760 });
761 }
762 if code == "NoSuchPublicAccessBlockConfiguration" {
763 return Ok(SectionResult::Value {
764 value: PublicAccessBlockConfig {
765 block_public_acls: false,
766 ignore_public_acls: false,
767 block_public_policy: false,
768 restrict_public_buckets: false,
769 },
770 });
771 }
772 if is_unsupported_code(code) {
773 return Ok(SectionResult::Unsupported {
774 reason: code.to_string(),
775 });
776 }
777 if let Some(hard) = classify_service_error(code, bucket) {
778 return Err(hard);
779 }
780 Ok(SectionResult::Unsupported {
781 reason: format!("GetPublicAccessBlock: {code}"),
782 })
783 }
784 Err(e) => Err(AppError::Network {
785 source: format!("GetPublicAccessBlock({bucket}): {e}"),
786 }),
787 }
788}
789
790async fn fetch_cors(
792 client: &Client,
793 bucket: &str,
794) -> Result<SectionResult<Vec<CorsRule>>, AppError> {
795 match client.get_bucket_cors().bucket(bucket).send().await {
796 Ok(resp) => {
797 let rules: Vec<CorsRule> = resp
798 .cors_rules()
799 .iter()
800 .map(|r| CorsRule {
801 allowed_origins: r.allowed_origins().iter().map(|s| s.to_string()).collect(),
802 allowed_methods: r.allowed_methods().iter().map(|s| s.to_string()).collect(),
803 allowed_headers: r.allowed_headers().iter().map(|s| s.to_string()).collect(),
804 expose_headers: r.expose_headers().iter().map(|s| s.to_string()).collect(),
805 max_age_seconds: r.max_age_seconds(),
806 })
807 .collect();
808 Ok(SectionResult::Value { value: rules })
809 }
810 Err(SdkError::ServiceError(ref svc)) => {
811 let code = svc.err().meta().code().unwrap_or("");
812 if code == "AccessDenied" || code == "InvalidClientTokenId" {
813 return Ok(SectionResult::Denied {
814 iam_action: "s3:GetBucketCORS".to_string(),
815 });
816 }
817 if code == "NoSuchCORSConfiguration" {
818 return Ok(SectionResult::Value { value: vec![] });
819 }
820 if is_unsupported_code(code) {
821 return Ok(SectionResult::Unsupported {
822 reason: code.to_string(),
823 });
824 }
825 if let Some(hard) = classify_service_error(code, bucket) {
826 return Err(hard);
827 }
828 Ok(SectionResult::Unsupported {
829 reason: format!("GetBucketCors: {code}"),
830 })
831 }
832 Err(e) => Err(AppError::Network {
833 source: format!("GetBucketCors({bucket}): {e}"),
834 }),
835 }
836}
837
838async fn fetch_tags(
840 client: &Client,
841 bucket: &str,
842) -> Result<SectionResult<HashMap<String, String>>, AppError> {
843 match client.get_bucket_tagging().bucket(bucket).send().await {
844 Ok(resp) => {
845 let map: HashMap<String, String> = resp
846 .tag_set()
847 .iter()
848 .map(|t| (t.key().to_string(), t.value().to_string()))
849 .collect();
850 Ok(SectionResult::Value { value: map })
851 }
852 Err(SdkError::ServiceError(ref svc)) => {
853 let code = svc.err().meta().code().unwrap_or("");
854 if code == "AccessDenied" || code == "InvalidClientTokenId" {
855 return Ok(SectionResult::Denied {
856 iam_action: "s3:GetBucketTagging".to_string(),
857 });
858 }
859 if code == "NoSuchTagSet" {
861 return Ok(SectionResult::Value {
862 value: HashMap::new(),
863 });
864 }
865 if is_unsupported_code(code) {
866 return Ok(SectionResult::Unsupported {
867 reason: code.to_string(),
868 });
869 }
870 if let Some(hard) = classify_service_error(code, bucket) {
871 return Err(hard);
872 }
873 Ok(SectionResult::Unsupported {
874 reason: format!("GetBucketTagging: {code}"),
875 })
876 }
877 Err(e) => Err(AppError::Network {
878 source: format!("GetBucketTagging({bucket}): {e}"),
879 }),
880 }
881}
882
883async fn fetch_replication(
885 client: &Client,
886 bucket: &str,
887) -> Result<SectionResult<ReplicationConfig>, AppError> {
888 match client.get_bucket_replication().bucket(bucket).send().await {
889 Ok(resp) => {
890 let cfg = resp.replication_configuration();
891 let role = cfg.map(|c| c.role().to_string()).unwrap_or_default();
892 let destination_buckets: Vec<String> = cfg
893 .map(|c| {
894 c.rules()
895 .iter()
896 .map(|r| {
897 r.destination()
898 .map(|d| d.bucket().to_string())
899 .unwrap_or_default()
900 })
901 .collect()
902 })
903 .unwrap_or_default();
904 Ok(SectionResult::Value {
905 value: ReplicationConfig {
906 role,
907 destination_buckets,
908 },
909 })
910 }
911 Err(SdkError::ServiceError(ref svc)) => {
912 let code = svc.err().meta().code().unwrap_or("");
913 if code == "AccessDenied" || code == "InvalidClientTokenId" {
914 return Ok(SectionResult::Denied {
915 iam_action: "s3:GetReplicationConfiguration".to_string(),
916 });
917 }
918 if code == "ReplicationConfigurationNotFoundError"
919 || code == "NoSuchReplicationConfiguration"
920 {
921 return Ok(SectionResult::Value {
922 value: ReplicationConfig {
923 role: String::new(),
924 destination_buckets: vec![],
925 },
926 });
927 }
928 if is_unsupported_code(code) {
929 return Ok(SectionResult::Unsupported {
930 reason: code.to_string(),
931 });
932 }
933 if let Some(hard) = classify_service_error(code, bucket) {
934 return Err(hard);
935 }
936 Ok(SectionResult::Unsupported {
937 reason: format!("GetBucketReplication: {code}"),
938 })
939 }
940 Err(e) => Err(AppError::Network {
941 source: format!("GetBucketReplication({bucket}): {e}"),
942 }),
943 }
944}
945
946async fn fetch_logging(
948 client: &Client,
949 bucket: &str,
950) -> Result<SectionResult<LoggingConfig>, AppError> {
951 match client.get_bucket_logging().bucket(bucket).send().await {
952 Ok(resp) => {
953 let (target_bucket, target_prefix) = resp
954 .logging_enabled()
955 .map(|le| {
956 (
957 Some(le.target_bucket().to_string()),
958 Some(le.target_prefix().to_string()),
959 )
960 })
961 .unwrap_or((None, None));
962 Ok(SectionResult::Value {
963 value: LoggingConfig {
964 target_bucket,
965 target_prefix,
966 },
967 })
968 }
969 Err(SdkError::ServiceError(ref svc)) => {
970 let code = svc.err().meta().code().unwrap_or("");
971 if code == "AccessDenied" || code == "InvalidClientTokenId" {
972 return Ok(SectionResult::Denied {
973 iam_action: "s3:GetBucketLogging".to_string(),
974 });
975 }
976 if is_unsupported_code(code) {
977 return Ok(SectionResult::Unsupported {
978 reason: code.to_string(),
979 });
980 }
981 if let Some(hard) = classify_service_error(code, bucket) {
982 return Err(hard);
983 }
984 Ok(SectionResult::Unsupported {
985 reason: format!("GetBucketLogging: {code}"),
986 })
987 }
988 Err(e) => Err(AppError::Network {
989 source: format!("GetBucketLogging({bucket}): {e}"),
990 }),
991 }
992}
993
994async fn fetch_website(
996 client: &Client,
997 bucket: &str,
998) -> Result<SectionResult<WebsiteConfig>, AppError> {
999 match client.get_bucket_website().bucket(bucket).send().await {
1000 Ok(resp) => Ok(SectionResult::Value {
1001 value: WebsiteConfig {
1002 index_document: resp.index_document().map(|i| i.suffix().to_string()),
1003 error_document: resp.error_document().map(|e| e.key().to_string()),
1004 redirect_all_requests_to: resp
1005 .redirect_all_requests_to()
1006 .map(|r| r.host_name().to_string()),
1007 },
1008 }),
1009 Err(SdkError::ServiceError(ref svc)) => {
1010 let code = svc.err().meta().code().unwrap_or("");
1011 if code == "AccessDenied" || code == "InvalidClientTokenId" {
1012 return Ok(SectionResult::Denied {
1013 iam_action: "s3:GetBucketWebsite".to_string(),
1014 });
1015 }
1016 if code == "NoSuchWebsiteConfiguration" {
1017 return Ok(SectionResult::Value {
1018 value: WebsiteConfig {
1019 index_document: None,
1020 error_document: None,
1021 redirect_all_requests_to: None,
1022 },
1023 });
1024 }
1025 if is_unsupported_code(code) {
1026 return Ok(SectionResult::Unsupported {
1027 reason: code.to_string(),
1028 });
1029 }
1030 if let Some(hard) = classify_service_error(code, bucket) {
1031 return Err(hard);
1032 }
1033 Ok(SectionResult::Unsupported {
1034 reason: format!("GetBucketWebsite: {code}"),
1035 })
1036 }
1037 Err(e) => Err(AppError::Network {
1038 source: format!("GetBucketWebsite({bucket}): {e}"),
1039 }),
1040 }
1041}
1042
1043async fn fetch_notifications(
1045 client: &Client,
1046 bucket: &str,
1047) -> Result<SectionResult<NotificationConfig>, AppError> {
1048 match client
1049 .get_bucket_notification_configuration()
1050 .bucket(bucket)
1051 .send()
1052 .await
1053 {
1054 Ok(resp) => Ok(SectionResult::Value {
1055 value: NotificationConfig {
1056 lambda_count: resp.lambda_function_configurations().len(),
1057 queue_count: resp.queue_configurations().len(),
1058 topic_count: resp.topic_configurations().len(),
1059 },
1060 }),
1061 Err(SdkError::ServiceError(ref svc)) => {
1062 let code = svc.err().meta().code().unwrap_or("");
1063 if code == "AccessDenied" || code == "InvalidClientTokenId" {
1064 return Ok(SectionResult::Denied {
1065 iam_action: "s3:GetBucketNotification".to_string(),
1066 });
1067 }
1068 if is_unsupported_code(code) {
1069 return Ok(SectionResult::Unsupported {
1070 reason: code.to_string(),
1071 });
1072 }
1073 if let Some(hard) = classify_service_error(code, bucket) {
1074 return Err(hard);
1075 }
1076 Ok(SectionResult::Unsupported {
1077 reason: format!("GetBucketNotificationConfiguration: {code}"),
1078 })
1079 }
1080 Err(e) => Err(AppError::Network {
1081 source: format!("GetBucketNotificationConfiguration({bucket}): {e}"),
1082 }),
1083 }
1084}
1085
1086async fn fetch_ownership_controls(
1088 client: &Client,
1089 bucket: &str,
1090) -> Result<SectionResult<OwnershipControls>, AppError> {
1091 match client
1092 .get_bucket_ownership_controls()
1093 .bucket(bucket)
1094 .send()
1095 .await
1096 {
1097 Ok(resp) => {
1098 let rule = resp
1099 .ownership_controls()
1100 .and_then(|oc| oc.rules().first())
1101 .map(|r| r.object_ownership().as_str().to_string())
1102 .unwrap_or_default();
1103 Ok(SectionResult::Value {
1104 value: OwnershipControls { rule },
1105 })
1106 }
1107 Err(SdkError::ServiceError(ref svc)) => {
1108 let code = svc.err().meta().code().unwrap_or("");
1109 if code == "AccessDenied" || code == "InvalidClientTokenId" {
1110 return Ok(SectionResult::Denied {
1111 iam_action: "s3:GetBucketOwnershipControls".to_string(),
1112 });
1113 }
1114 if code == "OwnershipControlsNotFoundError" {
1115 return Ok(SectionResult::Value {
1116 value: OwnershipControls {
1117 rule: String::new(),
1118 },
1119 });
1120 }
1121 if is_unsupported_code(code) {
1122 return Ok(SectionResult::Unsupported {
1123 reason: code.to_string(),
1124 });
1125 }
1126 if let Some(hard) = classify_service_error(code, bucket) {
1127 return Err(hard);
1128 }
1129 Ok(SectionResult::Unsupported {
1130 reason: format!("GetBucketOwnershipControls: {code}"),
1131 })
1132 }
1133 Err(e) => Err(AppError::Network {
1134 source: format!("GetBucketOwnershipControls({bucket}): {e}"),
1135 }),
1136 }
1137}
1138
1139async fn fetch_requester_pays(
1141 client: &Client,
1142 bucket: &str,
1143) -> Result<SectionResult<bool>, AppError> {
1144 match client
1145 .get_bucket_request_payment()
1146 .bucket(bucket)
1147 .send()
1148 .await
1149 {
1150 Ok(resp) => {
1151 let payer = resp.payer().map(|p| p.as_str()).unwrap_or("BucketOwner");
1152 Ok(SectionResult::Value {
1153 value: payer == "Requester",
1154 })
1155 }
1156 Err(SdkError::ServiceError(ref svc)) => {
1157 let code = svc.err().meta().code().unwrap_or("");
1158 if code == "AccessDenied" || code == "InvalidClientTokenId" {
1159 return Ok(SectionResult::Denied {
1160 iam_action: "s3:GetBucketRequestPayment".to_string(),
1161 });
1162 }
1163 if is_unsupported_code(code) {
1164 return Ok(SectionResult::Unsupported {
1165 reason: code.to_string(),
1166 });
1167 }
1168 if let Some(hard) = classify_service_error(code, bucket) {
1169 return Err(hard);
1170 }
1171 Ok(SectionResult::Unsupported {
1172 reason: format!("GetBucketRequestPayment: {code}"),
1173 })
1174 }
1175 Err(e) => Err(AppError::Network {
1176 source: format!("GetBucketRequestPayment({bucket}): {e}"),
1177 }),
1178 }
1179}
1180
1181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1190#[serde(rename_all = "camelCase")]
1191pub struct ObjectHead {
1192 pub content_length: Option<i64>,
1194 pub content_type: Option<String>,
1196 pub last_modified: Option<i64>,
1198 pub etag: Option<String>,
1200 pub version_id: Option<String>,
1202 pub storage_class: Option<String>,
1204 pub server_side_encryption: Option<String>,
1206 pub sse_kms_key_id: Option<String>,
1208 pub content_encoding: Option<String>,
1210 pub content_disposition: Option<String>,
1212 pub cache_control: Option<String>,
1214 pub expires: Option<i64>,
1216 pub metadata: std::collections::HashMap<String, String>,
1218}
1219
1220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1224#[serde(rename_all = "camelCase")]
1225pub struct AclSummary {
1226 pub owner_display_name: Option<String>,
1228 pub grants_count: usize,
1230}
1231
1232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1238#[serde(rename_all = "camelCase")]
1239pub struct RestoreStatus {
1240 pub ongoing: bool,
1242 pub expiry_secs: Option<i64>,
1244}
1245
1246#[derive(Debug, Clone, Serialize, Deserialize)]
1256#[serde(rename_all = "camelCase")]
1257pub struct ObjectInspectorReport {
1258 pub head: ObjectHead,
1260 pub tags: SectionResult<HashMap<String, String>>,
1262 pub acl_summary: SectionResult<AclSummary>,
1264 pub restore_status: SectionResult<Option<RestoreStatus>>,
1269 pub version_id: Option<String>,
1272 pub checksum_sha256: Option<String>,
1274 pub checksum_md5: Option<String>,
1276 pub checksum_crc32: Option<String>,
1278}
1279
1280fn is_glacier_class(storage_class: Option<&str>) -> bool {
1286 matches!(
1287 storage_class,
1288 Some("GLACIER") | Some("DEEP_ARCHIVE") | Some("GLACIER_IR") | Some("INTELLIGENT_TIERING")
1289 )
1290}
1291
1292fn parse_restore_header(header: &str) -> RestoreStatus {
1297 let ongoing = header.contains("ongoing-request=\"true\"");
1298 let expiry_secs = header
1300 .find("expiry-date=\"")
1301 .and_then(|start| {
1302 let after = &header[start + 13..];
1303 after.find('"').map(|end| &after[..end])
1304 })
1305 .and_then(|date_str| {
1306 parse_expiry_date(date_str)
1309 });
1310 RestoreStatus {
1311 ongoing,
1312 expiry_secs,
1313 }
1314}
1315
1316fn parse_expiry_date(s: &str) -> Option<i64> {
1324 aws_sdk_s3::primitives::DateTime::from_str(s, aws_sdk_s3::primitives::DateTimeFormat::HttpDate)
1325 .ok()
1326 .map(|dt| dt.secs())
1327}
1328
1329pub async fn inspect_object(
1337 client: &Client,
1338 bucket: &str,
1339 key: &str,
1340 version_id: Option<String>,
1341 capability_cache: &CapabilityCache,
1342 profile_id: &ProfileId,
1343) -> Result<ObjectInspectorReport, AppError> {
1344 use crate::ids::BucketId;
1345
1346 let bucket_id = BucketId::new(bucket);
1347
1348 let mut head_req = client.head_object().bucket(bucket).key(key);
1350 if let Some(ref vid) = version_id {
1351 head_req = head_req.version_id(vid);
1352 }
1353 let head_resp = match head_req.send().await {
1354 Ok(r) => r,
1355 Err(SdkError::ServiceError(ref svc)) => {
1356 let code = svc.err().meta().code().unwrap_or("");
1357 return Err(if code == "NoSuchKey" || code == "404" {
1358 AppError::NotFound {
1359 resource: format!("{bucket}/{key}"),
1360 }
1361 } else if code == "AccessDenied" || code == "InvalidClientTokenId" {
1362 AppError::Auth {
1363 reason: format!("HeadObject denied: {code}"),
1364 }
1365 } else {
1366 AppError::Network {
1367 source: format!("HeadObject({bucket}/{key}): {code}"),
1368 }
1369 });
1370 }
1371 Err(e) => {
1372 return Err(AppError::Network {
1373 source: format!("HeadObject({bucket}/{key}): {e}"),
1374 });
1375 }
1376 };
1377
1378 let checksum_sha256 = head_resp.checksum_sha256().map(|s| s.to_string());
1380 let checksum_md5 = head_resp.e_tag().map(|s| s.to_string()); let checksum_crc32 = head_resp.checksum_crc32().map(|s| s.to_string());
1382 let version_id_from_head = head_resp.version_id().map(|s| s.to_string());
1383
1384 let storage_class_str = head_resp.storage_class().map(|sc| sc.as_str().to_string());
1386 let restore_header = head_resp.restore().map(|s| s.to_string());
1387 let restore_status_value: Option<RestoreStatus> =
1388 if is_glacier_class(storage_class_str.as_deref()) {
1389 restore_header.as_deref().map(parse_restore_header)
1390 } else {
1391 None
1392 };
1393
1394 let head = ObjectHead {
1396 content_length: head_resp.content_length(),
1397 content_type: head_resp.content_type().map(|s| s.to_string()),
1398 last_modified: head_resp.last_modified().map(|dt| dt.secs()),
1399 etag: head_resp.e_tag().map(|s| s.to_string()),
1400 version_id: version_id_from_head.clone(),
1401 storage_class: storage_class_str,
1402 server_side_encryption: head_resp
1403 .server_side_encryption()
1404 .map(|sse| sse.as_str().to_string()),
1405 sse_kms_key_id: head_resp.ssekms_key_id().map(|s| s.to_string()),
1406 content_encoding: head_resp.content_encoding().map(|s| s.to_string()),
1407 content_disposition: head_resp.content_disposition().map(|s| s.to_string()),
1408 cache_control: head_resp.cache_control().map(|s| s.to_string()),
1409 expires: head_resp.expires_string().and_then(parse_expiry_date),
1410 metadata: head_resp
1411 .metadata()
1412 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
1413 .unwrap_or_default(),
1414 };
1415
1416 let (tags_result, acl_result) = tokio::join!(
1418 fetch_object_tags(client, bucket, key, version_id.as_deref()),
1419 fetch_object_acl(client, bucket, key, version_id.as_deref()),
1420 );
1421
1422 record_denied(
1424 capability_cache,
1425 profile_id,
1426 &bucket_id,
1427 "s3:GetObjectTagging",
1428 &tags_result,
1429 );
1430 record_denied(
1431 capability_cache,
1432 profile_id,
1433 &bucket_id,
1434 "s3:GetObjectAcl",
1435 &acl_result,
1436 );
1437
1438 let tags = tags_result?;
1439 let acl_summary = acl_result?;
1440
1441 Ok(ObjectInspectorReport {
1442 head,
1443 tags,
1444 acl_summary,
1445 restore_status: SectionResult::Value {
1446 value: restore_status_value,
1447 },
1448 version_id: version_id_from_head,
1449 checksum_sha256,
1450 checksum_md5,
1451 checksum_crc32,
1452 })
1453}
1454
1455pub async fn head_object(
1470 client: &Client,
1471 bucket: &str,
1472 key: &str,
1473 version_id: Option<String>,
1474) -> Result<ObjectHead, AppError> {
1475 let mut req = client.head_object().bucket(bucket).key(key);
1476 if let Some(ref vid) = version_id {
1477 req = req.version_id(vid);
1478 }
1479
1480 let resp = match req.send().await {
1481 Ok(r) => r,
1482 Err(SdkError::ServiceError(ref svc)) => {
1483 let code = svc.err().meta().code().unwrap_or("");
1484 return Err(if code == "NoSuchKey" || code == "404" {
1485 AppError::NotFound {
1486 resource: format!("{bucket}/{key}"),
1487 }
1488 } else if code == "AccessDenied" || code == "InvalidClientTokenId" {
1489 AppError::Auth {
1490 reason: format!("HeadObject denied: {code}"),
1491 }
1492 } else {
1493 AppError::Network {
1494 source: format!("HeadObject({bucket}/{key}): {code}"),
1495 }
1496 });
1497 }
1498 Err(e) => {
1499 return Err(AppError::Network {
1500 source: format!("HeadObject({bucket}/{key}): {e}"),
1501 });
1502 }
1503 };
1504
1505 let storage_class_str = resp.storage_class().map(|sc| sc.as_str().to_string());
1506 let head = ObjectHead {
1507 content_length: resp.content_length(),
1508 content_type: resp.content_type().map(|s| s.to_string()),
1509 last_modified: resp.last_modified().map(|dt| dt.secs()),
1510 etag: resp.e_tag().map(|s| s.to_string()),
1511 version_id: resp.version_id().map(|s| s.to_string()),
1512 storage_class: storage_class_str,
1513 server_side_encryption: resp
1514 .server_side_encryption()
1515 .map(|sse| sse.as_str().to_string()),
1516 sse_kms_key_id: resp.ssekms_key_id().map(|s| s.to_string()),
1517 content_encoding: resp.content_encoding().map(|s| s.to_string()),
1518 content_disposition: resp.content_disposition().map(|s| s.to_string()),
1519 cache_control: resp.cache_control().map(|s| s.to_string()),
1520 expires: resp.expires_string().and_then(parse_expiry_date),
1521 metadata: resp
1522 .metadata()
1523 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
1524 .unwrap_or_default(),
1525 };
1526
1527 Ok(head)
1528}
1529
1530async fn fetch_object_tags(
1536 client: &Client,
1537 bucket: &str,
1538 key: &str,
1539 version_id: Option<&str>,
1540) -> Result<SectionResult<HashMap<String, String>>, AppError> {
1541 let mut req = client.get_object_tagging().bucket(bucket).key(key);
1542 if let Some(vid) = version_id {
1543 req = req.version_id(vid);
1544 }
1545 match req.send().await {
1546 Ok(resp) => {
1547 let map: HashMap<String, String> = resp
1548 .tag_set()
1549 .iter()
1550 .map(|t| (t.key().to_string(), t.value().to_string()))
1551 .collect();
1552 Ok(SectionResult::Value { value: map })
1553 }
1554 Err(SdkError::ServiceError(ref svc)) => {
1555 let code = svc.err().meta().code().unwrap_or("");
1556 if code == "AccessDenied" || code == "InvalidClientTokenId" {
1557 return Ok(SectionResult::Denied {
1558 iam_action: "s3:GetObjectTagging".to_string(),
1559 });
1560 }
1561 if code == "NoSuchKey" {
1562 return Err(AppError::NotFound {
1563 resource: format!("{bucket}/{key}"),
1564 });
1565 }
1566 if is_unsupported_code(code) {
1567 return Ok(SectionResult::Unsupported {
1568 reason: code.to_string(),
1569 });
1570 }
1571 Ok(SectionResult::Unsupported {
1572 reason: format!("GetObjectTagging: {code}"),
1573 })
1574 }
1575 Err(e) => Err(AppError::Network {
1576 source: format!("GetObjectTagging({bucket}/{key}): {e}"),
1577 }),
1578 }
1579}
1580
1581async fn fetch_object_acl(
1583 client: &Client,
1584 bucket: &str,
1585 key: &str,
1586 version_id: Option<&str>,
1587) -> Result<SectionResult<AclSummary>, AppError> {
1588 let mut req = client.get_object_acl().bucket(bucket).key(key);
1589 if let Some(vid) = version_id {
1590 req = req.version_id(vid);
1591 }
1592 match req.send().await {
1593 Ok(resp) => {
1594 let owner_display_name = resp
1595 .owner()
1596 .and_then(|o| o.display_name())
1597 .map(|s| s.to_string());
1598 let grants_count = resp.grants().len();
1599 Ok(SectionResult::Value {
1600 value: AclSummary {
1601 owner_display_name,
1602 grants_count,
1603 },
1604 })
1605 }
1606 Err(SdkError::ServiceError(ref svc)) => {
1607 let code = svc.err().meta().code().unwrap_or("");
1608 if code == "AccessDenied" || code == "InvalidClientTokenId" {
1609 return Ok(SectionResult::Denied {
1610 iam_action: "s3:GetObjectAcl".to_string(),
1611 });
1612 }
1613 if code == "AclNotSupported" || code == "IllegalLocationConstraintException" {
1615 return Ok(SectionResult::Unsupported {
1616 reason: code.to_string(),
1617 });
1618 }
1619 if code == "NoSuchKey" {
1620 return Err(AppError::NotFound {
1621 resource: format!("{bucket}/{key}"),
1622 });
1623 }
1624 if is_unsupported_code(code) {
1625 return Ok(SectionResult::Unsupported {
1626 reason: code.to_string(),
1627 });
1628 }
1629 Ok(SectionResult::Unsupported {
1630 reason: format!("GetObjectAcl: {code}"),
1631 })
1632 }
1633 Err(e) => Err(AppError::Network {
1634 source: format!("GetObjectAcl({bucket}/{key}): {e}"),
1635 }),
1636 }
1637}
1638
1639#[cfg(test)]
1644mod tests {
1645 use super::*;
1646 use serde_json::Value;
1647
1648 fn ser<T: Serialize>(v: &T) -> Value {
1649 serde_json::to_value(v).expect("must serialize")
1650 }
1651
1652 #[test]
1655 fn section_result_value_kind() {
1656 let r: SectionResult<String> = SectionResult::Value {
1657 value: "us-east-1".to_string(),
1658 };
1659 let v = ser(&r);
1660 assert_eq!(v["kind"], "value");
1661 assert_eq!(v["value"], "us-east-1");
1662 }
1663
1664 #[test]
1665 fn section_result_denied_kind() {
1666 let r: SectionResult<String> = SectionResult::Denied {
1667 iam_action: "s3:GetBucketVersioning".to_string(),
1668 };
1669 let v = ser(&r);
1670 assert_eq!(v["kind"], "denied");
1671 assert_eq!(v["iamAction"], "s3:GetBucketVersioning");
1672 }
1673
1674 #[test]
1675 fn section_result_unsupported_kind() {
1676 let r: SectionResult<String> = SectionResult::Unsupported {
1677 reason: "NotImplemented".to_string(),
1678 };
1679 let v = ser(&r);
1680 assert_eq!(v["kind"], "unsupported");
1681 assert_eq!(v["reason"], "NotImplemented");
1682 }
1683
1684 #[test]
1685 fn section_result_deferred_kind() {
1686 let r: SectionResult<()> = SectionResult::Deferred {
1687 reason: "Deferred from v1".to_string(),
1688 };
1689 let v = ser(&r);
1690 assert_eq!(v["kind"], "deferred");
1691 assert_eq!(v["reason"], "Deferred from v1");
1692 }
1693
1694 #[test]
1697 fn bucket_policy_is_always_deferred() {
1698 let report = BucketInspectorReport {
1700 region: SectionResult::Value {
1701 value: "us-east-1".to_string(),
1702 },
1703 versioning: SectionResult::Value {
1704 value: VersioningStatus::Disabled,
1705 },
1706 encryption: SectionResult::Unsupported {
1707 reason: "n/a".to_string(),
1708 },
1709 lifecycle: SectionResult::Value { value: vec![] },
1710 object_lock: SectionResult::Unsupported {
1711 reason: "n/a".to_string(),
1712 },
1713 public_access_block: SectionResult::Unsupported {
1714 reason: "n/a".to_string(),
1715 },
1716 cors: SectionResult::Value { value: vec![] },
1717 tags: SectionResult::Value {
1718 value: HashMap::new(),
1719 },
1720 replication: SectionResult::Unsupported {
1721 reason: "n/a".to_string(),
1722 },
1723 logging: SectionResult::Unsupported {
1724 reason: "n/a".to_string(),
1725 },
1726 website: SectionResult::Unsupported {
1727 reason: "n/a".to_string(),
1728 },
1729 notifications: SectionResult::Unsupported {
1730 reason: "n/a".to_string(),
1731 },
1732 ownership_controls: SectionResult::Unsupported {
1733 reason: "n/a".to_string(),
1734 },
1735 requester_pays: SectionResult::Value { value: false },
1736 bucket_policy: SectionResult::Deferred {
1737 reason: "Deferred from v1".to_string(),
1738 },
1739 };
1740
1741 let v = ser(&report);
1742 assert_eq!(v["bucketPolicy"]["kind"], "deferred");
1743 assert_eq!(v["bucketPolicy"]["reason"], "Deferred from v1");
1744 assert_eq!(v["region"]["kind"], "value");
1746 assert_eq!(v["region"]["value"], "us-east-1");
1747 }
1748
1749 #[test]
1752 fn versioning_status_variants_serialize() {
1753 assert_eq!(
1754 ser(&VersioningStatus::Enabled)["kind"],
1755 serde_json::json!(null) );
1757 let v = serde_json::to_value(VersioningStatus::Enabled).unwrap();
1759 assert_eq!(v, "enabled");
1760 let v = serde_json::to_value(VersioningStatus::Suspended).unwrap();
1761 assert_eq!(v, "suspended");
1762 let v = serde_json::to_value(VersioningStatus::Disabled).unwrap();
1763 assert_eq!(v, "disabled");
1764 }
1765
1766 #[test]
1769 fn record_denied_writes_to_cache() {
1770 use crate::{
1771 cache::capability::{CapabilityCache, CapabilityClass},
1772 ids::{BucketId, ProfileId},
1773 };
1774
1775 let cache = CapabilityCache::default();
1776 let profile_id = ProfileId::new_v4();
1777 let bucket_id = BucketId::new("my-bucket");
1778 let section: Result<SectionResult<String>, AppError> = Ok(SectionResult::Denied {
1779 iam_action: "s3:GetBucketVersioning".to_string(),
1780 });
1781
1782 record_denied(
1783 &cache,
1784 &profile_id,
1785 &bucket_id,
1786 "s3:GetBucketVersioning",
1787 §ion,
1788 );
1789
1790 let record = cache.get(&profile_id, Some(&bucket_id), "s3:GetBucketVersioning");
1791 assert!(record.is_some(), "capability must be recorded after Denied");
1792 assert!(
1793 matches!(record.unwrap().class, CapabilityClass::Denied { .. }),
1794 "capability class must be Denied"
1795 );
1796 }
1797
1798 #[test]
1799 fn record_denied_does_not_write_for_value() {
1800 use crate::{
1801 cache::capability::CapabilityCache,
1802 ids::{BucketId, ProfileId},
1803 };
1804
1805 let cache = CapabilityCache::default();
1806 let profile_id = ProfileId::new_v4();
1807 let bucket_id = BucketId::new("my-bucket");
1808 let section: Result<SectionResult<String>, AppError> = Ok(SectionResult::Value {
1809 value: "us-east-1".to_string(),
1810 });
1811
1812 record_denied(
1813 &cache,
1814 &profile_id,
1815 &bucket_id,
1816 "s3:GetBucketLocation",
1817 §ion,
1818 );
1819
1820 let record = cache.get(&profile_id, Some(&bucket_id), "s3:GetBucketLocation");
1821 assert!(record.is_none(), "no capability record for a Value section");
1822 }
1823
1824 #[test]
1827 fn object_head_serializes_metadata_user_fields() {
1828 let mut meta = HashMap::new();
1829 meta.insert("x-custom-key".to_string(), "hello".to_string());
1830 meta.insert("author".to_string(), "test".to_string());
1831
1832 let head = ObjectHead {
1833 content_length: Some(1024),
1834 content_type: Some("application/octet-stream".to_string()),
1835 last_modified: Some(1_700_000_000),
1836 etag: Some("\"abc123\"".to_string()),
1837 version_id: Some("v1".to_string()),
1838 storage_class: Some("STANDARD".to_string()),
1839 server_side_encryption: None,
1840 sse_kms_key_id: None,
1841 content_encoding: None,
1842 content_disposition: None,
1843 cache_control: None,
1844 expires: None,
1845 metadata: meta,
1846 };
1847
1848 let v = ser(&head);
1849 assert_eq!(v["contentLength"], 1024);
1851 assert_eq!(v["contentType"], "application/octet-stream");
1852 assert_eq!(v["etag"], "\"abc123\"");
1853 assert_eq!(v["versionId"], "v1");
1854 assert_eq!(v["storageClass"], "STANDARD");
1855 assert_eq!(v["metadata"]["x-custom-key"], "hello");
1857 assert_eq!(v["metadata"]["author"], "test");
1858 }
1859
1860 #[test]
1863 fn acl_summary_serializes_correctly() {
1864 let acl = AclSummary {
1865 owner_display_name: Some("Alice".to_string()),
1866 grants_count: 3,
1867 };
1868 let v = ser(&acl);
1869 assert_eq!(v["ownerDisplayName"], "Alice");
1870 assert_eq!(v["grantsCount"], 3);
1871 }
1872
1873 #[test]
1874 fn acl_summary_null_owner() {
1875 let acl = AclSummary {
1876 owner_display_name: None,
1877 grants_count: 0,
1878 };
1879 let v = ser(&acl);
1880 assert!(v["ownerDisplayName"].is_null());
1881 assert_eq!(v["grantsCount"], 0);
1882 }
1883
1884 #[test]
1887 fn restore_status_ongoing_serializes() {
1888 let rs = RestoreStatus {
1889 ongoing: true,
1890 expiry_secs: None,
1891 };
1892 let v = ser(&rs);
1893 assert_eq!(v["ongoing"], true);
1894 assert!(v["expirySecs"].is_null());
1895 }
1896
1897 #[test]
1898 fn restore_status_completed_serializes() {
1899 let rs = RestoreStatus {
1900 ongoing: false,
1901 expiry_secs: Some(1_800_000_000),
1902 };
1903 let v = ser(&rs);
1904 assert_eq!(v["ongoing"], false);
1905 assert_eq!(v["expirySecs"], 1_800_000_000i64);
1906 }
1907
1908 #[test]
1911 fn parse_restore_header_ongoing_true() {
1912 let rs = parse_restore_header("ongoing-request=\"true\"");
1913 assert!(rs.ongoing);
1914 assert!(rs.expiry_secs.is_none());
1915 }
1916
1917 #[test]
1918 fn parse_restore_header_ongoing_false_no_expiry() {
1919 let rs = parse_restore_header("ongoing-request=\"false\"");
1920 assert!(!rs.ongoing);
1921 assert!(rs.expiry_secs.is_none());
1922 }
1923
1924 #[test]
1927 fn is_glacier_class_recognizes_glacier() {
1928 assert!(is_glacier_class(Some("GLACIER")));
1929 assert!(is_glacier_class(Some("DEEP_ARCHIVE")));
1930 assert!(is_glacier_class(Some("GLACIER_IR")));
1931 assert!(is_glacier_class(Some("INTELLIGENT_TIERING")));
1932 }
1933
1934 #[test]
1935 fn is_glacier_class_rejects_standard() {
1936 assert!(!is_glacier_class(Some("STANDARD")));
1937 assert!(!is_glacier_class(Some("STANDARD_IA")));
1938 assert!(!is_glacier_class(None));
1939 }
1940
1941 #[test]
1944 fn object_inspector_report_denied_tags_and_acl_serialize() {
1945 let report = ObjectInspectorReport {
1946 head: ObjectHead {
1947 content_length: Some(512),
1948 content_type: Some("text/plain".to_string()),
1949 last_modified: Some(1_700_000_000),
1950 etag: Some("\"deadbeef\"".to_string()),
1951 version_id: None,
1952 storage_class: Some("STANDARD".to_string()),
1953 server_side_encryption: None,
1954 sse_kms_key_id: None,
1955 content_encoding: None,
1956 content_disposition: None,
1957 cache_control: None,
1958 expires: None,
1959 metadata: HashMap::new(),
1960 },
1961 tags: SectionResult::Denied {
1962 iam_action: "s3:GetObjectTagging".to_string(),
1963 },
1964 acl_summary: SectionResult::Denied {
1965 iam_action: "s3:GetObjectAcl".to_string(),
1966 },
1967 restore_status: SectionResult::Value { value: None },
1968 version_id: None,
1969 checksum_sha256: None,
1970 checksum_md5: Some("\"deadbeef\"".to_string()),
1971 checksum_crc32: None,
1972 };
1973
1974 let v = ser(&report);
1975 assert_eq!(v["tags"]["kind"], "denied");
1977 assert_eq!(v["tags"]["iamAction"], "s3:GetObjectTagging");
1978 assert_eq!(v["aclSummary"]["kind"], "denied");
1980 assert_eq!(v["aclSummary"]["iamAction"], "s3:GetObjectAcl");
1981 assert_eq!(v["restoreStatus"]["kind"], "value");
1983 assert!(v["restoreStatus"]["value"].is_null());
1984 assert!(v["checksumSha256"].is_null());
1986 assert_eq!(v["checksumMd5"], "\"deadbeef\"");
1987 }
1988}