Skip to main content

netget/server/s3/
actions.rs

1//! S3 protocol actions and event types
2//!
3//! Defines the actions the LLM can take in response to S3 API requests.
4
5use crate::llm::actions::{
6    protocol_trait::{ActionResult, Server},
7    ActionDefinition, Parameter, ParameterDefinition,
8};
9use crate::protocol::log_template::LogTemplate;
10use crate::protocol::EventType;
11use crate::state::app_state::AppState;
12use anyhow::Result;
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16/// S3 protocol handler
17pub struct S3Protocol {
18    // Could store connection state here if needed
19}
20
21impl S3Protocol {
22    pub fn new() -> Self {
23        Self {}
24    }
25}
26
27/// S3 request event - triggered when an S3 API request is received
28pub static S3_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
29    EventType::new(
30        "s3_request",
31        "S3 API request received",
32        json!({"type": "placeholder", "event_id": "s3_request"}),
33    )
34    .with_parameters(vec![
35        Parameter {
36            name: "operation".to_string(),
37            type_hint: "string".to_string(),
38            description: "S3 operation (GetObject, PutObject, ListBuckets, etc.)".to_string(),
39            required: true,
40        },
41        Parameter {
42            name: "bucket".to_string(),
43            type_hint: "string".to_string(),
44            description: "Bucket name (if applicable)".to_string(),
45            required: false,
46        },
47        Parameter {
48            name: "key".to_string(),
49            type_hint: "string".to_string(),
50            description: "Object key/path (if applicable)".to_string(),
51            required: false,
52        },
53        Parameter {
54            name: "request_details".to_string(),
55            type_hint: "object".to_string(),
56            description: "Additional request details (headers, query params, etc.)".to_string(),
57            required: false,
58        },
59    ])
60    .with_actions(vec![
61        send_s3_object_action(),
62        send_s3_object_list_action(),
63        send_s3_bucket_list_action(),
64        send_s3_write_result_action(),
65        send_s3_error_action(),
66    ])
67    .with_log_template(
68        LogTemplate::new()
69            .with_info("S3 {operation} {bucket}/{key}")
70            .with_debug("S3 {operation} bucket={bucket}, key={key}")
71            .with_trace("S3: {json_pretty(.)}"),
72    )
73});
74
75fn send_s3_object_action() -> ActionDefinition {
76    ActionDefinition {
77        name: "send_s3_object".to_string(),
78        description: "Send the body of an S3 object in response to a GetObject request. \
79                      'content' holds the object bytes and the optional 'encoding' field says how \
80                      to turn it into bytes: omit 'encoding' (or use \"utf8\") for text objects, \
81                      or set \"encoding\": \"base64\" to send arbitrary binary content. There is \
82                      no auto-detection - a base64-looking string is sent literally unless you \
83                      set \"encoding\": \"base64\"."
84            .to_string(),
85        parameters: vec![
86            Parameter {
87                name: "content".to_string(),
88                type_hint: "string".to_string(),
89                description: "Object body. Interpreted according to 'encoding': as literal text \
90                              by default, or as base64-encoded bytes when \
91                              \"encoding\": \"base64\""
92                    .to_string(),
93                required: true,
94            },
95            encoding_parameter(),
96            Parameter {
97                name: "content_type".to_string(),
98                type_hint: "string".to_string(),
99                description: "Content-Type header (e.g., 'text/plain', 'application/json'). \
100                              Must be a single line of printable ASCII; an unusable value is \
101                              replaced with 'application/octet-stream'"
102                    .to_string(),
103                required: false,
104            },
105            Parameter {
106                name: "etag".to_string(),
107                type_hint: "string".to_string(),
108                description: "ETag for the object, normally the quoted MD5 of the body. Must be \
109                              a single line of printable ASCII; an unusable value is dropped"
110                    .to_string(),
111                required: false,
112            },
113        ],
114        example: json!({
115            "type": "send_s3_object",
116            "content": "Hello, World!",
117            "encoding": "utf8",
118            "content_type": "text/plain",
119            "etag": "\"d41d8cd98f00b204e9800998ecf8427e\""
120        }),
121        log_template: Some(
122            LogTemplate::new()
123                .with_info("-> S3 send object ({content_type})")
124                .with_debug("S3 send_s3_object: content_type={content_type} etag={etag}"),
125        ),
126    }
127}
128
129fn send_s3_object_list_action() -> ActionDefinition {
130    ActionDefinition {
131        name: "send_s3_object_list".to_string(),
132        description: "Send list of objects in bucket (ListObjects response)".to_string(),
133        parameters: vec![
134            Parameter {
135                name: "objects".to_string(),
136                type_hint: "array".to_string(),
137                description: "Array of objects with 'key', 'size', 'last_modified' fields"
138                    .to_string(),
139                required: true,
140            },
141            Parameter {
142                name: "is_truncated".to_string(),
143                type_hint: "boolean".to_string(),
144                description: "Whether there are more objects to list".to_string(),
145                required: false,
146            },
147        ],
148        example: json!({
149            "type": "send_s3_object_list",
150            "objects": [
151                {"key": "file1.txt", "size": 1024, "last_modified": "2024-01-01T00:00:00Z"},
152                {"key": "file2.jpg", "size": 2048, "last_modified": "2024-01-02T00:00:00Z"}
153            ],
154            "is_truncated": false
155        }),
156        log_template: Some(
157            LogTemplate::new()
158                .with_info("-> S3 list objects ({objects_len} items)")
159                .with_debug("S3 send_s3_object_list: count={objects_len} truncated={is_truncated}"),
160        ),
161    }
162}
163
164fn send_s3_bucket_list_action() -> ActionDefinition {
165    ActionDefinition {
166        name: "send_s3_bucket_list".to_string(),
167        description: "Send list of buckets (ListBuckets response)".to_string(),
168        parameters: vec![Parameter {
169            name: "buckets".to_string(),
170            type_hint: "array".to_string(),
171            description: "Array of buckets with 'name', 'creation_date' fields".to_string(),
172            required: true,
173        }],
174        example: json!({
175            "type": "send_s3_bucket_list",
176            "buckets": [
177                {"name": "my-bucket", "creation_date": "2024-01-01T00:00:00Z"},
178                {"name": "test-bucket", "creation_date": "2024-01-02T00:00:00Z"}
179            ]
180        }),
181        log_template: Some(
182            LogTemplate::new()
183                .with_info("-> S3 list buckets ({buckets_len} buckets)")
184                .with_debug("S3 send_s3_bucket_list: count={buckets_len}"),
185        ),
186    }
187}
188
189/// Acknowledge a write that produced no body: PutObject, CreateBucket, DeleteObject, HeadObject.
190///
191/// These four verbs have no content to return, so before this action existed the model had no
192/// way to say "yes" to them at all — the server fell through to an empty `200 OK` whenever the
193/// answer contained no S3 action. That made silence indistinguishable from approval on exactly
194/// the operations that mutate state, which is the fail-open pattern the root `CLAUDE.md` calls
195/// the most dangerous in this codebase. It could not be removed until there was an affirmative
196/// verb to replace it, which is what this is.
197fn send_s3_write_result_action() -> ActionDefinition {
198    ActionDefinition {
199        name: "send_s3_write_result".to_string(),
200        description: "Acknowledge a successful write or metadata request that returns no body \
201                      (PutObject, CreateBucket, DeleteObject, HeadObject). Required: without it \
202                      these operations are refused."
203            .to_string(),
204        parameters: vec![
205            Parameter {
206                name: "status_code".to_string(),
207                type_hint: "number".to_string(),
208                description: "HTTP status: 200 for PutObject/CreateBucket/HeadObject, 204 for \
209                              DeleteObject. Defaults to 200."
210                    .to_string(),
211                required: false,
212            },
213            Parameter {
214                name: "etag".to_string(),
215                type_hint: "string".to_string(),
216                description: "ETag of the stored object (PutObject). Sent as the ETag header."
217                    .to_string(),
218                required: false,
219            },
220            Parameter {
221                name: "location".to_string(),
222                type_hint: "string".to_string(),
223                description: "Location of the created bucket (CreateBucket), e.g. \"/my-bucket\"."
224                    .to_string(),
225                required: false,
226            },
227            Parameter {
228                name: "content_length".to_string(),
229                type_hint: "number".to_string(),
230                description: "Size in bytes (HeadObject). Sent as the Content-Length header."
231                    .to_string(),
232                required: false,
233            },
234            Parameter {
235                name: "content_type".to_string(),
236                type_hint: "string".to_string(),
237                description: "MIME type (HeadObject).".to_string(),
238                required: false,
239            },
240            Parameter {
241                name: "last_modified".to_string(),
242                type_hint: "string".to_string(),
243                description: "RFC 1123 timestamp (HeadObject), e.g. \"Mon, 01 Jan 2024 00:00:00 \
244                              GMT\"."
245                    .to_string(),
246                required: false,
247            },
248        ],
249        example: json!({
250            "type": "send_s3_write_result",
251            "status_code": 200,
252            "etag": "\"d41d8cd98f00b204e9800998ecf8427e\""
253        }),
254        log_template: Some(
255            LogTemplate::new()
256                .with_info("-> S3 write acknowledged (HTTP {status_code})")
257                .with_debug("S3 send_s3_write_result: status={status_code} etag={etag}"),
258        ),
259    }
260}
261
262fn send_s3_error_action() -> ActionDefinition {
263    ActionDefinition {
264        name: "send_s3_error".to_string(),
265        description: "Send S3 error response".to_string(),
266        parameters: vec![
267            Parameter {
268                name: "error_code".to_string(),
269                type_hint: "string".to_string(),
270                description: "S3 error code (NoSuchBucket, NoSuchKey, AccessDenied, etc.)"
271                    .to_string(),
272                required: true,
273            },
274            Parameter {
275                name: "message".to_string(),
276                type_hint: "string".to_string(),
277                description: "Error message".to_string(),
278                required: true,
279            },
280            Parameter {
281                name: "status_code".to_string(),
282                type_hint: "number".to_string(),
283                description: "HTTP status code (404, 403, 500, etc.)".to_string(),
284                required: true,
285            },
286        ],
287        example: json!({
288            "type": "send_s3_error",
289            "error_code": "NoSuchKey",
290            "message": "The specified key does not exist",
291            "status_code": 404
292        }),
293        log_template: Some(
294            LogTemplate::new()
295                .with_info("-> S3 error {error_code} (HTTP {status_code})")
296                .with_debug("S3 send_s3_error: error_code={error_code} status={status_code} message='{message}'"),
297        ),
298    }
299}
300
301/// Shared `encoding` parameter for the outbound object body.
302///
303/// Object bodies are the one place in this protocol where the payload is genuinely
304/// binary, so - following the `send_tcp_data` precedent - the encoding is stated
305/// explicitly by the caller and actually decoded by [`decode_object_content`],
306/// rather than guessed.
307fn encoding_parameter() -> Parameter {
308    Parameter {
309        name: "encoding".to_string(),
310        type_hint: "string".to_string(),
311        description: "How to convert 'content' into the object bytes. \"utf8\" (the default when \
312                      omitted) sends the characters of 'content' unchanged - use it for text, \
313                      JSON, XML and other textual objects. \"base64\" decodes 'content' as \
314                      base64 - use it for images, archives and any other binary object, e.g. \
315                      {\"content\": \"SGVsbG8=\", \"encoding\": \"base64\"} sends the 5 bytes \
316                      'Hello', whereas the same 'content' without \"encoding\": \"base64\" sends \
317                      the 8 characters S-G-V-s-b-G-8-=. No other values are accepted"
318            .to_string(),
319        required: false,
320    }
321}
322
323/// Turn the `content` field of `send_s3_object` into the exact object bytes,
324/// honouring the action's optional `encoding` field.
325///
326/// Returns the bytes re-encoded as base64 for transport through
327/// [`ActionResult::Custom`], whose payload is JSON and so cannot carry raw bytes.
328/// The server side decodes this canonical form, which it produced itself and which
329/// therefore cannot fail.
330fn decode_object_content(content: &str, action: &Value) -> Result<String> {
331    use base64::Engine;
332    let engine = base64::engine::general_purpose::STANDARD;
333
334    let encoding = action
335        .get("encoding")
336        .and_then(|v| v.as_str())
337        .unwrap_or("utf8");
338
339    match encoding {
340        "utf8" => Ok(engine.encode(content.as_bytes())),
341        "base64" => {
342            // Tolerate the whitespace and line wrapping models often emit.
343            let cleaned: String = content
344                .chars()
345                .filter(|c| !c.is_ascii_whitespace())
346                .collect();
347            engine
348                .decode(cleaned.as_bytes())
349                .map(|_| cleaned)
350                .map_err(|e| {
351                    anyhow::anyhow!(
352                        "Invalid base64 in 'content': {e}. Use standard base64 with padding, e.g. \
353                     \"SGVsbG8=\" for the 5 bytes 'Hello'. To send this string as literal text, \
354                     omit 'encoding' or set it to \"utf8\"."
355                    )
356                })
357        }
358        other => Err(anyhow::anyhow!(
359            "Invalid 'encoding' value {other:?}. Valid values are \"utf8\" (default, send the \
360             characters of 'content' as-is) and \"base64\" (decode 'content' as base64-encoded \
361             bytes)."
362        )),
363    }
364}
365
366pub fn get_s3_event_types() -> Vec<EventType> {
367    vec![S3_REQUEST_EVENT.clone()]
368}
369
370impl crate::llm::actions::protocol_trait::Protocol for S3Protocol {
371    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
372        // Deliberately empty. `port` is a top-level `open_server` field, not a startup
373        // parameter, and this protocol performs no authentication: `require_authentication`,
374        // `access_key`, `secret_key` and `region` used to be declared here but `spawn()`
375        // never read any of them, so accepting them told the caller that requests would be
376        // authenticated when every request was in fact served unconditionally.
377        vec![]
378    }
379
380    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
381        // No async actions for S3 currently
382        vec![]
383    }
384
385    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
386        vec![
387            send_s3_object_action(),
388            send_s3_object_list_action(),
389            send_s3_bucket_list_action(),
390            send_s3_write_result_action(),
391            send_s3_error_action(),
392        ]
393    }
394
395    fn protocol_name(&self) -> &'static str {
396        "S3"
397    }
398
399    fn get_event_types(&self) -> Vec<EventType> {
400        get_s3_event_types()
401    }
402
403    fn stack_name(&self) -> &'static str {
404        "ETH>IP>TCP>HTTP>S3"
405    }
406
407    fn keywords(&self) -> Vec<&'static str> {
408        vec!["s3", "aws s3", "s3 bucket", "object storage", "minio"]
409    }
410
411    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
412        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
413
414        ProtocolMetadataV2::builder()
415            .state(DevelopmentState::Beta)
416            .implementation("hyper v1.5 HTTP with manual S3 REST API")
417            .llm_control("All S3 operations (GetObject, PutObject, ListBuckets)")
418            .e2e_testing(
419                "TWO independent clients, neither #[ignore]d and neither able to skip. \
420                 (1) rust-s3 0.37 (`Bucket`, path-style) in tests/server/s3/e2e_test.rs: \
421                 ListObjects, GetObject, PutObject, HeadObject and DeleteObject, each pinned to \
422                 expect_calls(1) through a retry helper so a response rust-s3 REJECTED would \
423                 retry and break the count. \
424                 (2) the real `aws s3api` CLI (botocore) in tests/server/s3/real_client_test.rs: \
425                 it parses our hand-written ListBucketResult XML itself and prints JSON, and the \
426                 test asserts two objects, both keys -- including one containing a `/`, the one \
427                 most likely to be mangled by escaping -- and Size as a number; then GetObject \
428                 writes the body to a file and the bytes are compared. Verified by adding a \
429                 third object to the listing, which the test then reports. \
430                 S3 is the one protocol in the AWS family where the second client exercises a \
431                 hand-written DOCUMENT rather than a serde struct, which is why an element name \
432                 or namespace our own parser round-trips happily is worth a second reader. \
433                 botocore is forced to path-style addressing (AWS_S3_ADDRESSING_STYLE); its \
434                 default puts the bucket in the hostname, which is neither resolvable here nor \
435                 what this server routes. \
436                 THE TEST CANNOT REACH REAL AWS: --endpoint-url on every call, the port asserted \
437                 non-zero before the CLI is spawned, and credentials, region, profile and the \
438                 EC2 metadata service overridden in the child's environment -- the sibling \
439                 DynamoDB client once signed real requests against real AWS by dropping its \
440                 target. \
441                 UNPROVEN, and worth saying plainly: NO SIGNATURE IS VALIDATED. Also unproven: \
442                 multipart upload, versioning, pagination past the first page, and range GETs.",
443            )
444
445            .notes("Virtual objects (no persistence); no SigV4 auth; binary bodies via encoding=base64")
446            .max_inbound_bytes(crate::server::s3::MAX_REQUEST_BYTES)
447            .request_only("S3 is HTTP request/response; a server cannot send a peer anything unprompted")
448            .build()
449    }
450
451    fn description(&self) -> &'static str {
452        "S3-compatible object storage server"
453    }
454
455    fn example_prompt(&self) -> &'static str {
456        "Start an S3-compatible server on port 9000 with a test-bucket containing hello.txt"
457    }
458
459    fn group_name(&self) -> &'static str {
460        "Web & File"
461    }
462    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
463        use crate::llm::actions::StartupExamples;
464        use serde_json::json;
465
466        // Deterministic S3 responses routed on the parsed operation: one bucket,
467        // one canned object, NoSuchKey for everything else. No LLM call.
468        let script = r#"import json, sys
469data = json.load(sys.stdin)
470event = data["event"]
471if data["event_type_id"] == "s3_request":
472    op = event.get("operation", "")
473    if op == "ListBuckets":
474        actions = [{"type": "send_s3_bucket_list",
475                    "buckets": [{"name": "test-bucket",
476                                 "creation_date": "2024-01-01T00:00:00Z"}]}]
477    elif op == "GetObject":
478        actions = [{"type": "send_s3_object", "content": "Hello, World!",
479                    "encoding": "utf8", "content_type": "text/plain"}]
480    else:
481        actions = [{"type": "send_s3_error", "error_code": "NoSuchKey",
482                    "message": "The specified key does not exist",
483                    "status_code": 404}]
484else:
485    actions = []
486print(json.dumps({"actions": actions}))"#;
487
488        StartupExamples::new(
489            // LLM mode: generate object contents on demand from the request key.
490            json!({
491                "type": "open_server",
492                "port": 9000,
493                "base_stack": "s3",
494                "instruction": "Serve an S3 bucket named 'reports'. When a client GETs any object whose key ends in .json, generate a small JSON report containing the current date and the object key; for .txt objects return a short generated note. Remember which keys have been requested and list them when the bucket is listed."
495            }),
496            // Script mode: fixed bucket/object responses, no LLM call.
497            json!({
498                "type": "open_server",
499                "port": 9000,
500                "base_stack": "s3",
501                "event_handlers": [{
502                    "event_pattern": "s3_request",
503                    "handler": {
504                        "type": "script",
505                        "language": "python",
506                        "code": script
507                    }
508                }]
509            }),
510            // Static mode: Fixed responses
511            json!({
512                "type": "open_server",
513                "port": 9000,
514                "base_stack": "s3",
515                "event_handlers": [{
516                    "event_pattern": "s3_request",
517                    "handler": {
518                        "type": "static",
519                        "actions": [{
520                            "type": "send_s3_bucket_list",
521                            "buckets": [{"name": "test-bucket", "creation_date": "2024-01-01T00:00:00Z"}]
522                        }]
523                    }
524                }]
525            }),
526        )
527    }
528}
529
530impl Server for S3Protocol {
531    fn spawn(
532        &self,
533        ctx: crate::protocol::SpawnContext,
534    ) -> std::pin::Pin<
535        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
536    > {
537        Box::pin(async move {
538            use crate::server::s3::S3Server;
539            S3Server::spawn_with_llm_actions(
540                ctx.legacy_listen_addr(),
541                ctx.llm_client,
542                ctx.state,
543                ctx.status_tx,
544                ctx.server_id,
545            )
546            .await
547        })
548    }
549
550    fn execute_action(&self, action: Value) -> Result<ActionResult> {
551        let action_type = action
552            .get("type")
553            .and_then(|v| v.as_str())
554            .ok_or_else(|| anyhow::anyhow!("Missing action type"))?;
555
556        match action_type {
557            "send_s3_object" => {
558                let content = action
559                    .get("content")
560                    .and_then(|v| v.as_str())
561                    .ok_or_else(|| anyhow::anyhow!("Missing content"))?;
562
563                // Decode here so an invalid 'encoding' or malformed base64 surfaces as an
564                // action failure the model is told about, instead of silently putting the
565                // literal characters of a base64 string into the object body.
566                let content_b64 = decode_object_content(content, &action)?;
567
568                let content_type = action
569                    .get("content_type")
570                    .and_then(|v| v.as_str())
571                    .map(|s| s.to_string());
572
573                let etag = action
574                    .get("etag")
575                    .and_then(|v| v.as_str())
576                    .map(|s| s.to_string());
577
578                Ok(ActionResult::Custom {
579                    name: "s3_object".to_string(),
580                    data: json!({
581                        "content_b64": content_b64,
582                        "content_type": content_type,
583                        "etag": etag
584                    }),
585                })
586            }
587            "send_s3_object_list" => {
588                let objects = action
589                    .get("objects")
590                    .ok_or_else(|| anyhow::anyhow!("Missing objects"))?
591                    .clone();
592
593                let is_truncated = action
594                    .get("is_truncated")
595                    .and_then(|v| v.as_bool())
596                    .unwrap_or(false);
597
598                Ok(ActionResult::Custom {
599                    name: "s3_object_list".to_string(),
600                    data: json!({
601                        "objects": objects,
602                        "is_truncated": is_truncated
603                    }),
604                })
605            }
606            "send_s3_bucket_list" => {
607                let buckets = action
608                    .get("buckets")
609                    .ok_or_else(|| anyhow::anyhow!("Missing buckets"))?
610                    .clone();
611
612                Ok(ActionResult::Custom {
613                    name: "s3_bucket_list".to_string(),
614                    data: json!({
615                        "buckets": buckets
616                    }),
617                })
618            }
619            "send_s3_write_result" => {
620                // Every field is optional: the four operations this answers differ in which
621                // headers they carry, and a model that supplies none still gets a valid 200.
622                // What is NOT optional is emitting the action at all — that is the point.
623                // Range-check before narrowing. This check used to sit *after* `as u16`, and
624                // `65736 as u16` is 200 — a wrapped value passed it and was then
625                // indistinguishable from a model that legitimately asked for 200, so an
626                // explicit refusal arrived at the peer as a successful write.
627                let status_code = action
628                    .get("status_code")
629                    .and_then(|v| v.as_u64())
630                    .unwrap_or(200);
631                if !(100..=599).contains(&status_code) {
632                    anyhow::bail!(
633                        "send_s3_write_result status_code {} is outside 100-599",
634                        status_code
635                    );
636                }
637                let status_code = status_code as u16;
638                let mut data = json!({ "status_code": status_code });
639                for field in ["etag", "location", "content_type", "last_modified"] {
640                    if let Some(v) = action.get(field).and_then(|v| v.as_str()) {
641                        data[field] = json!(v);
642                    }
643                }
644                if let Some(v) = action.get("content_length").and_then(|v| v.as_u64()) {
645                    data["content_length"] = json!(v);
646                }
647
648                Ok(ActionResult::Custom {
649                    name: "s3_write_result".to_string(),
650                    data,
651                })
652            }
653            "send_s3_error" => {
654                let error_code = action
655                    .get("error_code")
656                    .and_then(|v| v.as_str())
657                    .ok_or_else(|| anyhow::anyhow!("Missing error_code"))?
658                    .to_string();
659
660                let message = action
661                    .get("message")
662                    .and_then(|v| v.as_str())
663                    .ok_or_else(|| anyhow::anyhow!("Missing message"))?
664                    .to_string();
665
666                // Range-check before narrowing: `as u16` on a u64 wraps, so 65540 became 4
667                // and reached `StatusCode::from_u16`, which rejected it — the peer then got a
668                // 500 and the model was told nothing. `send_s3_write_result` has checked this
669                // since it was written; this arm had not.
670                let status_code = action
671                    .get("status_code")
672                    .and_then(|v| v.as_u64())
673                    .ok_or_else(|| anyhow::anyhow!("Missing or invalid status_code"))?;
674                if !(100..=599).contains(&status_code) {
675                    anyhow::bail!(
676                        "send_s3_error status_code {} is outside 100-599. S3 uses 404 for \
677                         NoSuchKey/NoSuchBucket, 403 for AccessDenied and 500 for InternalError.",
678                        status_code
679                    );
680                }
681                let status_code = status_code as u16;
682
683                Ok(ActionResult::Custom {
684                    name: "s3_error".to_string(),
685                    data: json!({
686                        "error_code": error_code,
687                        "message": message,
688                        "status_code": status_code
689                    }),
690                })
691            }
692            _ => Err(anyhow::anyhow!("Unknown action type: {}", action_type)),
693        }
694    }
695}