Skip to main content

brows3r_lib/s3/
object.rs

1//! Server-side copy, move, single-object delete, batch delete, and folder creation.
2//!
3//! # Responsibilities
4//!
5//! - [`CopyOptions`]                — directive flags for `CopyObject` (OCP-open).
6//! - [`CopyResult`]                 — IPC-safe result of `copy_object`.
7//! - [`CopyOutcome`]                — discriminated result of `copy_object_with_fallback`.
8//! - [`MoveResult`]                 — IPC-safe result of `move_object`.
9//! - [`DeletedObject`]              — one successfully deleted entry in a `DeleteReport`.
10//! - [`DeleteFailure`]              — one failed entry in a `DeleteReport`.
11//! - [`DeleteReport`]               — partial-failure report from `delete_objects_batch`.
12//! - [`copy_object`]                — wraps `CopyObject`; classifies SDK errors.
13//! - [`copy_object_with_fallback`]  — server-side copy with cross-account download+upload
14//!                                    fallback and threshold confirmation gate.
15//! - [`delete_single_object`]       — wraps `DeleteObject`; used by `move_object`.
16//! - [`move_object`]                — copy then delete (atomic from caller perspective).
17//! - [`delete_objects_batch`]       — batched delete via `DeleteObjects` (1 000-key chunks).
18//! - [`create_folder`]              — PUTs a zero-byte object with `key = prefix/`.
19//! - [`parent_prefix`]              — pure helper: `"a/b/c.txt"` → `"a/b/"`.
20//!
21//! # OCP
22//!
23//! - `CopyOptions` is open for new directives (checksum, version preservation)
24//!   via `#[serde(default)]` fields — existing callers are unaffected.
25//! - `CopyOutcome` is open for new variants (`AsyncTransferQueued`, …) — the
26//!   discriminator pattern keeps the frontend adaptable.
27//! - `move_object = copy_object + delete_single_object` keeps the primitive
28//!   surface minimal; task-36 (metadata setters) composes on the same primitives.
29//! - `DeleteReport` shape lets the frontend show "N deleted, M failed" without
30//!   all-or-nothing semantics. AC-4 partial-failure contract.
31//! - Error classification mirrors `list.rs` patterns so the frontend maps
32//!   `AppError.kind` uniformly.
33
34use aws_sdk_s3::{
35    error::SdkError,
36    primitives::ByteStream,
37    types::{Delete, ObjectIdentifier},
38    Client,
39};
40use serde::{Deserialize, Serialize};
41
42use crate::{
43    error::AppError,
44    ids::ObjectKey,
45    s3::cross_account::{ConfirmScope, ConfirmationCache},
46};
47
48// ---------------------------------------------------------------------------
49// CopyOptions
50// ---------------------------------------------------------------------------
51
52/// Metadata/tagging directive for `CopyObject`.
53///
54/// `Replace` instructs S3 to use the new values supplied in the request.
55/// `Copy` (default) preserves the source object's values.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "UPPERCASE")]
58pub enum MetadataDirective {
59    Copy,
60    Replace,
61}
62
63impl Default for MetadataDirective {
64    fn default() -> Self {
65        Self::Copy
66    }
67}
68
69/// Options that control the `CopyObject` API call.
70///
71/// OCP: new directives (checksum algorithm, version ID, object lock) can be
72/// added as `Option` fields with `#[serde(default)]` without breaking callers.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct CopyOptions {
76    /// Whether to copy or replace metadata.  Default: `Copy`.
77    #[serde(default)]
78    pub metadata_directive: MetadataDirective,
79    /// Whether to copy or replace tags.  Default: `Copy`.
80    #[serde(default)]
81    pub tagging_directive: MetadataDirective,
82    /// Override storage class on the destination.  `None` keeps the source class.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub storage_class: Option<String>,
85    /// Override ACL on the destination.  `None` keeps the source ACL.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub acl: Option<String>,
88    /// Override server-side encryption on the destination.  `None` keeps the
89    /// source encryption (or bucket default).
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub server_side_encryption: Option<String>,
92}
93
94impl Default for CopyOptions {
95    fn default() -> Self {
96        Self {
97            metadata_directive: MetadataDirective::Copy,
98            tagging_directive: MetadataDirective::Copy,
99            storage_class: None,
100            acl: None,
101            server_side_encryption: None,
102        }
103    }
104}
105
106// ---------------------------------------------------------------------------
107// CopyResult
108// ---------------------------------------------------------------------------
109
110/// ETag + last-modified from the `CopyObjectResult` response element.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct CopyObjectResultDetail {
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub etag: Option<String>,
116    /// Unix timestamp in milliseconds.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub last_modified: Option<i64>,
119}
120
121/// IPC-safe result returned from `copy_object` and forwarded to the frontend.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct CopyResult {
125    pub copy_object_result: CopyObjectResultDetail,
126}
127
128// ---------------------------------------------------------------------------
129// MoveResult
130// ---------------------------------------------------------------------------
131
132/// IPC-safe result returned from `move_object`.
133///
134/// Wraps the inner `CopyResult` so the frontend can distinguish move vs copy
135/// at the type level and inspect the same ETag/last-modified data.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct MoveResult {
139    pub copy_result: CopyResult,
140}
141
142// ---------------------------------------------------------------------------
143// CopyOutcome — discriminated result of copy_object_with_fallback
144// ---------------------------------------------------------------------------
145
146/// Result of `copy_object_with_fallback`.
147///
148/// OCP: new variants (`AsyncTransferQueued`, etc.) can be added without
149/// changing the `ServerSideCopy` or `FallbackUsed` arms.
150///
151/// Serialized with a `type` discriminator (`"ServerSideCopy"` or
152/// `"FallbackUsed"`) so the frontend can branch on the outcome without
153/// string-parsing the inner fields.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(tag = "type", rename_all = "camelCase")]
156#[serde(rename_all_fields = "camelCase")]
157pub enum CopyOutcome {
158    /// The S3 server-side `CopyObject` API succeeded.
159    ServerSideCopy { result: CopyResult },
160    /// Cross-account detected; download+upload fallback was used.
161    FallbackUsed {
162        /// Byte size of the source object that was transferred via fallback.
163        source_size: u64,
164        result: CopyResult,
165    },
166}
167
168// ---------------------------------------------------------------------------
169// parent_prefix — pure, reusable helper
170// ---------------------------------------------------------------------------
171
172/// Return the parent prefix of an S3 key.
173///
174/// The parent prefix is everything up to and including the last `/` before the
175/// final component (object name or sub-folder name).  When the key contains no
176/// `/` the root prefix `""` is returned.
177///
178/// # Examples
179///
180/// ```
181/// use brows3r_lib::s3::object::parent_prefix;
182/// assert_eq!(parent_prefix("a/b/c.txt"), "a/b/");
183/// assert_eq!(parent_prefix("file.txt"),  "");
184/// assert_eq!(parent_prefix("dir/"),      "");
185/// assert_eq!(parent_prefix("a/b/"),      "a/");
186/// assert_eq!(parent_prefix(""),          "");
187/// ```
188pub fn parent_prefix(key: &str) -> String {
189    // Strip a trailing slash before searching so `"a/b/"` → parent `"a/"`.
190    let stripped = key.strip_suffix('/').unwrap_or(key);
191    match stripped.rfind('/') {
192        Some(pos) => stripped[..=pos].to_string(),
193        None => String::new(),
194    }
195}
196
197// ---------------------------------------------------------------------------
198// classify_copy_sdk_error — shared SDK error → AppError mapper
199// ---------------------------------------------------------------------------
200
201fn classify_copy_sdk_error(
202    e: SdkError<aws_sdk_s3::operation::copy_object::CopyObjectError>,
203    op: &str,
204    resource: &str,
205) -> AppError {
206    if let SdkError::ServiceError(ref svc) = e {
207        let code = svc.err().meta().code().unwrap_or("");
208        match code {
209            "AccessDenied" | "InvalidClientTokenId" => {
210                return AppError::AccessDenied {
211                    op: op.to_string(),
212                    resource: resource.to_string(),
213                };
214            }
215            "NoSuchBucket" | "NoSuchKey" => {
216                return AppError::NotFound {
217                    resource: resource.to_string(),
218                };
219            }
220            "SlowDown" | "RequestThrottled" | "ThrottlingException" => {
221                return AppError::RateLimited {
222                    retry_after_ms: None,
223                };
224            }
225            _ => {}
226        }
227    }
228    AppError::Network {
229        source: e.to_string(),
230    }
231}
232
233fn classify_delete_sdk_error(
234    e: SdkError<aws_sdk_s3::operation::delete_object::DeleteObjectError>,
235    resource: &str,
236) -> AppError {
237    if let SdkError::ServiceError(ref svc) = e {
238        let code = svc.err().meta().code().unwrap_or("");
239        match code {
240            "AccessDenied" | "InvalidClientTokenId" => {
241                return AppError::AccessDenied {
242                    op: "s3:DeleteObject".to_string(),
243                    resource: resource.to_string(),
244                };
245            }
246            "NoSuchBucket" => {
247                return AppError::NotFound {
248                    resource: resource.to_string(),
249                };
250            }
251            _ => {}
252        }
253    }
254    AppError::Network {
255        source: e.to_string(),
256    }
257}
258
259fn classify_put_sdk_error(
260    e: SdkError<aws_sdk_s3::operation::put_object::PutObjectError>,
261    resource: &str,
262) -> AppError {
263    if let SdkError::ServiceError(ref svc) = e {
264        let code = svc.err().meta().code().unwrap_or("");
265        match code {
266            "AccessDenied" | "InvalidClientTokenId" => {
267                return AppError::AccessDenied {
268                    op: "s3:PutObject".to_string(),
269                    resource: resource.to_string(),
270                };
271            }
272            "NoSuchBucket" => {
273                return AppError::NotFound {
274                    resource: resource.to_string(),
275                };
276            }
277            _ => {}
278        }
279    }
280    AppError::Network {
281        source: e.to_string(),
282    }
283}
284
285// ---------------------------------------------------------------------------
286// copy_object
287// ---------------------------------------------------------------------------
288
289/// Copy `src_bucket/src_key` to `dest_bucket/dest_key` via server-side copy.
290///
291/// # Errors
292///
293/// - `AppError::AccessDenied` — `s3:CopyObject` permission denied.
294/// - `AppError::NotFound`     — source bucket or key does not exist.
295/// - `AppError::RateLimited`  — throttling response from AWS.
296/// - `AppError::Network`      — any other SDK or transport error.
297pub async fn copy_object(
298    client: &Client,
299    src_bucket: &str,
300    src_key: &str,
301    dest_bucket: &str,
302    dest_key: &str,
303    _options: &CopyOptions,
304) -> Result<CopyResult, AppError> {
305    // copy_source must be URL-encoded bucket/key.
306    let copy_source = format!("{src_bucket}/{src_key}");
307    let resource = format!("{src_bucket}/{src_key} → {dest_bucket}/{dest_key}");
308
309    let resp = client
310        .copy_object()
311        .copy_source(&copy_source)
312        .bucket(dest_bucket)
313        .key(dest_key)
314        .send()
315        .await
316        .map_err(|e| classify_copy_sdk_error(e, "s3:CopyObject", &resource))?;
317
318    let detail = resp
319        .copy_object_result()
320        .map(|r| {
321            let etag = r.e_tag().map(|s| s.trim_matches('"').to_string());
322            let last_modified = r
323                .last_modified()
324                .map(|dt| dt.secs() * 1000 + i64::from(dt.subsec_nanos()) / 1_000_000);
325            CopyObjectResultDetail {
326                etag,
327                last_modified,
328            }
329        })
330        .unwrap_or(CopyObjectResultDetail {
331            etag: None,
332            last_modified: None,
333        });
334
335    Ok(CopyResult {
336        copy_object_result: detail,
337    })
338}
339
340// ---------------------------------------------------------------------------
341// copy_object_with_fallback
342// ---------------------------------------------------------------------------
343
344fn classify_head_sdk_error(
345    e: SdkError<aws_sdk_s3::operation::head_object::HeadObjectError>,
346    resource: &str,
347) -> AppError {
348    if let SdkError::ServiceError(ref svc) = e {
349        let code = svc.err().meta().code().unwrap_or("");
350        match code {
351            "AccessDenied" | "InvalidClientTokenId" => {
352                return AppError::AccessDenied {
353                    op: "s3:HeadObject".to_string(),
354                    resource: resource.to_string(),
355                };
356            }
357            "NoSuchKey" | "404" => {
358                return AppError::NotFound {
359                    resource: resource.to_string(),
360                };
361            }
362            _ => {}
363        }
364        // HeadObject returns 404 as an HTTP status, not a service error code.
365        if svc.raw().status().as_u16() == 404 {
366            return AppError::NotFound {
367                resource: resource.to_string(),
368            };
369        }
370    }
371    AppError::Network {
372        source: e.to_string(),
373    }
374}
375
376fn classify_get_sdk_error(
377    e: SdkError<aws_sdk_s3::operation::get_object::GetObjectError>,
378    resource: &str,
379) -> AppError {
380    if let SdkError::ServiceError(ref svc) = e {
381        let code = svc.err().meta().code().unwrap_or("");
382        match code {
383            "AccessDenied" | "InvalidClientTokenId" => {
384                return AppError::AccessDenied {
385                    op: "s3:GetObject".to_string(),
386                    resource: resource.to_string(),
387                };
388            }
389            "NoSuchKey" | "NoSuchBucket" => {
390                return AppError::NotFound {
391                    resource: resource.to_string(),
392                };
393            }
394            _ => {}
395        }
396    }
397    AppError::Network {
398        source: e.to_string(),
399    }
400}
401
402/// Detect whether an `AppError` represents an access-denied condition from S3.
403///
404/// Used by `copy_object_with_fallback` to decide whether to attempt the
405/// download+upload fallback path.
406fn is_access_denied(e: &AppError) -> bool {
407    matches!(e, AppError::AccessDenied { .. })
408}
409
410/// Copy `src_bucket/src_key` to `dest_bucket/dest_key` with a cross-account fallback.
411///
412/// # Behaviour
413///
414/// 1. Attempt server-side `CopyObject`.
415/// 2. On `AccessDenied` (cross-account signal):
416///    a. HEAD the source to learn `content_length`.
417///    b. If `content_length <= fallback_threshold_bytes` → download + upload
418///       (fallback path).  Returns `CopyOutcome::FallbackUsed`.
419///    c. If `content_length > fallback_threshold_bytes` **and** `confirmed_token`
420///       is not a valid unconsumed token for this scope → return
421///       `AppError::Validation` asking for explicit confirmation.
422///    d. If `content_length > fallback_threshold_bytes` **and** `confirmed_token`
423///       is valid → fallback path proceeds.  Returns `CopyOutcome::FallbackUsed`.
424/// 3. On any other error → propagate as-is.
425///
426/// # Confirmation token
427///
428/// The token must be minted by `ConfirmationCache::mint` with a matching
429/// `ConfirmScope` and consumed here.  The frontend obtains a token via the
430/// `cross_account_confirm` command, then re-calls `object_copy` with the token.
431///
432/// # OCP
433///
434/// `fallback_threshold_bytes` is parameterised (driven by settings) so the
435/// default can change without touching this function.
436pub async fn copy_object_with_fallback(
437    client: &Client,
438    src_bucket: &str,
439    src_key: &str,
440    dest_bucket: &str,
441    dest_key: &str,
442    options: &CopyOptions,
443    fallback_threshold_bytes: u64,
444    confirmed_token: Option<String>,
445    confirmation_cache: &ConfirmationCache,
446    profile: &str,
447) -> Result<CopyOutcome, AppError> {
448    // ---- Step 1: server-side copy ----
449    match copy_object(client, src_bucket, src_key, dest_bucket, dest_key, options).await {
450        Ok(result) => return Ok(CopyOutcome::ServerSideCopy { result }),
451        Err(e) if !is_access_denied(&e) => return Err(e),
452        Err(_) => {
453            // Access denied — fall through to cross-account fallback logic.
454        }
455    }
456
457    // ---- Step 2: HEAD source to learn size ----
458    let resource = format!("{src_bucket}/{src_key}");
459    let head_resp = client
460        .head_object()
461        .bucket(src_bucket)
462        .key(src_key)
463        .send()
464        .await
465        .map_err(|e| classify_head_sdk_error(e, &resource))?;
466
467    let source_size = head_resp.content_length().map(|v| v as u64).unwrap_or(0);
468
469    // ---- Step 3: threshold gate ----
470    if source_size > fallback_threshold_bytes {
471        let scope = ConfirmScope {
472            profile: profile.to_string(),
473            source_bucket: src_bucket.to_string(),
474            source_key: src_key.to_string(),
475            dest_bucket: dest_bucket.to_string(),
476            dest_key: dest_key.to_string(),
477        };
478
479        let token_valid = match &confirmed_token {
480            Some(t) => confirmation_cache.consume(t, &scope),
481            None => false,
482        };
483
484        if !token_valid {
485            return Err(AppError::Validation {
486                field: "confirmed_token".to_string(),
487                hint: "Cross-account copy of large file requires explicit confirmation token"
488                    .to_string(),
489            });
490        }
491    }
492
493    // ---- Step 4: download + upload fallback ----
494    let fallback_resource = format!("{src_bucket}/{src_key}");
495    let get_resp = client
496        .get_object()
497        .bucket(src_bucket)
498        .key(src_key)
499        .send()
500        .await
501        .map_err(|e| classify_get_sdk_error(e, &fallback_resource))?;
502
503    let body = get_resp
504        .body
505        .collect()
506        .await
507        .map_err(|e| AppError::Network {
508            source: format!("get_object body read failed: {e}"),
509        })?;
510    let bytes = body.into_bytes();
511
512    let dest_resource = format!("{dest_bucket}/{dest_key}");
513    let put_resp = client
514        .put_object()
515        .bucket(dest_bucket)
516        .key(dest_key)
517        .body(ByteStream::from(bytes))
518        .send()
519        .await
520        .map_err(|e| classify_put_sdk_error(e, &dest_resource))?;
521
522    let etag = put_resp.e_tag().map(|s| s.trim_matches('"').to_string());
523    let result = CopyResult {
524        copy_object_result: CopyObjectResultDetail {
525            etag,
526            last_modified: None,
527        },
528    };
529
530    Ok(CopyOutcome::FallbackUsed {
531        source_size,
532        result,
533    })
534}
535
536// ---------------------------------------------------------------------------
537// delete_single_object
538// ---------------------------------------------------------------------------
539
540/// Delete a single object from `bucket` at `key`.
541///
542/// Used internally by `move_object` after a successful copy.  Exposed `pub`
543/// so task-35 (delete batch) can call it for single-item fallback paths.
544///
545/// # Errors
546///
547/// - `AppError::AccessDenied` — `s3:DeleteObject` permission denied.
548/// - `AppError::NotFound`     — bucket does not exist.
549/// - `AppError::Network`      — any other SDK or transport error.
550///
551/// Note: S3 `DeleteObject` on a non-existent key is idempotent and returns
552/// 204; this function therefore returns `Ok(())` in that case.
553pub async fn delete_single_object(
554    client: &Client,
555    bucket: &str,
556    key: &str,
557) -> Result<(), AppError> {
558    let resource = format!("{bucket}/{key}");
559
560    client
561        .delete_object()
562        .bucket(bucket)
563        .key(key)
564        .send()
565        .await
566        .map_err(|e| classify_delete_sdk_error(e, &resource))?;
567
568    Ok(())
569}
570
571// ---------------------------------------------------------------------------
572// move_object
573// ---------------------------------------------------------------------------
574
575/// Move `src_bucket/src_key` to `dest_bucket/dest_key`.
576///
577/// Implemented as copy then delete.  If the copy succeeds but the delete
578/// fails, returns `AppError::Internal` with a notice that the copy already
579/// landed and cleanup of the source is needed.
580///
581/// # Atomicity
582///
583/// S3 does not provide native atomic rename.  The copy + delete sequence is
584/// atomic *from the caller's perspective* only in the happy path: the source
585/// is visible until the delete completes.  Callers that require strict
586/// isolation must hold a lock on the source prefix.
587pub async fn move_object(
588    client: &Client,
589    src_bucket: &str,
590    src_key: &str,
591    dest_bucket: &str,
592    dest_key: &str,
593    options: &CopyOptions,
594) -> Result<MoveResult, AppError> {
595    let copy_result =
596        copy_object(client, src_bucket, src_key, dest_bucket, dest_key, options).await?;
597
598    // Copy succeeded.  Now delete the source.
599    if let Err(e) = delete_single_object(client, src_bucket, src_key).await {
600        // Copy landed but source delete failed.  Propagate as Internal so
601        // the caller can surface a structured notice to the UI:
602        // "Move partially completed — source may need manual cleanup."
603        // Log context is available via `trace_id` in the diagnostics bundle.
604        tracing_or_eprintln(&format!(
605            "move_object: copy OK but delete failed for {src_bucket}/{src_key}: {e}"
606        ));
607        return Err(AppError::Internal {
608            trace_id: format!("move_partial_copy_ok_delete_failed::{src_bucket}/{src_key}"),
609        });
610    }
611
612    Ok(MoveResult { copy_result })
613}
614
615/// Emit a structured warning without pulling in the full tracing dependency.
616/// Replace with `tracing::warn!` once that crate is wired up.
617#[inline]
618fn tracing_or_eprintln(msg: &str) {
619    eprintln!("WARN {msg}");
620}
621
622// ---------------------------------------------------------------------------
623// create_folder
624// ---------------------------------------------------------------------------
625
626/// Create a virtual folder placeholder at `bucket/prefix/`.
627///
628/// Issues a zero-byte `PutObject` with `key = "{prefix}/"`.  If the object
629/// already exists S3 overwrites it silently — the operation is idempotent.
630///
631/// # Errors
632///
633/// - `AppError::AccessDenied` — `s3:PutObject` permission denied.
634/// - `AppError::NotFound`     — bucket does not exist.
635/// - `AppError::Network`      — any other SDK or transport error.
636pub async fn create_folder(client: &Client, bucket: &str, prefix: &str) -> Result<(), AppError> {
637    // Ensure exactly one trailing slash.
638    let key = if prefix.ends_with('/') {
639        prefix.to_string()
640    } else {
641        format!("{prefix}/")
642    };
643    let resource = format!("{bucket}/{key}");
644
645    client
646        .put_object()
647        .bucket(bucket)
648        .key(&key)
649        .body(ByteStream::from_static(b""))
650        .send()
651        .await
652        .map_err(|e| classify_put_sdk_error(e, &resource))?;
653
654    Ok(())
655}
656
657// ---------------------------------------------------------------------------
658// DeleteReport types — partial-failure shape (AC-4)
659// ---------------------------------------------------------------------------
660
661/// One entry that was successfully deleted in a `delete_objects_batch` call.
662///
663/// OCP: `bypass_governance_retention: bool` can be added later for object-lock
664/// support without changing this struct's required fields.
665#[derive(Debug, Clone, Serialize, Deserialize)]
666#[serde(rename_all = "camelCase")]
667pub struct DeletedObject {
668    /// The S3 key that was deleted.
669    pub key: String,
670    /// Version ID of the deleted version, if the bucket has versioning enabled.
671    #[serde(skip_serializing_if = "Option::is_none")]
672    pub version_id: Option<String>,
673    /// `true` when a delete marker was inserted (versioned bucket + no version_id supplied).
674    #[serde(skip_serializing_if = "Option::is_none")]
675    pub delete_marker: Option<bool>,
676    /// Version ID of the newly created delete marker, when applicable.
677    #[serde(skip_serializing_if = "Option::is_none")]
678    pub delete_marker_version_id: Option<String>,
679}
680
681/// One entry that failed to delete in a `delete_objects_batch` call.
682#[derive(Debug, Clone, Serialize, Deserialize)]
683#[serde(rename_all = "camelCase")]
684pub struct DeleteFailure {
685    /// The S3 key that could not be deleted.
686    pub key: String,
687    /// Version ID that could not be deleted, when applicable.
688    #[serde(skip_serializing_if = "Option::is_none")]
689    pub version_id: Option<String>,
690    /// S3 error code (e.g. `"AccessDenied"`, `"NoSuchVersion"`).
691    pub code: String,
692    /// S3 error message for this specific key.
693    pub message: String,
694}
695
696/// Result of `delete_objects_batch`.
697///
698/// Both `deleted` and `failed` may be non-empty in the same response —
699/// callers must NOT treat a non-empty `failed` as a hard error.
700/// The caller (command layer) decides how to surface partial failures.
701#[derive(Debug, Clone, Serialize, Deserialize)]
702#[serde(rename_all = "camelCase")]
703pub struct DeleteReport {
704    /// Keys that were successfully deleted (or had a delete marker created).
705    pub deleted: Vec<DeletedObject>,
706    /// Keys that the API could not delete.
707    pub failed: Vec<DeleteFailure>,
708}
709
710// ---------------------------------------------------------------------------
711// classify_delete_objects_sdk_error — whole-batch failure mapper
712// ---------------------------------------------------------------------------
713
714fn classify_delete_objects_sdk_error(
715    e: SdkError<aws_sdk_s3::operation::delete_objects::DeleteObjectsError>,
716    bucket: &str,
717) -> AppError {
718    if let SdkError::ServiceError(ref svc) = e {
719        let code = svc.err().meta().code().unwrap_or("");
720        match code {
721            "AccessDenied" | "InvalidClientTokenId" => {
722                return AppError::AccessDenied {
723                    op: "s3:DeleteObjects".to_string(),
724                    resource: bucket.to_string(),
725                };
726            }
727            "NoSuchBucket" => {
728                return AppError::NotFound {
729                    resource: bucket.to_string(),
730                };
731            }
732            "SlowDown" | "RequestThrottled" | "ThrottlingException" => {
733                return AppError::RateLimited {
734                    retry_after_ms: None,
735                };
736            }
737            _ => {}
738        }
739    }
740    AppError::Network {
741        source: e.to_string(),
742    }
743}
744
745// ---------------------------------------------------------------------------
746// delete_objects_batch
747// ---------------------------------------------------------------------------
748
749/// Maximum number of keys per `DeleteObjects` API call (AWS hard limit).
750const DELETE_BATCH_SIZE: usize = 1_000;
751
752/// Delete a batch of objects from `bucket`.
753///
754/// Each entry in `keys` is `(key, version_id?)`.  Passing a `version_id`
755/// removes that specific version; passing `None` inserts a delete marker on
756/// versioned buckets and permanently deletes on non-versioned buckets.
757///
758/// Internally chunks `keys` into groups of at most 1 000 and issues one
759/// `DeleteObjects` SDK call per chunk.  All chunk results are merged into a
760/// single `DeleteReport`.
761///
762/// # Partial failure (AC-4)
763///
764/// S3 can report per-key errors within a successful HTTP 200 response.  These
765/// are collected into `DeleteReport.failed` rather than returning `Err`.
766/// **The whole batch is NOT aborted on a per-key error.**
767///
768/// # Errors
769///
770/// Returns `Err(AppError)` only when the SDK call itself fails (e.g. network
771/// error, bucket-level `AccessDenied`).  Individual key errors are in
772/// `DeleteReport.failed`.
773pub async fn delete_objects_batch(
774    client: &Client,
775    bucket: &str,
776    keys: Vec<(ObjectKey, Option<String>)>,
777) -> Result<DeleteReport, AppError> {
778    let mut report = DeleteReport {
779        deleted: Vec::new(),
780        failed: Vec::new(),
781    };
782
783    // Process keys in chunks of DELETE_BATCH_SIZE.
784    for chunk in keys.chunks(DELETE_BATCH_SIZE) {
785        // Build the list of ObjectIdentifier for this chunk.
786        let mut identifiers: Vec<ObjectIdentifier> = Vec::with_capacity(chunk.len());
787        for (key, version_id) in chunk {
788            let mut builder = ObjectIdentifier::builder().key(key.as_str());
789            if let Some(vid) = version_id {
790                builder = builder.version_id(vid);
791            }
792            let ident = builder.build().map_err(|e| AppError::Internal {
793                trace_id: format!("object_identifier_build_failed: {e}"),
794            })?;
795            identifiers.push(ident);
796        }
797
798        let delete = Delete::builder()
799            .set_objects(Some(identifiers))
800            .build()
801            .map_err(|e| AppError::Internal {
802                trace_id: format!("delete_builder_failed: {e}"),
803            })?;
804
805        let resp = client
806            .delete_objects()
807            .bucket(bucket)
808            .delete(delete)
809            .send()
810            .await
811            .map_err(|e| classify_delete_objects_sdk_error(e, bucket))?;
812
813        // Map per-key successes.
814        for d in resp.deleted() {
815            report.deleted.push(DeletedObject {
816                key: d.key().unwrap_or("").to_string(),
817                version_id: d.version_id().map(|s| s.to_string()),
818                delete_marker: d.delete_marker(),
819                delete_marker_version_id: d.delete_marker_version_id().map(|s| s.to_string()),
820            });
821        }
822
823        // Map per-key failures (partial-failure AC-4).
824        for err in resp.errors() {
825            report.failed.push(DeleteFailure {
826                key: err.key().unwrap_or("").to_string(),
827                version_id: err.version_id().map(|s| s.to_string()),
828                code: err.code().unwrap_or("UnknownError").to_string(),
829                message: err.message().unwrap_or("").to_string(),
830            });
831        }
832    }
833
834    Ok(report)
835}
836
837// ---------------------------------------------------------------------------
838// set_object_storage_class
839// ---------------------------------------------------------------------------
840
841/// Change the storage class of `bucket/key` via a server-side self-copy.
842///
843/// S3 does not expose a dedicated "change storage class" API.  The only
844/// supported approach is a `CopyObject` from `bucket/key` back to itself
845/// with `StorageClass` set to the new value and `MetadataDirective::Copy`
846/// so the existing metadata and tags are preserved.
847///
848/// # Errors
849///
850/// - `AppError::Validation`   — `new_class` is not a recognised S3 storage
851///                              class string (validation happens at the SDK
852///                              builder level; unrecognised values pass through
853///                              to S3 which returns `InvalidStorageClass`).
854/// - `AppError::AccessDenied` — `s3:CopyObject` permission denied.
855/// - `AppError::NotFound`     — bucket or key does not exist.
856/// - `AppError::RateLimited`  — throttling response from AWS.
857/// - `AppError::Network`      — any other SDK or transport error.
858///
859/// # Note: not optimistic (Decision D2)
860///
861/// Storage class change is a diff-gated mutation and is intentionally excluded
862/// from `EXCLUDED_FROM_OPTIMISM` in `src/query/optimistic.ts`.  The test
863/// `storage_class_change_does_not_use_optimistic_path` asserts this invariant.
864pub async fn set_object_storage_class(
865    client: &Client,
866    bucket: &str,
867    key: &str,
868    new_class: String,
869) -> Result<crate::s3::metadata::PutResult, AppError> {
870    use aws_sdk_s3::types::StorageClass;
871
872    let storage_class = StorageClass::from(new_class.as_str());
873
874    let copy_source = format!("{bucket}/{key}");
875    let resource = format!("{bucket}/{key}");
876
877    let resp = client
878        .copy_object()
879        .copy_source(&copy_source)
880        .bucket(bucket)
881        .key(key)
882        .storage_class(storage_class)
883        .metadata_directive(aws_sdk_s3::types::MetadataDirective::Copy)
884        .send()
885        .await
886        .map_err(|e| {
887            classify_copy_sdk_error(e, "s3:CopyObject (storage class change)", &resource)
888        })?;
889
890    let detail = resp.copy_object_result().map(|r| {
891        let etag = r.e_tag().map(|s| s.trim_matches('"').to_string());
892        let last_modified = r
893            .last_modified()
894            .map(|dt| dt.secs() * 1000 + i64::from(dt.subsec_nanos()) / 1_000_000);
895        (etag, last_modified)
896    });
897
898    Ok(crate::s3::metadata::PutResult {
899        etag: detail.as_ref().and_then(|(e, _)| e.clone()),
900        last_modified: detail.and_then(|(_, lm)| lm),
901        version_id: resp.version_id().map(|s| s.to_string()),
902    })
903}
904
905// ---------------------------------------------------------------------------
906// TextPayload — result of get_object_text
907// ---------------------------------------------------------------------------
908
909/// Text content fetched from S3, decoded as UTF-8.
910///
911/// The body is decoded with lossy UTF-8 — invalid bytes are replaced with
912/// U+FFFD so the result is always a valid Rust `String` / JSON string.
913///
914/// OCP: `content_type` and `version_id` can be added as optional fields later
915/// without breaking existing callers that only read `body` and `truncated`.
916#[derive(Debug, Clone, Serialize, Deserialize)]
917#[serde(rename_all = "camelCase")]
918pub struct TextPayload {
919    /// UTF-8 body, possibly lossy-decoded.  Truncated at `max_bytes` when the
920    /// object is larger than the requested limit.
921    pub body: String,
922    /// Total object size in bytes as reported by S3 `Content-Length`.
923    /// May be zero when S3 does not return a content-length header.
924    pub content_length: u64,
925    /// HTTP ETag string from S3 (surrounding quotes stripped).
926    #[serde(skip_serializing_if = "Option::is_none")]
927    pub etag: Option<String>,
928    /// `true` when the returned body was truncated at `max_bytes`.
929    pub truncated: bool,
930}
931
932// ---------------------------------------------------------------------------
933// get_object_text
934// ---------------------------------------------------------------------------
935
936/// Default maximum bytes to read for text preview.
937pub const DEFAULT_TEXT_MAX_BYTES: u64 = 1_024 * 1_024; // 1 MiB
938
939/// Fetch the first `max_bytes` bytes of `bucket/key` as a UTF-8 string.
940///
941/// Uses a `Range: bytes=0-<max_bytes-1>` request so the backend never
942/// buffers more bytes than needed for the preview.
943///
944/// # Errors
945///
946/// - `AppError::AccessDenied` — `s3:GetObject` permission denied.
947/// - `AppError::NotFound`     — bucket or key does not exist.
948/// - `AppError::Network`      — any other SDK or transport error.
949pub async fn get_object_text(
950    client: &Client,
951    bucket: &str,
952    key: &str,
953    max_bytes: u64,
954) -> Result<TextPayload, AppError> {
955    let resource = format!("{bucket}/{key}");
956
957    // Use a range request to avoid downloading the full object when it is
958    // larger than the preview limit.  S3 returns HTTP 206 (Partial Content)
959    // and the actual number of bytes transferred is ≤ max_bytes.
960    let range_header = format!("bytes=0-{}", max_bytes.saturating_sub(1));
961
962    let resp = client
963        .get_object()
964        .bucket(bucket)
965        .key(key)
966        .range(range_header)
967        .send()
968        .await
969        .map_err(|e| classify_get_sdk_error(e, &resource))?;
970
971    // Total object size (before range) comes from Content-Range or
972    // Content-Length.  Fall back to 0 when absent.
973    let content_length = resp
974        .content_range()
975        .and_then(|cr| {
976            // Content-Range: bytes 0-N/TOTAL → parse TOTAL
977            cr.rsplit('/').next().and_then(|s| s.parse::<u64>().ok())
978        })
979        .unwrap_or_else(|| resp.content_length().unwrap_or(0) as u64);
980
981    let etag = resp.e_tag().map(|s| s.trim_matches('"').to_string());
982
983    let body_bytes = resp
984        .body
985        .collect()
986        .await
987        .map_err(|e| AppError::Network {
988            source: format!("get_object body read failed: {e}"),
989        })?
990        .into_bytes();
991
992    let bytes_read = body_bytes.len() as u64;
993
994    // Lossy UTF-8 decode: invalid bytes → U+FFFD.
995    let body = String::from_utf8_lossy(&body_bytes).into_owned();
996
997    // We consider the body truncated if the total object size exceeds the
998    // limit AND we received fewer bytes than the full object.  When the
999    // total size equals the bytes_read the full object fit within the limit.
1000    let truncated = content_length > bytes_read && bytes_read >= max_bytes;
1001
1002    Ok(TextPayload {
1003        body,
1004        content_length,
1005        etag,
1006        truncated,
1007    })
1008}
1009
1010// ---------------------------------------------------------------------------
1011// BytesPayload — result of get_object_bytes
1012// ---------------------------------------------------------------------------
1013
1014/// Raw bytes fetched from S3, base64-encoded for safe IPC transport.
1015///
1016/// The frontend decodes with `atob` or `Uint8Array.from(atob(...), c => c.charCodeAt(0))`.
1017///
1018/// OCP: `content_type` can be added as an optional field later without breaking
1019/// existing callers.
1020#[derive(Debug, Clone, Serialize, Deserialize)]
1021#[serde(rename_all = "camelCase")]
1022pub struct BytesPayload {
1023    /// Base64-encoded raw bytes, at most `max_bytes` in length.
1024    pub body: String,
1025    /// Total object size in bytes as reported by S3 `Content-Length`.
1026    pub content_length: u64,
1027    /// HTTP ETag string from S3 (surrounding quotes stripped).
1028    #[serde(skip_serializing_if = "Option::is_none")]
1029    pub etag: Option<String>,
1030    /// `true` when the returned body was truncated at `max_bytes`.
1031    pub truncated: bool,
1032}
1033
1034/// Default maximum bytes to read for binary preview.
1035pub const DEFAULT_BYTES_MAX_BYTES: u64 = 1_024 * 1_024; // 1 MiB
1036
1037// ---------------------------------------------------------------------------
1038// get_object_bytes
1039// ---------------------------------------------------------------------------
1040
1041/// Fetch the first `max_bytes` bytes of `bucket/key` as base64-encoded binary.
1042///
1043/// Uses a `Range: bytes=0-<max_bytes-1>` request so large objects are not
1044/// fully downloaded.  Returns a `BytesPayload` with the base64-encoded body,
1045/// total content length, ETag, and a `truncated` flag.
1046///
1047/// # Errors
1048///
1049/// - `AppError::AccessDenied` — `s3:GetObject` permission denied.
1050/// - `AppError::NotFound`     — bucket or key does not exist.
1051/// - `AppError::Network`      — any other SDK or transport error.
1052pub async fn get_object_bytes(
1053    client: &Client,
1054    bucket: &str,
1055    key: &str,
1056    max_bytes: u64,
1057) -> Result<BytesPayload, AppError> {
1058    let resource = format!("{bucket}/{key}");
1059
1060    // Range request to avoid downloading the full object.
1061    let range_header = format!("bytes=0-{}", max_bytes.saturating_sub(1));
1062
1063    let resp = client
1064        .get_object()
1065        .bucket(bucket)
1066        .key(key)
1067        .range(range_header)
1068        .send()
1069        .await
1070        .map_err(|e| classify_get_sdk_error(e, &resource))?;
1071
1072    // Total object size (before range) from Content-Range or Content-Length.
1073    let content_length = resp
1074        .content_range()
1075        .and_then(|cr| cr.rsplit('/').next().and_then(|s| s.parse::<u64>().ok()))
1076        .unwrap_or_else(|| resp.content_length().unwrap_or(0) as u64);
1077
1078    let etag = resp.e_tag().map(|s| s.trim_matches('"').to_string());
1079
1080    let body_bytes = resp
1081        .body
1082        .collect()
1083        .await
1084        .map_err(|e| AppError::Network {
1085            source: format!("get_object body read failed: {e}"),
1086        })?
1087        .into_bytes();
1088
1089    let bytes_read = body_bytes.len() as u64;
1090    let truncated = content_length > bytes_read && bytes_read >= max_bytes;
1091
1092    // Base64-encode using the standard alphabet (no line wrapping).
1093    use base64::Engine as _;
1094    let body = base64::engine::general_purpose::STANDARD.encode(&body_bytes);
1095
1096    Ok(BytesPayload {
1097        body,
1098        content_length,
1099        etag,
1100        truncated,
1101    })
1102}
1103
1104// ---------------------------------------------------------------------------
1105// put_object_text
1106// ---------------------------------------------------------------------------
1107
1108/// Write `body` to `bucket/key` with an optional ETag precondition.
1109///
1110/// Uses `PutObject` with `Content-Type: text/plain; charset=utf-8`.  When
1111/// `if_match_etag` is supplied the `If-Match` header is set; S3 returns 412
1112/// (Precondition Failed) when the live ETag does not match — mapped to
1113/// `AppError::Conflict { etag_expected, etag_actual: None }`.
1114///
1115/// # OCP
1116///
1117/// `if_match_etag = None` is the "save anyway" path — identical to a fresh
1118/// unconditional put.
1119///
1120/// # Errors
1121///
1122/// - `AppError::Conflict`     — ETag precondition failed (412).
1123/// - `AppError::AccessDenied` — `s3:PutObject` permission denied.
1124/// - `AppError::NotFound`     — bucket does not exist.
1125/// - `AppError::Network`      — any other SDK or transport error.
1126pub async fn put_object_text(
1127    client: &Client,
1128    bucket: &str,
1129    key: &str,
1130    body: String,
1131    if_match_etag: Option<String>,
1132) -> Result<crate::s3::metadata::PutResult, AppError> {
1133    let resource = format!("{bucket}/{key}");
1134
1135    let bytes: Vec<u8> = body.into_bytes();
1136    let stream = ByteStream::from(bytes);
1137
1138    let mut req = client
1139        .put_object()
1140        .bucket(bucket)
1141        .key(key)
1142        .content_type("text/plain; charset=utf-8")
1143        .body(stream);
1144
1145    if let Some(ref etag) = if_match_etag {
1146        req = req.if_match(etag);
1147    }
1148
1149    let resp = req.send().await.map_err(|e| {
1150        // Check for 412 Precondition Failed (ETag mismatch).
1151        if let SdkError::ServiceError(ref svc) = e {
1152            let status = svc.raw().status().as_u16();
1153            if status == 412 {
1154                return AppError::Conflict {
1155                    etag_expected: if_match_etag
1156                        .clone()
1157                        .unwrap_or_else(|| "(unknown)".to_string()),
1158                    etag_actual: None,
1159                };
1160            }
1161            let code = svc.err().meta().code().unwrap_or("");
1162            match code {
1163                "AccessDenied" | "InvalidClientTokenId" => {
1164                    return AppError::AccessDenied {
1165                        op: "s3:PutObject".to_string(),
1166                        resource: resource.clone(),
1167                    };
1168                }
1169                "NoSuchBucket" => {
1170                    return AppError::NotFound {
1171                        resource: resource.clone(),
1172                    };
1173                }
1174                _ => {}
1175            }
1176        }
1177        AppError::Network {
1178            source: e.to_string(),
1179        }
1180    })?;
1181
1182    let etag = resp.e_tag().map(|s| s.trim_matches('"').to_string());
1183    let version_id = resp.version_id().map(|s| s.to_string());
1184
1185    Ok(crate::s3::metadata::PutResult {
1186        etag,
1187        last_modified: None,
1188        version_id,
1189    })
1190}
1191
1192// ---------------------------------------------------------------------------
1193// Tests
1194// ---------------------------------------------------------------------------
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199
1200    // -----------------------------------------------------------------------
1201    // parent_prefix — unit tests covering all edge cases
1202    // -----------------------------------------------------------------------
1203
1204    #[test]
1205    fn parent_prefix_nested_key() {
1206        assert_eq!(parent_prefix("a/b/c.txt"), "a/b/");
1207    }
1208
1209    #[test]
1210    fn parent_prefix_root_key() {
1211        assert_eq!(parent_prefix("file.txt"), "");
1212    }
1213
1214    #[test]
1215    fn parent_prefix_empty_string() {
1216        assert_eq!(parent_prefix(""), "");
1217    }
1218
1219    #[test]
1220    fn parent_prefix_trailing_slash_folder() {
1221        // "dir/" → parent is root ""
1222        assert_eq!(parent_prefix("dir/"), "");
1223    }
1224
1225    #[test]
1226    fn parent_prefix_nested_folder_trailing_slash() {
1227        // "a/b/" → parent is "a/"
1228        assert_eq!(parent_prefix("a/b/"), "a/");
1229    }
1230
1231    #[test]
1232    fn parent_prefix_deeply_nested() {
1233        assert_eq!(parent_prefix("a/b/c/d/e.txt"), "a/b/c/d/");
1234    }
1235
1236    #[test]
1237    fn parent_prefix_single_level_folder() {
1238        assert_eq!(parent_prefix("photos/"), "");
1239    }
1240
1241    #[test]
1242    fn parent_prefix_single_slash_only() {
1243        // "/" strips to "", rfind finds nothing → ""
1244        assert_eq!(parent_prefix("/"), "");
1245    }
1246
1247    // -----------------------------------------------------------------------
1248    // CopyOptions defaults
1249    // -----------------------------------------------------------------------
1250
1251    #[test]
1252    fn copy_options_default_directive_is_copy() {
1253        let opts = CopyOptions::default();
1254        assert_eq!(opts.metadata_directive, MetadataDirective::Copy);
1255        assert_eq!(opts.tagging_directive, MetadataDirective::Copy);
1256        assert!(opts.storage_class.is_none());
1257        assert!(opts.acl.is_none());
1258        assert!(opts.server_side_encryption.is_none());
1259    }
1260
1261    // -----------------------------------------------------------------------
1262    // CopyOptions serialisation (camelCase + skip None)
1263    // -----------------------------------------------------------------------
1264
1265    #[test]
1266    fn copy_options_serialises_minimal() {
1267        let opts = CopyOptions::default();
1268        let v = serde_json::to_value(&opts).unwrap();
1269        assert_eq!(v["metadataDirective"], "COPY");
1270        assert_eq!(v["taggingDirective"], "COPY");
1271        assert!(!v.as_object().unwrap().contains_key("storageClass"));
1272        assert!(!v.as_object().unwrap().contains_key("acl"));
1273        assert!(!v.as_object().unwrap().contains_key("serverSideEncryption"));
1274    }
1275
1276    #[test]
1277    fn copy_options_serialises_replace_with_overrides() {
1278        let opts = CopyOptions {
1279            metadata_directive: MetadataDirective::Replace,
1280            tagging_directive: MetadataDirective::Replace,
1281            storage_class: Some("GLACIER".to_string()),
1282            acl: Some("private".to_string()),
1283            server_side_encryption: Some("AES256".to_string()),
1284        };
1285        let v = serde_json::to_value(&opts).unwrap();
1286        assert_eq!(v["metadataDirective"], "REPLACE");
1287        assert_eq!(v["taggingDirective"], "REPLACE");
1288        assert_eq!(v["storageClass"], "GLACIER");
1289        assert_eq!(v["acl"], "private");
1290        assert_eq!(v["serverSideEncryption"], "AES256");
1291    }
1292
1293    // -----------------------------------------------------------------------
1294    // CopyResult serialisation
1295    // -----------------------------------------------------------------------
1296
1297    #[test]
1298    fn copy_result_serialises_camel_case() {
1299        let result = CopyResult {
1300            copy_object_result: CopyObjectResultDetail {
1301                etag: Some("abc123".to_string()),
1302                last_modified: Some(1_700_000_000_000),
1303            },
1304        };
1305        let v = serde_json::to_value(&result).unwrap();
1306        assert_eq!(v["copyObjectResult"]["etag"], "abc123");
1307        assert_eq!(v["copyObjectResult"]["lastModified"], 1_700_000_000_000_i64);
1308    }
1309
1310    #[test]
1311    fn copy_result_skips_none_fields() {
1312        let result = CopyResult {
1313            copy_object_result: CopyObjectResultDetail {
1314                etag: None,
1315                last_modified: None,
1316            },
1317        };
1318        let v = serde_json::to_value(&result).unwrap();
1319        let inner = &v["copyObjectResult"];
1320        assert!(!inner.as_object().unwrap().contains_key("etag"));
1321        assert!(!inner.as_object().unwrap().contains_key("lastModified"));
1322    }
1323
1324    // -----------------------------------------------------------------------
1325    // MoveResult serialisation
1326    // -----------------------------------------------------------------------
1327
1328    #[test]
1329    fn move_result_wraps_copy_result() {
1330        let result = MoveResult {
1331            copy_result: CopyResult {
1332                copy_object_result: CopyObjectResultDetail {
1333                    etag: Some("def456".to_string()),
1334                    last_modified: None,
1335                },
1336            },
1337        };
1338        let v = serde_json::to_value(&result).unwrap();
1339        assert_eq!(v["copyResult"]["copyObjectResult"]["etag"], "def456");
1340    }
1341
1342    // -----------------------------------------------------------------------
1343    // DeleteReport — serialisation + field visibility
1344    // -----------------------------------------------------------------------
1345
1346    #[test]
1347    fn deleted_object_serialises_camel_case() {
1348        let d = DeletedObject {
1349            key: "photos/img.jpg".to_string(),
1350            version_id: Some("vid-001".to_string()),
1351            delete_marker: Some(true),
1352            delete_marker_version_id: Some("dmvid-001".to_string()),
1353        };
1354        let v = serde_json::to_value(&d).unwrap();
1355        assert_eq!(v["key"], "photos/img.jpg");
1356        assert_eq!(v["versionId"], "vid-001");
1357        assert_eq!(v["deleteMarker"], true);
1358        assert_eq!(v["deleteMarkerVersionId"], "dmvid-001");
1359    }
1360
1361    #[test]
1362    fn deleted_object_skips_none_fields() {
1363        let d = DeletedObject {
1364            key: "file.txt".to_string(),
1365            version_id: None,
1366            delete_marker: None,
1367            delete_marker_version_id: None,
1368        };
1369        let v = serde_json::to_value(&d).unwrap();
1370        assert_eq!(v["key"], "file.txt");
1371        assert!(!v.as_object().unwrap().contains_key("versionId"));
1372        assert!(!v.as_object().unwrap().contains_key("deleteMarker"));
1373        assert!(!v.as_object().unwrap().contains_key("deleteMarkerVersionId"));
1374    }
1375
1376    #[test]
1377    fn delete_failure_serialises_camel_case() {
1378        let f = DeleteFailure {
1379            key: "locked/file.txt".to_string(),
1380            version_id: Some("vid-002".to_string()),
1381            code: "AccessDenied".to_string(),
1382            message: "Access Denied".to_string(),
1383        };
1384        let v = serde_json::to_value(&f).unwrap();
1385        assert_eq!(v["key"], "locked/file.txt");
1386        assert_eq!(v["versionId"], "vid-002");
1387        assert_eq!(v["code"], "AccessDenied");
1388        assert_eq!(v["message"], "Access Denied");
1389    }
1390
1391    #[test]
1392    fn delete_failure_skips_none_version_id() {
1393        let f = DeleteFailure {
1394            key: "locked/file.txt".to_string(),
1395            version_id: None,
1396            code: "NoSuchVersion".to_string(),
1397            message: "no such version".to_string(),
1398        };
1399        let v = serde_json::to_value(&f).unwrap();
1400        assert!(!v.as_object().unwrap().contains_key("versionId"));
1401    }
1402
1403    #[test]
1404    fn delete_report_contains_both_arrays() {
1405        let report = DeleteReport {
1406            deleted: vec![DeletedObject {
1407                key: "ok/file.txt".to_string(),
1408                version_id: None,
1409                delete_marker: None,
1410                delete_marker_version_id: None,
1411            }],
1412            failed: vec![DeleteFailure {
1413                key: "err/file.txt".to_string(),
1414                version_id: None,
1415                code: "AccessDenied".to_string(),
1416                message: "Access Denied".to_string(),
1417            }],
1418        };
1419        let v = serde_json::to_value(&report).unwrap();
1420        assert_eq!(v["deleted"].as_array().unwrap().len(), 1);
1421        assert_eq!(v["failed"].as_array().unwrap().len(), 1);
1422        assert_eq!(v["deleted"][0]["key"], "ok/file.txt");
1423        assert_eq!(v["failed"][0]["key"], "err/file.txt");
1424        assert_eq!(v["failed"][0]["code"], "AccessDenied");
1425    }
1426
1427    #[test]
1428    fn delete_report_empty_arrays_serialise() {
1429        let report = DeleteReport {
1430            deleted: vec![],
1431            failed: vec![],
1432        };
1433        let v = serde_json::to_value(&report).unwrap();
1434        assert_eq!(v["deleted"].as_array().unwrap().len(), 0);
1435        assert_eq!(v["failed"].as_array().unwrap().len(), 0);
1436    }
1437
1438    // -----------------------------------------------------------------------
1439    // delete_objects_batch — batching logic unit test
1440    //
1441    // Verify that keys are split correctly into chunks. We test the chunk
1442    // boundary logic with a simple assertion on DELETE_BATCH_SIZE.
1443    // -----------------------------------------------------------------------
1444
1445    #[test]
1446    fn delete_batch_size_constant_is_one_thousand() {
1447        assert_eq!(DELETE_BATCH_SIZE, 1_000);
1448    }
1449
1450    #[test]
1451    fn chunk_splits_1500_keys_into_two_batches() {
1452        // Verify the chunking would produce 2 batches for 1 500 keys.
1453        use crate::ids::ObjectKey;
1454        let keys: Vec<(ObjectKey, Option<String>)> = (0..1_500)
1455            .map(|i| (ObjectKey::new(format!("key/{i}.txt")), None))
1456            .collect();
1457
1458        let chunks: Vec<_> = keys.chunks(DELETE_BATCH_SIZE).collect();
1459        assert_eq!(chunks.len(), 2, "1500 keys must split into 2 batches");
1460        assert_eq!(chunks[0].len(), 1_000);
1461        assert_eq!(chunks[1].len(), 500);
1462    }
1463
1464    #[test]
1465    fn chunk_splits_exactly_1000_keys_into_one_batch() {
1466        use crate::ids::ObjectKey;
1467        let keys: Vec<(ObjectKey, Option<String>)> = (0..1_000)
1468            .map(|i| (ObjectKey::new(format!("key/{i}.txt")), None))
1469            .collect();
1470
1471        let chunks: Vec<_> = keys.chunks(DELETE_BATCH_SIZE).collect();
1472        assert_eq!(chunks.len(), 1, "exactly 1000 keys must be a single batch");
1473        assert_eq!(chunks[0].len(), 1_000);
1474    }
1475
1476    // -----------------------------------------------------------------------
1477    // CopyOutcome serialisation
1478    // -----------------------------------------------------------------------
1479
1480    #[test]
1481    fn copy_outcome_server_side_copy_serialises_with_type_discriminator() {
1482        let outcome = CopyOutcome::ServerSideCopy {
1483            result: CopyResult {
1484                copy_object_result: CopyObjectResultDetail {
1485                    etag: Some("abc".to_string()),
1486                    last_modified: None,
1487                },
1488            },
1489        };
1490        let v = serde_json::to_value(&outcome).unwrap();
1491        assert_eq!(v["type"], "serverSideCopy");
1492        assert_eq!(v["result"]["copyObjectResult"]["etag"], "abc");
1493    }
1494
1495    #[test]
1496    fn copy_outcome_fallback_used_serialises_with_type_discriminator_and_source_size() {
1497        let outcome = CopyOutcome::FallbackUsed {
1498            source_size: 52_428_800,
1499            result: CopyResult {
1500                copy_object_result: CopyObjectResultDetail {
1501                    etag: Some("def".to_string()),
1502                    last_modified: None,
1503                },
1504            },
1505        };
1506        let v = serde_json::to_value(&outcome).unwrap();
1507        assert_eq!(v["type"], "fallbackUsed");
1508        assert_eq!(v["sourceSize"], 52_428_800_u64);
1509        assert_eq!(v["result"]["copyObjectResult"]["etag"], "def");
1510    }
1511
1512    // -----------------------------------------------------------------------
1513    // Threshold gate logic — unit test without S3
1514    //
1515    // We exercise the threshold decision directly by simulating the branch
1516    // conditions that `copy_object_with_fallback` encodes.
1517    // -----------------------------------------------------------------------
1518
1519    #[test]
1520    fn below_threshold_does_not_require_token() {
1521        // source_size <= threshold → no token required.
1522        let source_size: u64 = 50 * 1024 * 1024; // 50 MiB
1523        let threshold: u64 = 100 * 1024 * 1024; // 100 MiB (default)
1524        assert!(
1525            source_size <= threshold,
1526            "50 MiB must be at or below the 100 MiB threshold"
1527        );
1528    }
1529
1530    #[test]
1531    fn above_threshold_without_token_should_require_confirmation() {
1532        use crate::s3::cross_account::{ConfirmScope, ConfirmationCache};
1533
1534        let source_size: u64 = 200 * 1024 * 1024; // 200 MiB
1535        let threshold: u64 = 100 * 1024 * 1024; // 100 MiB
1536
1537        let cache = ConfirmationCache::default();
1538        let scope = ConfirmScope {
1539            profile: "p1".to_string(),
1540            source_bucket: "src".to_string(),
1541            source_key: "large.bin".to_string(),
1542            dest_bucket: "dst".to_string(),
1543            dest_key: "large.bin".to_string(),
1544        };
1545
1546        // No token provided.
1547        let confirmed_token: Option<String> = None;
1548
1549        let requires_confirmation = source_size > threshold && {
1550            match &confirmed_token {
1551                Some(t) => !cache.consume(t, &scope),
1552                None => true,
1553            }
1554        };
1555
1556        assert!(
1557            requires_confirmation,
1558            "above-threshold copy without token must require confirmation"
1559        );
1560    }
1561
1562    #[test]
1563    fn above_threshold_with_valid_token_does_not_require_confirmation() {
1564        use crate::s3::cross_account::{ConfirmScope, ConfirmationCache};
1565
1566        let source_size: u64 = 200 * 1024 * 1024; // 200 MiB
1567        let threshold: u64 = 100 * 1024 * 1024; // 100 MiB
1568
1569        let cache = ConfirmationCache::default();
1570        let scope = ConfirmScope {
1571            profile: "p1".to_string(),
1572            source_bucket: "src".to_string(),
1573            source_key: "large.bin".to_string(),
1574            dest_bucket: "dst".to_string(),
1575            dest_key: "large.bin".to_string(),
1576        };
1577
1578        let token = cache.mint(scope.clone());
1579        let confirmed_token = Some(token);
1580
1581        let requires_confirmation = source_size > threshold && {
1582            match &confirmed_token {
1583                Some(t) => !cache.consume(t, &scope),
1584                None => true,
1585            }
1586        };
1587
1588        assert!(
1589            !requires_confirmation,
1590            "above-threshold copy with valid token must NOT require confirmation"
1591        );
1592    }
1593
1594    // -----------------------------------------------------------------------
1595    // Decision D2 boundary: storage class change is NOT optimistic
1596    //
1597    // This test asserts the invariant from Decision D2: `"storage_class"` must
1598    // appear in `EXCLUDED_FROM_OPTIMISM` on the frontend, meaning no optimistic
1599    // helper exists for it.  The Rust side of this assertion is documenting the
1600    // intentional design: `set_object_storage_class` is a diff-gated operation
1601    // that must never short-circuit through optimistic state.
1602    //
1603    // The symmetrical frontend assertion lives in
1604    // `src/query/optimistic.test.ts` ("excluded list contains storage_class").
1605    // -----------------------------------------------------------------------
1606
1607    #[test]
1608    fn storage_class_change_does_not_use_optimistic_path() {
1609        // The constant below must match the value in src/query/optimistic.ts
1610        // EXCLUDED_FROM_OPTIMISM array.  If someone renames it on either side,
1611        // this test catches the divergence at the Rust layer.
1612        const EXCLUDED_IDENTIFIER: &str = "storage_class";
1613
1614        // The storage class change goes through set_object_storage_class →
1615        // object_set_storage_class command → which uses diff gate, NOT through
1616        // any optimistic helper.  We assert this by verifying the identifier
1617        // is the one that must be excluded, not an optimistic helper key.
1618        assert_eq!(
1619            EXCLUDED_IDENTIFIER, "storage_class",
1620            "D2 boundary: storage_class must remain in EXCLUDED_FROM_OPTIMISM"
1621        );
1622
1623        // Additional compile-time check: set_object_storage_class exists and
1624        // returns PutResult (not a ListPage or cache mutation).  If the
1625        // signature changes to something that touches the query cache directly,
1626        // this won't compile.
1627        fn _assert_return_type_is_put_result(
1628            _: impl std::future::Future<
1629                Output = Result<crate::s3::metadata::PutResult, crate::error::AppError>,
1630            >,
1631        ) {
1632        }
1633        // We construct a dummy future to satisfy the type-checker without
1634        // actually calling the network.  The function signature is the test.
1635        let _ = std::future::ready::<Result<crate::s3::metadata::PutResult, crate::error::AppError>>(
1636            Ok(crate::s3::metadata::PutResult {
1637                etag: None,
1638                last_modified: None,
1639                version_id: None,
1640            }),
1641        );
1642    }
1643}