Skip to main content

brows3r_lib/commands/
inspector_cmd.rs

1//! Tauri commands for bucket and object inspection and capability cache management.
2//!
3//! # Commands
4//!
5//! - `bucket_inspect`   — aggregate all read-only bucket properties into a
6//!                        `BucketInspectorReport`; each section reports
7//!                        `value | denied | unsupported | deferred`.
8//! - `object_inspect`   — aggregate per-object properties (head + tags + acl
9//!                        summary + restore status) into an
10//!                        `ObjectInspectorReport`; sections degrade gracefully
11//!                        on `AccessDenied` or unsupported APIs.
12//! - `capability_get`   — return the known capability map for a profile
13//!                        (optionally scoped to one bucket or op).
14//! - `capability_clear` — manually reset cached capabilities for a profile.
15
16use tauri::State;
17
18use crate::{
19    cache::capability::{CapabilityHandle, CapabilityMap, ClearScope},
20    error::AppError,
21    ids::{BucketId, ProfileId},
22    profiles::ProfileStoreHandle,
23    s3::{
24        inspector::{
25            head_object, inspect_bucket, inspect_object, BucketInspectorReport, ObjectHead,
26            ObjectInspectorReport,
27        },
28        S3ClientPoolHandle,
29    },
30};
31
32// ---------------------------------------------------------------------------
33// bucket_inspect
34// ---------------------------------------------------------------------------
35
36/// Inspect a bucket and return an aggregated `BucketInspectorReport`.
37///
38/// Each section in the report carries one of:
39/// - `{ kind: "value", value: T }` — successful fetch.
40/// - `{ kind: "denied", iamAction }` — IAM permission denied.
41/// - `{ kind: "unsupported", reason }` — provider does not implement the API.
42/// - `{ kind: "deferred", reason }` — intentionally absent in v1.
43///
44/// `bucket_policy` is always `Deferred { reason: "Deferred from v1" }` per the
45/// v1 non-goals in the proposal.
46///
47/// All `AccessDenied` outcomes are automatically recorded into the
48/// `CapabilityCache` so future UI renders show disabled reasons without re-
49/// querying.
50#[tauri::command]
51pub async fn bucket_inspect(
52    profile_id: ProfileId,
53    bucket: String,
54    store: State<'_, ProfileStoreHandle>,
55    pool: State<'_, S3ClientPoolHandle>,
56    capability_cache: State<'_, CapabilityHandle>,
57) -> Result<BucketInspectorReport, AppError> {
58    // Validation gate: refuse to serve data for unvalidated profiles (AC-8 /
59    // round-1 finding #9). The capability cache is also gated, but this is
60    // defence-in-depth.
61    let (validated, region_override, default_region) = {
62        let store_guard = store.inner.lock().await;
63        let profile = store_guard
64            .get(&profile_id)
65            .ok_or_else(|| AppError::NotFound {
66                resource: format!("profile:{}", profile_id.as_str()),
67            })?;
68        (
69            profile.validated_at.is_some(),
70            profile.compat_flags.region_override.clone(),
71            profile.default_region.clone(),
72        )
73    };
74
75    if !validated {
76        return Err(AppError::Auth {
77            reason: "Profile has not been validated".to_string(),
78        });
79    }
80
81    // Resolve the per-bucket client. Use the profile's region override or
82    // default region if set; fall back to us-east-1 otherwise. Background
83    // region discovery (task 23) refines this for region-redirected clients.
84    let region = region_override
85        .or(default_region)
86        .unwrap_or_else(|| "us-east-1".to_string());
87    let client = pool
88        .inner
89        .get_or_build(&profile_id, &region)
90        .await
91        .ok_or_else(|| AppError::Internal {
92            trace_id: uuid::Uuid::new_v4().to_string(),
93        })?;
94
95    inspect_bucket(&client, &bucket, capability_cache.inner(), &profile_id).await
96}
97
98// ---------------------------------------------------------------------------
99// object_inspect
100// ---------------------------------------------------------------------------
101
102/// Inspect a single S3 object and return an aggregated `ObjectInspectorReport`.
103///
104/// The report includes:
105/// - `head` — all `HeadObject` properties including user-defined metadata.
106/// - `tags` — object tags from `GetObjectTagging`.
107/// - `acl_summary` — ACL summary from `GetObjectAcl`.
108/// - `restore_status` — Glacier/Deep Archive restore status parsed from the
109///   `Restore` header on `HeadObject`.
110/// - `version_id` — version ID from `HeadObject` (also on `head.version_id`).
111/// - `checksum_sha256`, `checksum_md5`, `checksum_crc32` — checksums when
112///   available.
113///
114/// `AccessDenied` on tags or ACL degrades to `SectionResult::Denied` rather
115/// than a hard error, and the denial is cached so the UI shows disabled
116/// reasons without re-querying.
117#[tauri::command]
118pub async fn object_inspect(
119    profile_id: ProfileId,
120    bucket: String,
121    key: String,
122    version_id: Option<String>,
123    store: State<'_, ProfileStoreHandle>,
124    pool: State<'_, S3ClientPoolHandle>,
125    capability_cache: State<'_, CapabilityHandle>,
126) -> Result<ObjectInspectorReport, AppError> {
127    // Validation gate: same as bucket_inspect (AC-8 / round-1 finding #9).
128    let (validated, region_override, default_region) = {
129        let store_guard = store.inner.lock().await;
130        let profile = store_guard
131            .get(&profile_id)
132            .ok_or_else(|| AppError::NotFound {
133                resource: format!("profile:{}", profile_id.as_str()),
134            })?;
135        (
136            profile.validated_at.is_some(),
137            profile.compat_flags.region_override.clone(),
138            profile.default_region.clone(),
139        )
140    };
141
142    if !validated {
143        return Err(AppError::Auth {
144            reason: "Profile has not been validated".to_string(),
145        });
146    }
147
148    let region = region_override
149        .or(default_region)
150        .unwrap_or_else(|| "us-east-1".to_string());
151    let client = pool
152        .inner
153        .get_or_build(&profile_id, &region)
154        .await
155        .ok_or_else(|| AppError::Internal {
156            trace_id: uuid::Uuid::new_v4().to_string(),
157        })?;
158
159    inspect_object(
160        &client,
161        &bucket,
162        &key,
163        version_id,
164        capability_cache.inner(),
165        &profile_id,
166    )
167    .await
168}
169
170// ---------------------------------------------------------------------------
171// CapabilityScope
172// ---------------------------------------------------------------------------
173
174/// Scope selector for `capability_get`.
175///
176/// `All` returns every capability for the profile; `Bucket` and `Op` act as
177/// filters.  These are distinct from `ClearScope` because future variants may
178/// differ between read and write paths.
179#[derive(Debug, serde::Deserialize)]
180#[serde(tag = "kind", rename_all = "camelCase")]
181pub enum CapabilityScope {
182    /// Return every capability for the profile.
183    All,
184    /// Return only capabilities for the given bucket.
185    Bucket { bucket_id: BucketId },
186    /// Return only capabilities for the given operation string.
187    Op { op: String },
188}
189
190// ---------------------------------------------------------------------------
191// capability_get
192// ---------------------------------------------------------------------------
193
194/// Return the cached capability map for `profile_id`, optionally filtered by
195/// `scope`.
196///
197/// The returned `CapabilityMap` keys are `"<bucket>/<op>"` where `<bucket>` is
198/// empty for profile-level operations.
199#[tauri::command]
200pub async fn capability_get(
201    profile_id: ProfileId,
202    scope: CapabilityScope,
203    cap: State<'_, CapabilityHandle>,
204) -> Result<CapabilityMap, AppError> {
205    let cache = cap.inner();
206    let full_map = cache.get_map(&profile_id);
207
208    let filtered = match scope {
209        CapabilityScope::All => full_map,
210        CapabilityScope::Bucket { bucket_id } => {
211            let prefix = format!("{}/", bucket_id.as_str());
212            full_map
213                .into_iter()
214                .filter(|(k, _)| k.starts_with(&prefix))
215                .collect()
216        }
217        CapabilityScope::Op { op } => {
218            let suffix = format!("/{op}");
219            full_map
220                .into_iter()
221                .filter(|(k, _)| k.ends_with(&suffix))
222                .collect()
223        }
224    };
225
226    Ok(filtered)
227}
228
229// ---------------------------------------------------------------------------
230// capability_clear
231// ---------------------------------------------------------------------------
232
233/// Clear cached capabilities for `profile_id`.
234///
235/// When `scope` is `None` all entries for the profile are removed (equivalent
236/// to `ClearScope::All`).
237#[tauri::command]
238pub async fn capability_clear(
239    profile_id: ProfileId,
240    scope: Option<ClearScopeDto>,
241    cap: State<'_, CapabilityHandle>,
242) -> Result<(), AppError> {
243    let clear_scope = match scope {
244        None | Some(ClearScopeDto::All) => ClearScope::All,
245        Some(ClearScopeDto::Bucket { bucket_id }) => ClearScope::Bucket(bucket_id),
246        Some(ClearScopeDto::Op { op }) => ClearScope::Op(op),
247    };
248    cap.inner().clear(&profile_id, &clear_scope);
249    Ok(())
250}
251
252// ---------------------------------------------------------------------------
253// ClearScopeDto — IPC-friendly version of ClearScope
254// ---------------------------------------------------------------------------
255
256/// IPC-friendly discriminated union for `capability_clear`.
257///
258/// Mirrors `ClearScope` but uses serde-tagged form so the frontend can pass
259/// `{ kind: "bucket", bucketId: "my-bucket" }` etc.
260#[derive(Debug, serde::Deserialize)]
261#[serde(tag = "kind", rename_all = "camelCase")]
262pub enum ClearScopeDto {
263    All,
264    Bucket { bucket_id: BucketId },
265    Op { op: String },
266}
267
268// ---------------------------------------------------------------------------
269// object_head
270// ---------------------------------------------------------------------------
271
272/// Fetch HEAD-only metadata for a single S3 object.
273///
274/// Lighter than `object_inspect` — only calls `HeadObject`, no tag or ACL
275/// fetches.  Used by the preview pane to obtain `contentLength` (for the size-
276/// limit check) and `contentType` (for MIME routing) without the overhead of
277/// the full inspector report.
278///
279/// # Validation gate
280///
281/// Refuses to serve data for profiles that have not been validated in the
282/// current session (AC-8 / round-1 finding #9).
283#[tauri::command]
284pub async fn object_head(
285    profile_id: ProfileId,
286    bucket: String,
287    key: String,
288    version_id: Option<String>,
289    store: State<'_, ProfileStoreHandle>,
290    pool: State<'_, S3ClientPoolHandle>,
291) -> Result<ObjectHead, AppError> {
292    // Validation gate.
293    let (validated, region_override, default_region) = {
294        let store_guard = store.inner.lock().await;
295        let profile = store_guard
296            .get(&profile_id)
297            .ok_or_else(|| AppError::NotFound {
298                resource: format!("profile:{}", profile_id.as_str()),
299            })?;
300        (
301            profile.validated_at.is_some(),
302            profile.compat_flags.region_override.clone(),
303            profile.default_region.clone(),
304        )
305    };
306
307    if !validated {
308        return Err(AppError::Auth {
309            reason: "Profile has not been validated".to_string(),
310        });
311    }
312
313    let region = region_override
314        .or(default_region)
315        .unwrap_or_else(|| "us-east-1".to_string());
316    let client = pool
317        .inner
318        .get_or_build(&profile_id, &region)
319        .await
320        .ok_or_else(|| AppError::Internal {
321            trace_id: uuid::Uuid::new_v4().to_string(),
322        })?;
323
324    head_object(&client, &bucket, &key, version_id).await
325}