1use 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
16pub struct S3Protocol {
18 }
20
21impl S3Protocol {
22 pub fn new() -> Self {
23 Self {}
24 }
25}
26
27pub 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
189fn 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
301fn 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
323fn 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 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 vec![]
378 }
379
380 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
381 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 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 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 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 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 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 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 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}