Skip to main content

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("&amp;"),
526            '<' => out.push_str("&lt;"),
527            '>' => out.push_str("&gt;"),
528            '"' => out.push_str("&quot;"),
529            '\'' => out.push_str("&apos;"),
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}