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, ®ion)
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, ®ion)
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, ®ion)
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}