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(©_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(©_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}