Skip to main content

brows3r_lib/s3/
inspector.rs

1//! Bucket inspector: aggregates read-only bucket properties in parallel.
2//!
3//! # Design
4//!
5//! Each bucket property is fetched as an independent S3 API call via
6//! `tokio::join!`. Every call is classified into one of four outcomes:
7//!
8//! - `SectionResult::Value(T)` — the call succeeded and returned a value.
9//! - `SectionResult::Denied { iam_action }` — `AccessDenied`; also recorded
10//!   into `CapabilityCache` so the UI can render "Requires `s3:GetBucketX`".
11//! - `SectionResult::Unsupported { reason }` — the provider does not
12//!   implement this API (e.g. LocalStack free-tier, MinIO, R2).
13//! - `SectionResult::Deferred { reason }` — intentionally omitted from v1
14//!   (currently only `bucket_policy`).
15//!
16//! Any error that does not map to Denied or Unsupported is treated as a
17//! critical failure and bubbles up as `Err(AppError)` for the whole call.
18//! In practice, only `NoSuchBucket` (bucket deleted mid-inspect) is a hard
19//! failure; all capability errors degrade gracefully at the section level.
20//!
21//! # OCP
22//!
23//! - Adding a new section: one new field on `BucketInspectorReport` + one
24//!   parallel arm inside `inspect_bucket`. No existing sections change.
25//! - Adding a new `SectionResult` discriminator: one variant + one arm in
26//!   consumer `match` blocks. Existing variants are untouched.
27//! - Capability cache writes happen automatically from `Denied` outcomes;
28//!   the frontend never needs to call `capability_get` explicitly.
29
30use 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// ---------------------------------------------------------------------------
42// SectionResult — discriminated outcome for each inspector section
43// ---------------------------------------------------------------------------
44
45/// The result of fetching one bucket property section.
46///
47/// Serializes with `tag = "kind"` and `rename_all = "camelCase"` so the
48/// frontend discriminates by `section.kind`.
49///
50/// OCP: adding e.g. `Pending` is one new variant here.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52#[serde(tag = "kind", rename_all = "camelCase")]
53pub enum SectionResult<T> {
54    /// The API succeeded and returned a value.
55    Value { value: T },
56    /// `AccessDenied` — IAM policy blocked this section.
57    ///
58    /// `iam_action` is the IAM action string from the error when available
59    /// (e.g. `"s3:GetBucketVersioning"`).
60    Denied {
61        #[serde(rename = "iamAction")]
62        iam_action: String,
63    },
64    /// The provider does not implement this API.
65    Unsupported { reason: String },
66    /// Intentionally omitted from v1 (per design non-goals).
67    Deferred { reason: String },
68}
69
70// ---------------------------------------------------------------------------
71// Section value types — all camelCase for IPC
72// ---------------------------------------------------------------------------
73
74/// Bucket versioning state.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "camelCase")]
77pub enum VersioningStatus {
78    Enabled,
79    Suspended,
80    Disabled,
81}
82
83/// Summary of server-side encryption configuration (read-only in v1).
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85#[serde(rename_all = "camelCase")]
86pub struct EncryptionConfig {
87    /// Primary SSE algorithm, e.g. `"aws:kms"` or `"AES256"`.
88    pub sse_algorithm: Option<String>,
89    /// KMS key ID when `sse_algorithm` is `"aws:kms"`.
90    pub kms_master_key_id: Option<String>,
91}
92
93/// A single lifecycle rule summary.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95#[serde(rename_all = "camelCase")]
96pub struct LifecycleRule {
97    pub id: Option<String>,
98    pub status: String,
99    /// Filter prefix for this rule, if any.
100    pub prefix: Option<String>,
101}
102
103/// Object-lock configuration summary.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105#[serde(rename_all = "camelCase")]
106pub struct ObjectLockConfig {
107    pub object_lock_enabled: bool,
108    /// Default lock mode, e.g. `"COMPLIANCE"` or `"GOVERNANCE"`.
109    pub default_retention_mode: Option<String>,
110    /// Default retention in days.
111    pub default_retention_days: Option<i32>,
112    /// Default retention in years.
113    pub default_retention_years: Option<i32>,
114}
115
116/// Public access block configuration.
117#[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/// A single CORS rule summary.
127#[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/// Replication configuration summary.
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
139#[serde(rename_all = "camelCase")]
140pub struct ReplicationConfig {
141    pub role: String,
142    /// Destination bucket ARNs (one per rule).
143    pub destination_buckets: Vec<String>,
144}
145
146/// Bucket logging configuration.
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148#[serde(rename_all = "camelCase")]
149pub struct LoggingConfig {
150    /// Target bucket for access logs.
151    pub target_bucket: Option<String>,
152    /// Key prefix for log objects.
153    pub target_prefix: Option<String>,
154}
155
156/// Static website hosting configuration summary.
157#[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/// S3 event notification configuration summary.
166///
167/// Not the app's own notification system — these are S3-side event
168/// notifications such as Lambda triggers, SQS queues, and SNS topics.
169#[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/// Bucket ownership controls.
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
179#[serde(rename_all = "camelCase")]
180pub struct OwnershipControls {
181    /// Ownership rule, e.g. `"BucketOwnerEnforced"` or `"ObjectWriter"`.
182    pub rule: String,
183}
184
185// ---------------------------------------------------------------------------
186// BucketInspectorReport — the aggregated report returned over IPC
187// ---------------------------------------------------------------------------
188
189/// Aggregated read-only properties of a bucket.
190///
191/// Every section uses `SectionResult<T>` so the frontend can render
192/// `Value`, disabled `Denied`, `Unsupported`, or `Deferred` states
193/// without treating capability gaps as errors.
194///
195/// OCP: adding a new section is one new field here + one parallel call in
196/// `inspect_bucket`. No existing sections change.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct BucketInspectorReport {
200    pub region: SectionResult<String>,
201    pub versioning: SectionResult<VersioningStatus>,
202    /// Server-side encryption rules summary. Read-only in v1.
203    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    /// S3 event notification configuration (Lambda, SQS, SNS triggers).
213    pub notifications: SectionResult<NotificationConfig>,
214    pub ownership_controls: SectionResult<OwnershipControls>,
215    pub requester_pays: SectionResult<bool>,
216    /// Intentionally absent in v1 — bucket policy viewer is a non-goal.
217    pub bucket_policy: SectionResult<()>,
218}
219
220// ---------------------------------------------------------------------------
221// inspect_bucket — main entry point
222// ---------------------------------------------------------------------------
223
224/// Fetch all supported bucket properties in parallel and return an aggregated
225/// `BucketInspectorReport`.
226///
227/// - Successful sections → `SectionResult::Value`.
228/// - `AccessDenied` → `SectionResult::Denied`; also recorded into the
229///   `CapabilityCache` for the (profile, bucket, op) triple.
230/// - `NotImplemented` / `UnsupportedOperation` / provider-specific
231///   "not supported" codes → `SectionResult::Unsupported`.
232/// - `bucket_policy` is hardcoded `Deferred` without calling the API.
233///
234/// Only `AppError::NotFound` (bucket deleted mid-inspect) propagates as a
235/// hard failure; everything else degrades per-section.
236pub 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 AccessDenied outcomes into the capability cache.
277    record_denied(
278        capability_cache,
279        profile_id,
280        &bucket_id,
281        "s3:GetBucketLocation",
282        &region_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        &notification_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    // Propagate hard failures (NoSuchBucket) from any section.
377    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 is a v1 non-goal — never call the API.
408        bucket_policy: SectionResult::Deferred {
409            reason: "Deferred from v1".to_string(),
410        },
411    })
412}
413
414// ---------------------------------------------------------------------------
415// Private helpers
416// ---------------------------------------------------------------------------
417
418/// Record a `Denied` capability into the cache when the section is `Denied`.
419fn 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
438/// Classify a generic service-error code into `SectionResult`.
439///
440/// Returns `None` when the error is a hard failure that must propagate.
441fn 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
450/// Return `true` for codes that indicate the API is not supported by this
451/// provider (LocalStack free-tier, MinIO, R2, …).
452fn 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
470// ---------------------------------------------------------------------------
471// Individual section fetchers
472// ---------------------------------------------------------------------------
473
474/// Fetch the bucket region via `GetBucketLocation`.
475async 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
513/// Fetch versioning state via `GetBucketVersioning`.
514async 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
552/// Fetch SSE configuration via `GetBucketEncryption`.
553async 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            // "ServerSideEncryptionConfigurationNotFoundError" means no SSE
585            // configured — that is a valid value (no encryption).
586            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
612/// Fetch lifecycle rules via `GetBucketLifecycleConfiguration`.
613async 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
663/// Fetch object-lock configuration via `GetObjectLockConfiguration`.
664async 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
736/// Fetch public access block configuration via `GetPublicAccessBlock`.
737async 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
790/// Fetch CORS rules via `GetBucketCors`.
791async 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
838/// Fetch bucket tags via `GetBucketTagging`.
839async 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            // NoSuchTagSet means the bucket has no tags — valid empty state.
860            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
883/// Fetch replication configuration via `GetBucketReplication`.
884async 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
946/// Fetch logging configuration via `GetBucketLogging`.
947async 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
994/// Fetch static website configuration via `GetBucketWebsite`.
995async 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
1043/// Fetch S3 event notification configuration via `GetBucketNotificationConfiguration`.
1044async 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
1086/// Fetch ownership controls via `GetBucketOwnershipControls`.
1087async 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
1139/// Fetch requester-pays status via `GetBucketRequestPayment`.
1140async 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// ---------------------------------------------------------------------------
1182// ObjectHead — per-object property bag from HeadObject
1183// ---------------------------------------------------------------------------
1184
1185/// All properties returned by `HeadObject` for a single S3 object.
1186///
1187/// User-defined metadata (`x-amz-meta-*`) is surfaced in `metadata`.
1188/// All fields are optional — not every object or provider returns every header.
1189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1190#[serde(rename_all = "camelCase")]
1191pub struct ObjectHead {
1192    /// Object size in bytes.
1193    pub content_length: Option<i64>,
1194    /// MIME type, e.g. `"application/octet-stream"`.
1195    pub content_type: Option<String>,
1196    /// RFC 2822 last-modified timestamp as a Unix epoch (seconds).
1197    pub last_modified: Option<i64>,
1198    /// HTTP ETag string (including surrounding quotes from S3).
1199    pub etag: Option<String>,
1200    /// Version ID if the bucket has versioning enabled.
1201    pub version_id: Option<String>,
1202    /// S3 storage class, e.g. `"STANDARD"`, `"GLACIER"`.
1203    pub storage_class: Option<String>,
1204    /// Server-side encryption algorithm, e.g. `"aws:kms"` or `"AES256"`.
1205    pub server_side_encryption: Option<String>,
1206    /// KMS key ID when SSE-KMS is active.
1207    pub sse_kms_key_id: Option<String>,
1208    /// `Content-Encoding` header value, e.g. `"gzip"`.
1209    pub content_encoding: Option<String>,
1210    /// `Content-Disposition` header value.
1211    pub content_disposition: Option<String>,
1212    /// `Cache-Control` header value.
1213    pub cache_control: Option<String>,
1214    /// `Expires` header as Unix epoch (seconds), if present.
1215    pub expires: Option<i64>,
1216    /// User-defined metadata (keys stripped of the `x-amz-meta-` prefix).
1217    pub metadata: std::collections::HashMap<String, String>,
1218}
1219
1220/// ACL summary: owner display name + total grant count.
1221///
1222/// Intentionally minimal — the full ACL grant list is not v1 scope.
1223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1224#[serde(rename_all = "camelCase")]
1225pub struct AclSummary {
1226    /// Owner display name when available.
1227    pub owner_display_name: Option<String>,
1228    /// Total number of individual grants on this object.
1229    pub grants_count: usize,
1230}
1231
1232/// Glacier / Deep Archive restore status for an object.
1233///
1234/// - `ongoing`: a restore is in progress but not yet complete.
1235/// - `expiry_secs`: Unix epoch when the restored copy expires (if already restored).
1236/// - Neither field set: the object is in a non-Glacier class or has no active restore.
1237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1238#[serde(rename_all = "camelCase")]
1239pub struct RestoreStatus {
1240    /// `true` while an AWS Glacier restore job is in progress.
1241    pub ongoing: bool,
1242    /// Unix epoch (seconds) when the restored copy will expire, if restored.
1243    pub expiry_secs: Option<i64>,
1244}
1245
1246// ---------------------------------------------------------------------------
1247// ObjectInspectorReport — the aggregated per-object report
1248// ---------------------------------------------------------------------------
1249
1250/// Aggregated read-only properties for a single S3 object.
1251///
1252/// OCP: adding a new section (e.g. `legal_hold`, `retention`) is one new field
1253/// here plus one parallel arm in `inspect_object`. Existing sections are
1254/// untouched.
1255#[derive(Debug, Clone, Serialize, Deserialize)]
1256#[serde(rename_all = "camelCase")]
1257pub struct ObjectInspectorReport {
1258    /// Properties from `HeadObject`.
1259    pub head: ObjectHead,
1260    /// Object tags from `GetObjectTagging`.
1261    pub tags: SectionResult<HashMap<String, String>>,
1262    /// ACL summary from `GetObjectAcl`.
1263    pub acl_summary: SectionResult<AclSummary>,
1264    /// Glacier/Deep Archive restore status.
1265    ///
1266    /// `Value(None)` means the object is in a non-Glacier class; no restore
1267    /// info is relevant. `Value(Some(…))` carries the parsed status.
1268    pub restore_status: SectionResult<Option<RestoreStatus>>,
1269    /// Version ID extracted from `HeadObject` — also available inline on
1270    /// `head.version_id` but mirrored here as a flat convenience field.
1271    pub version_id: Option<String>,
1272    /// SHA-256 checksum if returned by S3.
1273    pub checksum_sha256: Option<String>,
1274    /// MD5 checksum if returned by S3 (pre-SDK checksum header).
1275    pub checksum_md5: Option<String>,
1276    /// CRC-32 checksum if returned by S3.
1277    pub checksum_crc32: Option<String>,
1278}
1279
1280// ---------------------------------------------------------------------------
1281// inspect_object — main entry point
1282// ---------------------------------------------------------------------------
1283
1284/// Glacier-class storage class codes recognised for restore-status parsing.
1285fn 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
1292/// Parse the Glacier restore header string into a `RestoreStatus`.
1293///
1294/// S3 uses the form: `ongoing-request="true"` or
1295/// `ongoing-request="false", expiry-date="<RFC 2822 date>"`.
1296fn parse_restore_header(header: &str) -> RestoreStatus {
1297    let ongoing = header.contains("ongoing-request=\"true\"");
1298    // Extract expiry-date if present; parse to epoch seconds.
1299    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            // httpdate / RFC 2822 — use a simple parser via chrono if available,
1307            // otherwise parse "Fri, 01 Jan 2027 00:00:00 GMT" style.
1308            parse_expiry_date(date_str)
1309        });
1310    RestoreStatus {
1311        ongoing,
1312        expiry_secs,
1313    }
1314}
1315
1316/// Attempt to parse a Glacier restore expiry date to a Unix timestamp.
1317///
1318/// AWS returns HTTP-date strings such as `"Fri, 01 Jan 2027 00:00:00 GMT"`.
1319/// We parse via `aws_sdk_s3::primitives::DateTime` which is always available
1320/// as a re-export of `aws-smithy-types`.
1321///
1322/// Returns `None` on parse failure rather than panicking on unexpected formats.
1323fn 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
1329/// Fetch all object properties in parallel and return an `ObjectInspectorReport`.
1330///
1331/// - `HeadObject` is always called (hard failure if the key does not exist).
1332/// - `GetObjectTagging` and `GetObjectAcl` are called in parallel.
1333/// - Restore-status parsing reads the `Restore` header returned by `HeadObject`.
1334/// - `AccessDenied` on tagging/ACL → `SectionResult::Denied` recorded in the
1335///   capability cache so the UI can render disabled reasons.
1336pub 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    // --- HeadObject ---
1349    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    // Extract checksum fields before moving head_resp.
1379    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()); // ETag is the MD5 for non-MPU
1381    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    // Parse restore status from HeadObject response.
1385    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    // Build ObjectHead from the HeadObject response.
1395    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    // --- Parallel: GetObjectTagging + GetObjectAcl ---
1417    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 capability cache for denied sections.
1423    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
1455// ---------------------------------------------------------------------------
1456// head_object — lightweight HEAD-only path for the preview pane
1457// ---------------------------------------------------------------------------
1458
1459/// Fetch only `HeadObject` for a single S3 object, returning an `ObjectHead`.
1460///
1461/// Lighter than `inspect_object` — no parallel `GetObjectTagging` or
1462/// `GetObjectAcl` calls.  Used by the preview pane for MIME-type detection and
1463/// size-limit checks before deciding which renderer to show.
1464///
1465/// # Errors
1466///
1467/// Returns `AppError::NotFound` if the key does not exist, `AppError::Auth` on
1468/// `AccessDenied`, and `AppError::Network` for other S3 errors.
1469pub 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
1530// ---------------------------------------------------------------------------
1531// Object section fetchers
1532// ---------------------------------------------------------------------------
1533
1534/// Fetch object tags via `GetObjectTagging`.
1535async 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
1581/// Fetch object ACL summary via `GetObjectAcl`.
1582async 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            // When bucket uses BucketOwnerEnforced, ACL is disabled.
1614            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// ---------------------------------------------------------------------------
1640// Unit tests
1641// ---------------------------------------------------------------------------
1642
1643#[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    // -- SectionResult serialization ------------------------------------------
1653
1654    #[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    // -- BucketInspectorReport.bucket_policy is always Deferred --------------
1695
1696    #[test]
1697    fn bucket_policy_is_always_deferred() {
1698        // Build a minimal report with a Value region and deferred policy.
1699        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        // region is a Value
1745        assert_eq!(v["region"]["kind"], "value");
1746        assert_eq!(v["region"]["value"], "us-east-1");
1747    }
1748
1749    // -- VersioningStatus serialization --------------------------------------
1750
1751    #[test]
1752    fn versioning_status_variants_serialize() {
1753        assert_eq!(
1754            ser(&VersioningStatus::Enabled)["kind"],
1755            serde_json::json!(null) // not tagged — test raw string form
1756        );
1757        // VersioningStatus is a plain unit enum with camelCase rename_all.
1758        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    // -- record_denied writes into capability cache -------------------------
1767
1768    #[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            &section,
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            &section,
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    // -- ObjectHead serialization ---------------------------------------------
1825
1826    #[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        // camelCase serialization
1850        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        // User-defined metadata fields must be present
1856        assert_eq!(v["metadata"]["x-custom-key"], "hello");
1857        assert_eq!(v["metadata"]["author"], "test");
1858    }
1859
1860    // -- AclSummary serialization --------------------------------------------
1861
1862    #[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    // -- RestoreStatus serialization -----------------------------------------
1885
1886    #[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    // -- parse_restore_header ------------------------------------------------
1909
1910    #[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    // -- is_glacier_class ----------------------------------------------------
1925
1926    #[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    // -- ObjectInspectorReport: SectionResult::Denied for tags + acl ---------
1942
1943    #[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        // tags section denied
1976        assert_eq!(v["tags"]["kind"], "denied");
1977        assert_eq!(v["tags"]["iamAction"], "s3:GetObjectTagging");
1978        // acl_summary section denied
1979        assert_eq!(v["aclSummary"]["kind"], "denied");
1980        assert_eq!(v["aclSummary"]["iamAction"], "s3:GetObjectAcl");
1981        // restore_status for non-Glacier is Value(null)
1982        assert_eq!(v["restoreStatus"]["kind"], "value");
1983        assert!(v["restoreStatus"]["value"].is_null());
1984        // checksums
1985        assert!(v["checksumSha256"].is_null());
1986        assert_eq!(v["checksumMd5"], "\"deadbeef\"");
1987    }
1988}