netget/server/s3/mod.rs
1//! S3-compatible object storage server implementation
2//!
3//! Implements an S3-compatible REST API on port 9000 (default).
4//! The LLM controls all operations and maintains "virtual" data through conversation context.
5
6pub mod actions;
7
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::sync::Arc;
11
12use bytes::Bytes;
13use http_body_util::{BodyExt, Full, Limited};
14use hyper::body::{Body, Incoming};
15use hyper::server::conn::http1;
16use hyper::service::service_fn;
17use hyper::{Method, Request, Response, StatusCode};
18use hyper_util::rt::TokioIo;
19use tokio::sync::mpsc;
20use tracing::error;
21
22use crate::llm::ollama_client::OllamaClient;
23use crate::llm::ActionResult;
24use crate::logging::emit::Log;
25use crate::server::accept_bounded::{
26 accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
27};
28use crate::server::connection::ConnectionId;
29use crate::server::S3Protocol;
30use crate::state::app_state::AppState;
31
32/// Largest request body this server will buffer.
33///
34/// The body was read with an unbounded `req.into_body().collect()`, so a single PUT could
35/// grow the process without limit — nothing in HTTP/1.1 bounds a chunked body, and the
36/// content is never stored anyway (only its length reaches the model). `Limited` stops at
37/// the cap and errors, so the memory one request can claim is bounded by a constant.
38pub const MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024;
39
40/// How long a peer that has produced nothing at all may hold this connection.
41///
42/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
43/// S3 client sends it inside its dial path, so a peer that has connected and sent no byte
44/// has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
45/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
46///
47/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
48/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
49/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
50/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
51/// without consuming it, so the request line is still there for hyper afterwards.
52const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
53
54/// How long an established connection may sit **silent** between requests.
55///
56/// This bounds the silence and never a transfer. A request being answered holds
57/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
58/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
59/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
60/// ever runs on a connection with nothing in flight.
61///
62/// Five minutes, which is deliberately more permissive than the service this mimics: AWS's own
63/// load balancers ship a 60-second idle timeout and S3 closes idle connections of its own
64/// accord, so every SDK already has the reopen path this can exercise - that is what an SDK's
65/// connection-pool revalidation is for, and what RFC 9112 requires of any HTTP client. An object
66/// body being served is not silence: the response is built under a busy guard and written by
67/// hyper, so only the pause between two requests is measured.
68const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
69
70/// Concurrent connections this server admits.
71///
72/// Each admitted connection may hold one whole in-memory object body, so the cap is what turns
73/// that per-connection bound into a total one - and object bodies are the largest thing this
74/// family serves. 256 is well above the pool any S3 SDK opens against one endpoint (the AWS SDKs
75/// default to 50 connections) and far below what socket exhaustion needs.
76const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
77
78/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
79///
80/// `503 Service Unavailable` with `Retry-After`, in S3's own vocabulary:
81/// a 503 with `Retry-After` is S3's own overload reply (`SlowDown`),
82/// so every SDK's retry path already handles it, and a human with `curl` reads it directly.
83/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
84/// reason, under `decision=fail_closed_connection_cap`.
85const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
86 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
87
88/// S3 server that delegates API operations to LLM
89pub struct S3Server;
90
91impl S3Server {
92 /// Spawn the S3 server with integrated LLM actions
93 pub async fn spawn_with_llm_actions(
94 listen_addr: SocketAddr,
95 llm_client: OllamaClient,
96 app_state: Arc<AppState>,
97 status_tx: mpsc::UnboundedSender<String>,
98 server_id: crate::state::ServerId,
99 ) -> anyhow::Result<SocketAddr> {
100 let listener =
101 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
102 let local_addr = listener.local_addr()?;
103 Log::new(Some(&status_tx)).info(format!("S3 server listening on {}", local_addr));
104
105 let protocol = Arc::new(S3Protocol::new());
106
107 // Spawn server loop
108 let task_registrar = app_state.clone();
109 let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
110 let accept_handle = tokio::spawn(async move {
111 loop {
112 match accept_bounded(
113 &listener,
114 &limiter,
115 CONNECTION_CAP_REFUSAL,
116 "S3",
117 Some(&status_tx),
118 )
119 .await
120 {
121 Ok((stream, remote_addr, permit)) => {
122 let connection_id =
123 ConnectionId::new(app_state.get_next_unified_id().await);
124 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
125 Log::new(Some(&status_tx)).info(format!(
126 "S3 connection {} from {}",
127 connection_id, remote_addr
128 ));
129
130 // Add connection to ServerInstance
131 use crate::state::server::{
132 ConnectionState as ServerConnectionState, ConnectionStatus,
133 ProtocolConnectionInfo,
134 };
135 let now = crate::utils::clock::Instant::now();
136 let conn_state = ServerConnectionState {
137 id: connection_id,
138 remote_addr,
139 local_addr: local_addr_conn,
140 bytes_sent: 0,
141 bytes_received: 0,
142 packets_sent: 0,
143 packets_received: 0,
144 last_activity: now,
145 status: ConnectionStatus::Active,
146 status_changed_at: now,
147 protocol_info: ProtocolConnectionInfo::empty(),
148 };
149 app_state
150 .add_connection_to_server(server_id, conn_state)
151 .await;
152 let _ = status_tx.send("__UPDATE_UI__".to_string());
153
154 let llm_client_clone = llm_client.clone();
155 let app_state_clone = app_state.clone();
156 let status_tx_clone = status_tx.clone();
157 let protocol_clone = protocol.clone();
158
159 // Spawn a task to handle this connection
160 // Tracked, not detached: stop_server must abort this task too.
161 let task_owner = app_state.clone();
162 task_owner
163 .spawn_server_task(server_id, async move {
164 // Held for the life of the connection, so the
165 // cap counts live clients, not accepts.
166 let _permit = permit;
167
168 // The first-byte bound, before hyper sees the
169 // socket. `peek` waits for data without
170 // consuming it, so the request line is still
171 // there for hyper afterwards, and it bounds
172 // exactly the case that needs bounding: a peer
173 // that connected and said nothing at all.
174 let spoke = matches!(
175 tokio::time::timeout(
176 FIRST_BYTE_READ_TIMEOUT,
177 stream.peek(&mut [0u8; 1]),
178 )
179 .await,
180 Ok(Ok(n)) if n > 0
181 );
182
183 if spoke {
184 let io = TokioIo::new(stream);
185
186 // Clone for service closure
187 let status_for_service = status_tx_clone.clone();
188 let app_state_for_service = app_state_clone.clone();
189
190 // Whether this connection is answering
191 // anything. The watchdog below reads it, so
192 // only genuine silence - never work in
193 // flight - can close a connection.
194 let activity = Arc::new(ConnectionActivity::new());
195 let activity_for_service = Arc::clone(&activity);
196 // Create a service that handles S3 requests with LLM
197 let service = service_fn(move |req: Request<Incoming>| {
198 let llm_clone = llm_client_clone.clone();
199 let state_clone = app_state_for_service.clone();
200 let status_clone = status_for_service.clone();
201 let protocol_clone = protocol_clone.clone();
202 // Busy for the whole of this request - the
203 // model round-trip included, and a `manual`
204 // rule parked for a human - so the idle
205 // watchdog can never close the connection an
206 // answer belongs to.
207 let activity = Arc::clone(&activity_for_service);
208 async move {
209 let _busy = activity.busy();
210 handle_s3_request_with_llm(
211 req,
212 connection_id,
213 llm_clone,
214 state_clone,
215 status_clone,
216 protocol_clone,
217 server_id,
218 )
219 .await
220 }
221 });
222
223 // Serve HTTP/1 on this connection
224 let conn = http1::Builder::new().serve_connection(io, service);
225 tokio::pin!(conn);
226 tokio::select! {
227 result = &mut conn => {
228 if let Err(err) = result {
229 error!("Error serving S3 connection: {:?}", err);
230 }
231 }
232 // The idle bound, over `ConnectionActivity`
233 // rather than over a read: hyper owns every
234 // read once `serve_connection` starts, and
235 // keeps polling for frames while a request is
236 // being answered, so a deadline on those reads
237 // would fire mid-answer. A connection with work
238 // in flight is not idle at all.
239 _ = watch_idle(
240 Arc::clone(&activity),
241 IDLE_BETWEEN_REQUESTS_TIMEOUT,
242 ) => {
243 Log::new(Some(&status_tx_clone)).debug(format!(
244 "S3 connection {} idle for {}s; closing",
245 connection_id,
246 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
247 ));
248 }
249 }
250 } else {
251 Log::new(Some(&status_tx_clone)).debug(format!(
252 "S3 peer {} sent no request within {}s; closing \
253 decision=fail_closed_first_byte_timeout",
254 remote_addr,
255 FIRST_BYTE_READ_TIMEOUT.as_secs()
256 ));
257 }
258
259 // Mark connection as closed
260 app_state_clone
261 .close_connection_on_server(server_id, connection_id)
262 .await;
263 Log::new(Some(&status_tx_clone))
264 .info(format!("S3 connection {} closed", connection_id));
265 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
266 })
267 .await;
268 }
269 Err(e) => {
270 Log::new(Some(&status_tx))
271 .error(format!("Failed to accept S3 connection: {}", e));
272 break;
273 }
274 }
275 }
276 });
277
278 // Register the accept loop so stop_server can abort it and release the port.
279 task_registrar
280 .register_server_task(server_id, accept_handle)
281 .await;
282
283 Ok(local_addr)
284 }
285}
286
287/// Handle a single S3 request with LLM, then record what crossed the wire.
288///
289/// The rail's byte counters and the connection-scoped task prompts read
290/// `bytes_received`/`bytes_sent`, and this server left both at the zero it registered the
291/// connection with, so every S3 peer showed no traffic at all however much it moved.
292async fn handle_s3_request_with_llm(
293 req: Request<Incoming>,
294 connection_id: ConnectionId,
295 llm_client: OllamaClient,
296 app_state: Arc<AppState>,
297 status_tx: mpsc::UnboundedSender<String>,
298 protocol: Arc<S3Protocol>,
299 server_id: crate::state::ServerId,
300) -> Result<Response<Full<Bytes>>, Infallible> {
301 let received = req.body().size_hint().lower();
302 let response = handle_s3_request_inner(
303 req,
304 llm_client,
305 app_state.clone(),
306 status_tx.clone(),
307 protocol,
308 server_id,
309 )
310 .await;
311
312 let sent = response
313 .as_ref()
314 .ok()
315 .and_then(|resp| resp.body().size_hint().exact())
316 .unwrap_or(0);
317 app_state
318 .update_connection_stats(
319 server_id,
320 connection_id,
321 Some(received),
322 Some(sent),
323 Some(1),
324 Some(1),
325 )
326 .await;
327 let _ = status_tx.send("__UPDATE_UI__".to_string());
328
329 response
330}
331
332async fn handle_s3_request_inner(
333 req: Request<Incoming>,
334 llm_client: OllamaClient,
335 app_state: Arc<AppState>,
336 status_tx: mpsc::UnboundedSender<String>,
337 protocol: Arc<S3Protocol>,
338 server_id: crate::state::ServerId,
339) -> Result<Response<Full<Bytes>>, Infallible> {
340 // Extract request details
341 let method = req.method().clone();
342 let uri = req.uri().clone();
343 let path = uri.path().to_string();
344
345 // Parse bucket and key from path
346 // Path format: / (list buckets), /bucket (bucket ops), /bucket/key (object ops)
347 let (bucket, key, operation) = parse_s3_path(&method, &path);
348
349 let log = Log::new(Some(&status_tx));
350 // FileOnly: the s3_request event's own log_template already reports the request to
351 // the TUI at INFO.
352 log.debug(format!(
353 "S3 request: {} {} bucket={:?} key={:?} operation={}",
354 method, path, bucket, key, operation
355 ));
356
357 // Read the request body (for PUT operations), capped at MAX_REQUEST_BYTES.
358 let body_bytes = match Limited::new(req.into_body(), MAX_REQUEST_BYTES)
359 .collect()
360 .await
361 {
362 Ok(collected) => collected.to_bytes(),
363 Err(e) => {
364 // Refuse rather than continue with an empty body. A truncated or oversized
365 // upload used to be reported to the model as a PutObject carrying zero bytes,
366 // which the model would then acknowledge as a stored object.
367 log.warn(format!(
368 "S3 {} {} decision=fail_closed_body_rejected (limit {} bytes): {}",
369 operation, path, MAX_REQUEST_BYTES, e
370 ));
371 return Ok(Response::builder()
372 .status(StatusCode::PAYLOAD_TOO_LARGE)
373 .header("Content-Type", "application/xml")
374 .body(Full::new(Bytes::from(build_error_xml(
375 "EntityTooLarge",
376 "request body rejected",
377 ))))
378 .unwrap());
379 }
380 };
381
382 if !body_bytes.is_empty() {
383 log.trace(format!("S3 request body ({} bytes)", body_bytes.len()));
384 }
385
386 // Create S3 request event
387 let event = crate::protocol::Event::new(
388 &actions::S3_REQUEST_EVENT,
389 serde_json::json!({
390 "operation": operation,
391 "bucket": bucket,
392 "key": key,
393 "request_details": {
394 "method": method.as_str(),
395 "path": path,
396 "body_size": body_bytes.len(),
397 }
398 }),
399 );
400
401 // Call LLM to handle request
402 let llm_result = crate::llm::action_helper::call_llm(
403 &llm_client,
404 &app_state,
405 server_id,
406 None, // Connection ID not needed for stateless HTTP
407 &event,
408 protocol.as_ref(),
409 )
410 .await;
411
412 // Process LLM result and build HTTP response
413 match llm_result {
414 Ok(execution_result) => {
415 // Which of the three outcomes this was, for the log. `decision=` is a stable
416 // grep target: `model_reject` is the model deliberately refusing the request,
417 // `model_answer` is a real S3 response, `model_no_action` is the model saying
418 // nothing usable. None of them is `fail_closed_llm_error`, which only the `Err`
419 // arm below may write — an operator must be able to tell a model's refusal from
420 // netget failing to reach one.
421 let decision = execution_result
422 .protocol_results
423 .iter()
424 .find_map(|result| match result {
425 ActionResult::Custom { name, .. } => match name.as_str() {
426 "s3_error" => Some("model_reject"),
427 "s3_object" | "s3_object_list" | "s3_bucket_list" | "s3_write_result" => {
428 Some("model_answer")
429 }
430 _ => None,
431 },
432 _ => None,
433 })
434 .unwrap_or("model_no_action");
435 let failures = execution_result.failures.len();
436
437 // Scan for the first action that is actually an S3 response. This was a
438 // `for` loop with an unconditional `return` inside it — so it examined
439 // only the first result and, if that was something like `show_message`,
440 // returned the empty-200 fallback and dropped the real object. It also
441 // tripped clippy's `never_loop`, which is how it was found.
442 if let Some(response) = execution_result
443 .protocol_results
444 .into_iter()
445 .find_map(|result| process_s3_action_result(result, bucket.as_deref(), &status_tx))
446 {
447 log.debug(format!("S3 {} {} decision={}", operation, path, decision));
448 return Ok(response);
449 }
450
451 // The model produced nothing this protocol can turn into a response.
452 //
453 // This used to answer an empty 200 OK, and for PutObject, CreateBucket,
454 // DeleteObject and HeadObject an empty 200 is exactly what S3 returns on success —
455 // so a model that declined the operation was reported to the caller as having
456 // performed it. It could not simply be made to refuse, because until
457 // `send_s3_write_result` existed those four verbs had no affirmative action at all
458 // and refusing would have made them permanently impossible. Now they have one, so
459 // silence can mean what it should.
460 log.warn(format!(
461 "S3 {} {} decision={} (no S3 response action; {} action(s) failed to \
462 execute) — answering 500 InternalError",
463 operation, path, decision, failures
464 ));
465 Ok(Response::builder()
466 .status(StatusCode::INTERNAL_SERVER_ERROR)
467 .header("Content-Type", "application/xml")
468 .body(Full::new(Bytes::from(build_error_xml(
469 "InternalError",
470 crate::utils::WireFailure::Unavailable.text(),
471 ))))
472 .unwrap())
473 }
474 Err(e) => {
475 // netget could not reach a decision at all. Fail closed with an S3 `<Error>`
476 // document carrying only a category — never the error itself, which names the
477 // backend, the model and netget's own retry machinery. The full error goes to
478 // the log and the status stream, which is where an operator looks.
479 let failure = crate::utils::WireFailure::classify(&e);
480 log.warn(format!(
481 "S3 {} {} decision=fail_closed_llm_error category={:?}: {}",
482 operation, path, failure, e
483 ));
484
485 // Distinct codes, so a client backs off rather than recording a permanent
486 // fault: an overloaded backend is 503 + Retry-After (the AWS SDKs and rust-s3
487 // treat 503 `ServiceUnavailable` as retryable), anything else is 500.
488 let (status, code) = match failure {
489 crate::utils::WireFailure::Overloaded => {
490 (StatusCode::SERVICE_UNAVAILABLE, "ServiceUnavailable")
491 }
492 crate::utils::WireFailure::Unavailable => {
493 (StatusCode::INTERNAL_SERVER_ERROR, "InternalError")
494 }
495 };
496
497 let mut builder = Response::builder()
498 .status(status)
499 .header("Content-Type", "application/xml");
500 if failure == crate::utils::WireFailure::Overloaded {
501 builder = builder.header("Retry-After", "5");
502 }
503
504 Ok(builder
505 .body(Full::new(Bytes::from(build_error_xml(
506 code,
507 failure.text(),
508 ))))
509 .unwrap())
510 }
511 }
512}
513
514/// Escape a value for inclusion in XML character data or an attribute.
515///
516/// Every value below reaches the wire from either the request path (bucket and object
517/// keys) or model output (error messages, ETags, dates). None of it was escaped before,
518/// so a single `&` in a key or message produced a body that is not well-formed XML. Real
519/// clients do not recover from that: the AWS CLI silently discards an `<Error>` document
520/// it cannot parse and reports a bare HTTP status, losing the S3 error code entirely.
521fn xml_escape(s: &str) -> String {
522 let mut out = String::with_capacity(s.len());
523 for c in s.chars() {
524 match c {
525 '&' => out.push_str("&"),
526 '<' => out.push_str("<"),
527 '>' => out.push_str(">"),
528 '"' => out.push_str("""),
529 '\'' => out.push_str("'"),
530 _ => out.push(c),
531 }
532 }
533 out
534}
535
536/// Build an S3 `<Error>` document.
537fn build_error_xml(code: &str, message: &str) -> String {
538 format!(
539 r#"<?xml version="1.0" encoding="UTF-8"?>
540<Error>
541 <Code>{}</Code>
542 <Message>{}</Message>
543</Error>"#,
544 xml_escape(code),
545 xml_escape(message)
546 )
547}
548
549/// Attach a header whose value came from model output.
550///
551/// `http::HeaderValue` rejects control characters and non-ASCII bytes, and
552/// `Builder::body()` surfaces that as an error which the old `.unwrap()` turned into a
553/// panic - killing the connection task on a malformed model response. Skip the header
554/// instead and say so in the log.
555fn header_or_skip(
556 builder: hyper::http::response::Builder,
557 name: &'static str,
558 value: &str,
559) -> hyper::http::response::Builder {
560 match hyper::header::HeaderValue::from_str(value) {
561 Ok(v) => builder.header(name, v),
562 Err(_) => {
563 error!(
564 "Dropping S3 {} header: {:?} is not a valid HTTP header value",
565 name, value
566 );
567 builder
568 }
569 }
570}
571
572/// Parse S3 path into bucket, key, and operation
573fn parse_s3_path(method: &Method, path: &str) -> (Option<String>, Option<String>, String) {
574 let parts: Vec<&str> = path
575 .trim_start_matches('/')
576 .split('/')
577 .filter(|s| !s.is_empty())
578 .collect();
579
580 match (method, parts.as_slice()) {
581 // List buckets: GET /
582 (m, []) if m == Method::GET => (None, None, "ListBuckets".to_string()),
583
584 // Bucket operations: GET /bucket, PUT /bucket, DELETE /bucket
585 (m, [bucket]) if m == Method::GET => {
586 (Some(bucket.to_string()), None, "ListObjects".to_string())
587 }
588 (m, [bucket]) if m == Method::PUT => {
589 (Some(bucket.to_string()), None, "CreateBucket".to_string())
590 }
591 (m, [bucket]) if m == Method::DELETE => {
592 (Some(bucket.to_string()), None, "DeleteBucket".to_string())
593 }
594 (m, [bucket]) if m == Method::HEAD => {
595 (Some(bucket.to_string()), None, "HeadBucket".to_string())
596 }
597
598 // Object operations: GET /bucket/key, PUT /bucket/key, DELETE /bucket/key
599 (m, parts) if parts.len() >= 2 && m == Method::GET => {
600 let bucket = parts[0].to_string();
601 let key = parts[1..].join("/");
602 (Some(bucket), Some(key), "GetObject".to_string())
603 }
604 (m, parts) if parts.len() >= 2 && m == Method::PUT => {
605 let bucket = parts[0].to_string();
606 let key = parts[1..].join("/");
607 (Some(bucket), Some(key), "PutObject".to_string())
608 }
609 (m, parts) if parts.len() >= 2 && m == Method::DELETE => {
610 let bucket = parts[0].to_string();
611 let key = parts[1..].join("/");
612 (Some(bucket), Some(key), "DeleteObject".to_string())
613 }
614 (m, parts) if parts.len() >= 2 && m == Method::HEAD => {
615 let bucket = parts[0].to_string();
616 let key = parts[1..].join("/");
617 (Some(bucket), Some(key), "HeadObject".to_string())
618 }
619
620 // Unknown
621 _ => (None, None, "Unknown".to_string()),
622 }
623}
624
625/// Process LLM action result and build HTTP response
626/// Build an S3 response from one action result.
627///
628/// Returns `None` when the action is not an S3 response action, so the caller can
629/// keep scanning. This used to return an empty `200 OK` for anything it did not
630/// recognise, and the caller returned unconditionally on the first result — so a
631/// model that emitted the documented `show_message` + `s3_object` pair had its
632/// object silently replaced by an empty body.
633fn process_s3_action_result(
634 action_result: ActionResult,
635 bucket: Option<&str>,
636 status_tx: &mpsc::UnboundedSender<String>,
637) -> Option<Response<Full<Bytes>>> {
638 match action_result {
639 ActionResult::Custom { name, data } => {
640 match name.as_str() {
641 "s3_object" => {
642 // `content_b64` is the canonical form produced by
643 // `S3Protocol::execute_action`, which already validated and decoded
644 // the model's `content`/`encoding` pair. It cannot fail to decode here.
645 use base64::Engine;
646 let content: Vec<u8> = data
647 .get("content_b64")
648 .and_then(|v| v.as_str())
649 .and_then(|s| base64::engine::general_purpose::STANDARD.decode(s).ok())
650 .unwrap_or_default();
651
652 let content_type = data
653 .get("content_type")
654 .and_then(|v| v.as_str())
655 .unwrap_or("application/octet-stream");
656
657 let etag = data.get("etag").and_then(|v| v.as_str());
658
659 // FileOnly: the send_s3_object action's own log_template already
660 // reports "-> S3 send object ({content_type})" to the TUI at INFO.
661 Log::new(Some(status_tx)).debug(format!(
662 "Sending S3 object ({} bytes, {})",
663 content.len(),
664 content_type
665 ));
666
667 // `send_s3_object` documents an unusable content_type as being replaced
668 // with application/octet-stream; `header_or_skip` alone would drop the
669 // header entirely and answer with no Content-Type at all.
670 let content_type = if hyper::header::HeaderValue::from_str(content_type).is_ok()
671 {
672 content_type
673 } else {
674 error!(
675 "S3 content_type {:?} is not a valid header value; \
676 sending application/octet-stream",
677 content_type
678 );
679 "application/octet-stream"
680 };
681
682 let mut builder = Response::builder().status(StatusCode::OK);
683 builder = header_or_skip(builder, "Content-Type", content_type);
684 if let Some(etag) = etag {
685 builder = header_or_skip(builder, "ETag", etag);
686 }
687
688 Some(
689 builder
690 .body(Full::new(Bytes::from(content)))
691 .unwrap_or_else(|_| Response::new(Full::new(Bytes::new()))),
692 )
693 }
694 "s3_object_list" => {
695 // Send list of objects as XML
696 let objects = data
697 .get("objects")
698 .and_then(|v| v.as_array())
699 .cloned()
700 .unwrap_or_default();
701
702 let is_truncated = data
703 .get("is_truncated")
704 .and_then(|v| v.as_bool())
705 .unwrap_or(false);
706
707 let xml = build_list_objects_xml(bucket, &objects, is_truncated);
708
709 // FileOnly: the send_s3_object_list action's own log_template already
710 // reports "-> S3 list objects (...)" to the TUI at INFO.
711 Log::new(Some(status_tx)).debug(format!(
712 "Sending S3 object list ({} objects)",
713 objects.len()
714 ));
715
716 Some(
717 Response::builder()
718 .status(StatusCode::OK)
719 .header("Content-Type", "application/xml")
720 .body(Full::new(Bytes::from(xml)))
721 .unwrap(),
722 )
723 }
724 "s3_bucket_list" => {
725 // Send list of buckets as XML
726 let buckets = data
727 .get("buckets")
728 .and_then(|v| v.as_array())
729 .cloned()
730 .unwrap_or_default();
731
732 let xml = build_list_buckets_xml(&buckets);
733
734 // FileOnly: the send_s3_bucket_list action's own log_template already
735 // reports "-> S3 list buckets (...)" to the TUI at INFO.
736 Log::new(Some(status_tx)).debug(format!(
737 "Sending S3 bucket list ({} buckets)",
738 buckets.len()
739 ));
740
741 Some(
742 Response::builder()
743 .status(StatusCode::OK)
744 .header("Content-Type", "application/xml")
745 .body(Full::new(Bytes::from(xml)))
746 .unwrap(),
747 )
748 }
749 "s3_write_result" => {
750 // A body-less acknowledgement: PutObject/CreateBucket/HeadObject answer
751 // 200, DeleteObject answers 204. Each header is attached only when the
752 // model supplied it, so an unadorned acknowledgement is still valid.
753 let status = data
754 .get("status_code")
755 .and_then(|v| v.as_u64())
756 .unwrap_or(200) as u16;
757 let status =
758 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
759
760 Log::new(Some(status_tx))
761 .debug(format!("Sending S3 write acknowledgement ({})", status));
762
763 // Every header value here is model output, so each goes through
764 // `header_or_skip`. Attaching one directly makes `Builder::body()` fail
765 // instead, and the `unwrap_or_else` below then answers an empty 200 —
766 // silently downgrading a 204 DeleteObject to a 200 because an ETag
767 // contained a newline.
768 let mut builder = Response::builder().status(status);
769 for (field, header) in [
770 ("etag", "ETag"),
771 ("location", "Location"),
772 ("content_type", "Content-Type"),
773 ("last_modified", "Last-Modified"),
774 ] {
775 if let Some(v) = data.get(field).and_then(|v| v.as_str()) {
776 builder = header_or_skip(builder, header, v);
777 }
778 }
779 if let Some(v) = data.get("content_length").and_then(|v| v.as_u64()) {
780 builder = builder.header("Content-Length", v.to_string());
781 }
782
783 Some(
784 builder
785 .body(Full::new(Bytes::new()))
786 .unwrap_or_else(|_| Response::new(Full::new(Bytes::new()))),
787 )
788 }
789 "s3_error" => {
790 // Send S3 error response
791 let error_code = data
792 .get("error_code")
793 .and_then(|v| v.as_str())
794 .unwrap_or("InternalError");
795
796 let message = data
797 .get("message")
798 .and_then(|v| v.as_str())
799 .unwrap_or("An error occurred");
800
801 let status_code = data
802 .get("status_code")
803 .and_then(|v| v.as_u64())
804 .unwrap_or(500) as u16;
805
806 let xml = build_error_xml(error_code, message);
807
808 // FileOnly: the send_s3_error action's own log_template already reports
809 // "-> S3 error {error_code} (...)" to the TUI at INFO.
810 Log::new(Some(status_tx)).debug(format!(
811 "Sending S3 error: {} ({})",
812 error_code, status_code
813 ));
814
815 Some(
816 Response::builder()
817 .status(
818 StatusCode::from_u16(status_code)
819 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
820 )
821 .header("Content-Type", "application/xml")
822 .body(Full::new(Bytes::from(xml)))
823 .unwrap(),
824 )
825 }
826 _ => {
827 // Unknown custom action, return empty response
828 // Not an S3 action: let the caller keep scanning.
829 None
830 }
831 }
832 }
833 _ => {
834 // For non-custom actions (NoAction, etc.), return 200 OK with empty body
835 // Not an S3 action: let the caller keep scanning.
836 None
837 }
838 }
839}
840
841/// Build ListBuckets XML response
842fn build_list_buckets_xml(buckets: &[serde_json::Value]) -> String {
843 let mut xml = String::from(
844 r#"<?xml version="1.0" encoding="UTF-8"?>
845<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
846 <Owner>
847 <DisplayName>netget</DisplayName>
848 <ID>netget-user</ID>
849 </Owner>
850 <Buckets>"#,
851 );
852
853 for bucket in buckets {
854 let name = bucket.get("name").and_then(|v| v.as_str()).unwrap_or("");
855 let creation_date = bucket
856 .get("creation_date")
857 .and_then(|v| v.as_str())
858 .unwrap_or("2024-01-01T00:00:00.000Z");
859
860 xml.push_str(&format!(
861 r#"
862 <Bucket>
863 <Name>{}</Name>
864 <CreationDate>{}</CreationDate>
865 </Bucket>"#,
866 xml_escape(name),
867 xml_escape(creation_date)
868 ));
869 }
870
871 xml.push_str(
872 r#"
873 </Buckets>
874</ListAllMyBucketsResult>"#,
875 );
876
877 xml
878}
879
880/// Build ListObjects XML response
881///
882/// `Name`, `Prefix`, `MaxKeys` and `KeyCount` are always-present elements of a real
883/// `ListBucketResult`; omitting them left SDKs deserializing a bucket listing with no
884/// bucket name and no key count. `bucket` comes from the request path, since the
885/// listing action carries only the objects.
886fn build_list_objects_xml(
887 bucket: Option<&str>,
888 objects: &[serde_json::Value],
889 is_truncated: bool,
890) -> String {
891 let mut xml = String::from(
892 r#"<?xml version="1.0" encoding="UTF-8"?>
893<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">"#,
894 );
895
896 xml.push_str(&format!(
897 r#"
898 <Name>{}</Name>
899 <Prefix></Prefix>
900 <MaxKeys>1000</MaxKeys>
901 <KeyCount>{}</KeyCount>
902 <IsTruncated>{}</IsTruncated>"#,
903 xml_escape(bucket.unwrap_or("")),
904 objects.len(),
905 is_truncated
906 ));
907
908 for object in objects {
909 let key = object.get("key").and_then(|v| v.as_str()).unwrap_or("");
910 let size = object.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
911 let last_modified = object
912 .get("last_modified")
913 .and_then(|v| v.as_str())
914 .unwrap_or("2024-01-01T00:00:00.000Z");
915 let etag = object
916 .get("etag")
917 .and_then(|v| v.as_str())
918 .unwrap_or("\"default\"");
919
920 xml.push_str(&format!(
921 r#"
922 <Contents>
923 <Key>{}</Key>
924 <Size>{}</Size>
925 <LastModified>{}</LastModified>
926 <ETag>{}</ETag>
927 <StorageClass>STANDARD</StorageClass>
928 </Contents>"#,
929 xml_escape(key),
930 size,
931 xml_escape(last_modified),
932 xml_escape(etag)
933 ));
934 }
935
936 xml.push_str(
937 r#"
938</ListBucketResult>"#,
939 );
940
941 xml
942}