1use async_trait::async_trait;
2use bytes::Bytes;
3use http::{HeaderMap, Method, StatusCode};
4use md5::{Digest, Md5};
5use parking_lot::Mutex;
6use std::collections::{BTreeMap, HashMap};
7use std::path::PathBuf;
8
9use crate::auth::Principal;
10
11pub type RequestBodyStream = axum::body::Body;
18
19pub struct AwsRequest {
21 pub service: String,
22 pub action: String,
23 pub region: String,
24 pub account_id: String,
25 pub request_id: String,
26 pub headers: HeaderMap,
27 pub query_params: HashMap<String, String>,
28 pub body: Bytes,
31 pub body_stream: Mutex<Option<RequestBodyStream>>,
36 pub path_segments: Vec<String>,
40 pub raw_path: String,
42 pub raw_query: String,
44 pub method: Method,
45 pub is_query_protocol: bool,
47 pub access_key_id: Option<String>,
49 pub principal: Option<Principal>,
56}
57
58impl std::fmt::Debug for AwsRequest {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.debug_struct("AwsRequest")
61 .field("service", &self.service)
62 .field("action", &self.action)
63 .field("region", &self.region)
64 .field("account_id", &self.account_id)
65 .field("request_id", &self.request_id)
66 .field("headers", &self.headers)
67 .field("query_params", &self.query_params)
68 .field("body_len", &self.body.len())
69 .field(
70 "body_stream",
71 &self.body_stream.lock().as_ref().map(|_| "<stream>"),
72 )
73 .field("path_segments", &self.path_segments)
74 .field("raw_path", &self.raw_path)
75 .field("raw_query", &self.raw_query)
76 .field("method", &self.method)
77 .field("is_query_protocol", &self.is_query_protocol)
78 .field("access_key_id", &self.access_key_id)
79 .field("principal", &self.principal)
80 .finish()
81 }
82}
83
84impl AwsRequest {
85 pub fn json_body(&self) -> serde_json::Value {
87 serde_json::from_slice(&self.body).unwrap_or(serde_json::Value::Null)
88 }
89
90 pub fn take_body_stream(&self) -> Option<RequestBodyStream> {
95 self.body_stream.lock().take()
96 }
97
98 pub fn query_param_all(&self, key: &str) -> Vec<String> {
105 crate::protocol::form_urlencoded_pairs(&self.raw_query)
106 .into_iter()
107 .filter_map(|(k, v)| (k == key).then_some(v))
108 .collect()
109 }
110}
111
112pub async fn drain_request_stream(stream: RequestBodyStream) -> Result<Bytes, AwsServiceError> {
122 use http_body_util::BodyExt;
123 match stream.collect().await {
124 Ok(c) => Ok(c.to_bytes()),
125 Err(e) => Err(stream_error_to_aws(&e.to_string())),
126 }
127}
128
129fn stream_error_to_aws(msg: &str) -> AwsServiceError {
130 let too_large = msg.to_ascii_lowercase().contains("limit");
135 let (status, code, message) = if too_large {
136 (
137 StatusCode::PAYLOAD_TOO_LARGE,
138 "RequestEntityTooLarge",
139 "Streaming request body exceeded the configured limit",
140 )
141 } else {
142 (
143 StatusCode::BAD_REQUEST,
144 "MalformedRequestBody",
145 "Failed to read streaming request body",
146 )
147 };
148 AwsServiceError::aws_error(status, code, message)
149}
150
151#[derive(Debug)]
162pub struct SpooledBody {
163 pub path: PathBuf,
164 pub size: u64,
165 pub md5_hex: String,
166 pub sha256_hex: String,
173}
174
175#[derive(Default)]
190pub struct AwsChunkedDecoder {
191 state: ChunkState,
192 line: Vec<u8>,
193 remaining: usize,
194 done: bool,
195}
196
197#[derive(Default, PartialEq)]
198enum ChunkState {
199 #[default]
200 Header,
201 Data,
202 AfterData,
203 Trailer,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub struct MalformedChunk;
209
210impl AwsChunkedDecoder {
211 pub fn feed(&mut self, input: &[u8]) -> Result<Vec<u8>, MalformedChunk> {
214 let mut out = Vec::new();
215 let mut i = 0;
216 while i < input.len() && !self.done {
217 match self.state {
218 ChunkState::Data => {
219 let take = self.remaining.min(input.len() - i);
220 out.extend_from_slice(&input[i..i + take]);
221 i += take;
222 self.remaining -= take;
223 if self.remaining == 0 {
224 self.state = ChunkState::AfterData;
225 }
226 }
227 ChunkState::AfterData => {
228 while i < input.len() {
230 let b = input[i];
231 i += 1;
232 if b == b'\n' {
233 self.state = ChunkState::Header;
234 break;
235 }
236 }
237 }
238 ChunkState::Header | ChunkState::Trailer => {
239 let is_header = self.state == ChunkState::Header;
240 while i < input.len() {
241 let b = input[i];
242 i += 1;
243 if b == b'\n' {
244 let line = std::mem::take(&mut self.line);
245 if is_header {
246 let hex_part: &[u8] =
248 line.split(|&c| c == b';').next().unwrap_or(&[]);
249 let hex = std::str::from_utf8(hex_part)
250 .map_err(|_| MalformedChunk)?
251 .trim();
252 let size =
253 usize::from_str_radix(hex, 16).map_err(|_| MalformedChunk)?;
254 if size == 0 {
255 self.state = ChunkState::Trailer;
256 } else {
257 self.remaining = size;
258 self.state = ChunkState::Data;
259 }
260 } else if line.is_empty() {
261 self.done = true;
263 }
264 break;
266 } else if b != b'\r' {
267 self.line.push(b);
268 }
269 }
270 }
271 }
272 }
273 Ok(out)
274 }
275}
276
277pub fn is_aws_chunked(headers: &http::HeaderMap) -> bool {
282 headers
283 .get("content-encoding")
284 .and_then(|v| v.to_str().ok())
285 .is_some_and(|v| {
286 v.split(',')
287 .any(|t| t.trim().eq_ignore_ascii_case("aws-chunked"))
288 })
289 || headers
290 .get("x-amz-content-sha256")
291 .and_then(|v| v.to_str().ok())
292 .is_some_and(|v| v.starts_with("STREAMING-"))
293}
294
295pub fn strip_aws_chunked_encoding(content_encoding: Option<&str>) -> Option<String> {
300 let ce = content_encoding?;
301 let kept: Vec<&str> = ce
302 .split(',')
303 .map(|t| t.trim())
304 .filter(|t| !t.is_empty() && !t.eq_ignore_ascii_case("aws-chunked"))
305 .collect();
306 if kept.is_empty() {
307 None
308 } else {
309 Some(kept.join(", "))
310 }
311}
312
313pub async fn spool_request_stream(
331 stream: RequestBodyStream,
332 dir: Option<&std::path::Path>,
333 aws_chunked: bool,
334) -> Result<SpooledBody, AwsServiceError> {
335 use http_body_util::BodyExt;
336 use tokio::io::AsyncWriteExt;
337
338 let dir = dir.map(|d| d.to_path_buf());
339 if let Some(d) = dir.as_ref() {
340 let _ = tokio::fs::create_dir_all(d).await;
342 }
343
344 let mut builder = tempfile::Builder::new();
345 builder.prefix("fc-spool-");
346 let named = match dir.as_ref() {
347 Some(d) => builder.tempfile_in(d),
348 None => builder.tempfile(),
349 }
350 .map_err(|e| {
351 AwsServiceError::aws_error(
352 StatusCode::INTERNAL_SERVER_ERROR,
353 "InternalError",
354 format!("failed to create spool tempfile: {e}"),
355 )
356 })?;
357
358 let (std_file, temp_path) = named.into_parts();
361 let path: PathBuf = temp_path.keep().map_err(|e| {
364 AwsServiceError::aws_error(
365 StatusCode::INTERNAL_SERVER_ERROR,
366 "InternalError",
367 format!("failed to persist spool tempfile: {e}"),
368 )
369 })?;
370
371 let mut file = tokio::fs::File::from_std(std_file);
372 let mut hasher = Md5::new();
373 let mut sha = sha2::Sha256::new();
374 let mut size: u64 = 0;
375 let mut body = stream;
376 let mut decoder = aws_chunked.then(AwsChunkedDecoder::default);
377
378 async fn cleanup(file: tokio::fs::File, path: &std::path::Path) {
383 drop(file);
384 let _ = tokio::fs::remove_file(path).await;
385 }
386
387 loop {
388 match body.frame().await {
389 Some(Ok(frame)) => {
390 if let Ok(raw) = frame.into_data() {
391 if !raw.is_empty() {
392 let payload = match decoder.as_mut() {
395 Some(d) => match d.feed(&raw) {
396 Ok(decoded) => decoded,
397 Err(_) => {
398 cleanup(file, &path).await;
399 return Err(AwsServiceError::aws_error(
400 StatusCode::BAD_REQUEST,
401 "InvalidChunkSizeError",
402 "Malformed aws-chunked request body",
403 ));
404 }
405 },
406 None => raw.to_vec(),
407 };
408 if !payload.is_empty() {
409 hasher.update(&payload);
410 sha.update(&payload);
411 size += payload.len() as u64;
412 if let Err(e) = file.write_all(&payload).await {
413 cleanup(file, &path).await;
414 return Err(AwsServiceError::aws_error(
415 StatusCode::INTERNAL_SERVER_ERROR,
416 "InternalError",
417 format!("failed to spool request body: {e}"),
418 ));
419 }
420 }
421 }
422 }
423 }
426 Some(Err(e)) => {
427 cleanup(file, &path).await;
428 return Err(stream_error_to_aws(&e.to_string()));
429 }
430 None => break,
431 }
432 }
433
434 if let Err(e) = file.flush().await {
435 cleanup(file, &path).await;
436 return Err(AwsServiceError::aws_error(
437 StatusCode::INTERNAL_SERVER_ERROR,
438 "InternalError",
439 format!("failed to flush spool tempfile: {e}"),
440 ));
441 }
442 drop(file);
443
444 let md5_hex = hex_lower(&hasher.finalize());
445 let sha256_hex = hex_lower(&sha.finalize());
446 Ok(SpooledBody {
447 path,
448 size,
449 md5_hex,
450 sha256_hex,
451 })
452}
453
454fn hex_lower(bytes: &[u8]) -> String {
455 const HEX: &[u8] = b"0123456789abcdef";
456 let mut out = String::with_capacity(bytes.len() * 2);
457 for b in bytes {
458 out.push(HEX[(b >> 4) as usize] as char);
459 out.push(HEX[(b & 0x0f) as usize] as char);
460 }
461 out
462}
463
464#[derive(Debug)]
473pub enum ResponseBody {
474 Bytes(Bytes),
475 File { file: tokio::fs::File, size: u64 },
476}
477
478impl ResponseBody {
479 pub fn len(&self) -> u64 {
480 match self {
481 ResponseBody::Bytes(b) => b.len() as u64,
482 ResponseBody::File { size, .. } => *size,
483 }
484 }
485
486 pub fn is_empty(&self) -> bool {
487 self.len() == 0
488 }
489
490 pub fn expect_bytes(&self) -> &[u8] {
494 match self {
495 ResponseBody::Bytes(b) => b,
496 ResponseBody::File { .. } => {
497 panic!("expect_bytes called on ResponseBody::File")
498 }
499 }
500 }
501}
502
503impl Default for ResponseBody {
504 fn default() -> Self {
505 ResponseBody::Bytes(Bytes::new())
506 }
507}
508
509impl From<Bytes> for ResponseBody {
510 fn from(b: Bytes) -> Self {
511 ResponseBody::Bytes(b)
512 }
513}
514
515impl From<Vec<u8>> for ResponseBody {
516 fn from(v: Vec<u8>) -> Self {
517 ResponseBody::Bytes(Bytes::from(v))
518 }
519}
520
521impl From<&'static [u8]> for ResponseBody {
522 fn from(s: &'static [u8]) -> Self {
523 ResponseBody::Bytes(Bytes::from_static(s))
524 }
525}
526
527impl From<String> for ResponseBody {
528 fn from(s: String) -> Self {
529 ResponseBody::Bytes(Bytes::from(s))
530 }
531}
532
533impl From<&'static str> for ResponseBody {
534 fn from(s: &'static str) -> Self {
535 ResponseBody::Bytes(Bytes::from_static(s.as_bytes()))
536 }
537}
538
539impl PartialEq<Bytes> for ResponseBody {
540 fn eq(&self, other: &Bytes) -> bool {
541 match self {
542 ResponseBody::Bytes(b) => b == other,
543 ResponseBody::File { .. } => false,
544 }
545 }
546}
547
548pub struct AwsResponse {
550 pub status: StatusCode,
551 pub content_type: String,
552 pub body: ResponseBody,
553 pub headers: HeaderMap,
554}
555
556impl AwsResponse {
557 pub fn xml(status: StatusCode, body: impl Into<Bytes>) -> Self {
558 Self {
559 status,
560 content_type: "text/xml".to_string(),
561 body: ResponseBody::Bytes(body.into()),
562 headers: HeaderMap::new(),
563 }
564 }
565
566 pub fn json(status: StatusCode, body: impl Into<Bytes>) -> Self {
567 Self {
568 status,
569 content_type: "application/x-amz-json-1.1".to_string(),
570 body: ResponseBody::Bytes(body.into()),
571 headers: HeaderMap::new(),
572 }
573 }
574
575 pub fn json_value(status: StatusCode, value: serde_json::Value) -> Self {
581 Self::json(
582 status,
583 serde_json::to_vec(&value).expect("serde_json::Value serialization is infallible"),
584 )
585 }
586
587 pub fn ok_json(value: serde_json::Value) -> Self {
589 Self::json_value(StatusCode::OK, value)
590 }
591}
592
593#[derive(Debug, thiserror::Error)]
595pub enum AwsServiceError {
596 #[error("service not found: {service}")]
597 ServiceNotFound { service: String },
598
599 #[error("action {action} not implemented for service {service}")]
600 ActionNotImplemented { service: String, action: String },
601
602 #[error("{code}: {message}")]
603 AwsError {
604 status: StatusCode,
605 code: String,
606 message: String,
607 extra_fields: Vec<(String, String)>,
609 headers: Vec<(String, String)>,
611 },
612}
613
614impl AwsServiceError {
615 pub fn action_not_implemented(service: &str, action: &str) -> Self {
616 Self::ActionNotImplemented {
617 service: service.to_string(),
618 action: action.to_string(),
619 }
620 }
621
622 pub fn aws_error(
623 status: StatusCode,
624 code: impl Into<String>,
625 message: impl Into<String>,
626 ) -> Self {
627 Self::AwsError {
628 status,
629 code: code.into(),
630 message: message.into(),
631 extra_fields: Vec::new(),
632 headers: Vec::new(),
633 }
634 }
635
636 pub fn aws_error_with_fields(
637 status: StatusCode,
638 code: impl Into<String>,
639 message: impl Into<String>,
640 extra_fields: Vec<(String, String)>,
641 ) -> Self {
642 Self::AwsError {
643 status,
644 code: code.into(),
645 message: message.into(),
646 extra_fields,
647 headers: Vec::new(),
648 }
649 }
650
651 pub fn aws_error_with_headers(
652 status: StatusCode,
653 code: impl Into<String>,
654 message: impl Into<String>,
655 headers: Vec<(String, String)>,
656 ) -> Self {
657 Self::AwsError {
658 status,
659 code: code.into(),
660 message: message.into(),
661 extra_fields: Vec::new(),
662 headers,
663 }
664 }
665
666 pub fn extra_fields(&self) -> &[(String, String)] {
667 match self {
668 Self::AwsError { extra_fields, .. } => extra_fields,
669 _ => &[],
670 }
671 }
672
673 pub fn status(&self) -> StatusCode {
674 match self {
675 Self::ServiceNotFound { .. } => StatusCode::BAD_REQUEST,
676 Self::ActionNotImplemented { .. } => StatusCode::NOT_IMPLEMENTED,
677 Self::AwsError { status, .. } => *status,
678 }
679 }
680
681 pub fn code(&self) -> &str {
682 match self {
683 Self::ServiceNotFound { .. } => "UnknownService",
684 Self::ActionNotImplemented { .. } => "InvalidAction",
685 Self::AwsError { code, .. } => code,
686 }
687 }
688
689 pub fn message(&self) -> String {
690 match self {
691 Self::ServiceNotFound { service } => format!("service not found: {service}"),
692 Self::ActionNotImplemented { service, action } => {
693 format!("action {action} not implemented for service {service}")
694 }
695 Self::AwsError { message, .. } => message.clone(),
696 }
697 }
698
699 pub fn response_headers(&self) -> &[(String, String)] {
700 match self {
701 Self::AwsError { headers, .. } => headers,
702 _ => &[],
703 }
704 }
705}
706
707#[async_trait]
709pub trait AwsService: Send + Sync {
710 fn service_name(&self) -> &str;
712
713 async fn handle(&self, request: AwsRequest) -> Result<AwsResponse, AwsServiceError>;
715
716 fn supported_actions(&self) -> &[&str];
718
719 fn iam_enforceable(&self) -> bool {
734 false
735 }
736
737 fn iam_action_for(&self, _request: &AwsRequest) -> Option<crate::auth::IamAction> {
750 None
751 }
752
753 fn iam_actions_for(&self, request: &AwsRequest) -> Vec<crate::auth::IamAction> {
764 self.iam_action_for(request).into_iter().collect()
765 }
766
767 fn iam_resource_in_caller_account(&self, _request: &AwsRequest) -> bool {
775 false
776 }
777
778 fn iam_condition_keys_for(
798 &self,
799 _request: &AwsRequest,
800 _action: &crate::auth::IamAction,
801 ) -> BTreeMap<String, Vec<String>> {
802 BTreeMap::new()
803 }
804
805 fn resource_tags_for(
817 &self,
818 _resource_arn: &str,
819 ) -> Option<std::collections::HashMap<String, String>> {
820 None
821 }
822
823 fn request_tags_from(
833 &self,
834 _request: &AwsRequest,
835 _action: &str,
836 ) -> Option<std::collections::HashMap<String, String>> {
837 None
838 }
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844 use crate::auth::IamAction;
845 use async_trait::async_trait;
846
847 fn aws_chunked_body(payload: &[u8], chunk_size: usize, with_trailer: bool) -> Vec<u8> {
850 let sig = "0".repeat(64);
851 let mut out = Vec::new();
852 for c in payload.chunks(chunk_size.max(1)) {
853 out.extend_from_slice(format!("{:x};chunk-signature={sig}\r\n", c.len()).as_bytes());
854 out.extend_from_slice(c);
855 out.extend_from_slice(b"\r\n");
856 }
857 out.extend_from_slice(format!("0;chunk-signature={sig}\r\n").as_bytes());
858 if with_trailer {
859 out.extend_from_slice(b"x-amz-checksum-crc32:AAAAAA==\r\n");
860 }
861 out.extend_from_slice(b"\r\n");
862 out
863 }
864
865 fn decode_all(body: &[u8], feed_size: usize) -> Vec<u8> {
866 let mut d = AwsChunkedDecoder::default();
867 let mut out = Vec::new();
868 for frame in body.chunks(feed_size.max(1)) {
869 out.extend(d.feed(frame).expect("valid chunked body"));
870 }
871 out
872 }
873
874 #[test]
875 fn aws_chunked_decoder_roundtrips_across_frame_boundaries() {
876 let payload: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
877 for with_trailer in [false, true] {
879 let body = aws_chunked_body(&payload, 1024, with_trailer);
880 for feed in [1usize, 7, 64, 1000, body.len()] {
883 let decoded = decode_all(&body, feed);
884 assert_eq!(decoded, payload, "feed={feed} trailer={with_trailer}");
885 }
886 }
887 }
888
889 #[test]
890 fn aws_chunked_decoder_handles_empty_payload() {
891 let body = aws_chunked_body(b"", 1024, false);
892 assert_eq!(decode_all(&body, 3), Vec::<u8>::new());
893 }
894
895 fn sha256_hex(bytes: &[u8]) -> String {
896 let mut h = sha2::Sha256::new();
897 h.update(bytes);
898 hex_lower(&h.finalize())
899 }
900
901 #[tokio::test]
902 async fn spool_computes_sha256_over_plain_payload() {
903 let payload = b"hello world".to_vec();
904 let spooled = spool_request_stream(axum::body::Body::from(payload.clone()), None, false)
905 .await
906 .expect("spool ok");
907 assert_eq!(spooled.size, payload.len() as u64);
908 assert_eq!(spooled.sha256_hex, sha256_hex(&payload));
909 let _ = std::fs::remove_file(&spooled.path);
910 }
911
912 #[tokio::test]
913 async fn spool_sha256_is_over_decoded_aws_chunked_payload() {
914 let payload: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
918 let body = aws_chunked_body(&payload, 1024, true);
919 let spooled = spool_request_stream(axum::body::Body::from(body), None, true)
920 .await
921 .expect("spool ok");
922 assert_eq!(spooled.size, payload.len() as u64);
923 assert_eq!(spooled.sha256_hex, sha256_hex(&payload));
924 let _ = std::fs::remove_file(&spooled.path);
925 }
926
927 #[test]
928 fn aws_chunked_decoder_rejects_bad_size_line() {
929 let mut d = AwsChunkedDecoder::default();
930 assert!(d.feed(b"zz;chunk-signature=x\r\n").is_err());
931 }
932
933 #[test]
934 fn is_aws_chunked_detects_streaming_markers() {
935 let mut h = http::HeaderMap::new();
936 assert!(!is_aws_chunked(&h));
937 h.insert("content-encoding", "aws-chunked".parse().unwrap());
938 assert!(is_aws_chunked(&h));
939 let mut h2 = http::HeaderMap::new();
940 h2.insert(
941 "x-amz-content-sha256",
942 "STREAMING-AWS4-HMAC-SHA256-PAYLOAD".parse().unwrap(),
943 );
944 assert!(is_aws_chunked(&h2));
945 let mut h3 = http::HeaderMap::new();
947 h3.insert("content-encoding", "gzip".parse().unwrap());
948 assert!(!is_aws_chunked(&h3));
949 }
950
951 #[test]
952 fn strip_aws_chunked_keeps_real_encoding() {
953 assert_eq!(strip_aws_chunked_encoding(Some("aws-chunked")), None);
954 assert_eq!(
955 strip_aws_chunked_encoding(Some("aws-chunked, gzip")).as_deref(),
956 Some("gzip")
957 );
958 assert_eq!(
959 strip_aws_chunked_encoding(Some("gzip")).as_deref(),
960 Some("gzip")
961 );
962 assert_eq!(strip_aws_chunked_encoding(None), None);
963 }
964
965 struct DefaultService;
966
967 #[async_trait]
968 impl AwsService for DefaultService {
969 fn service_name(&self) -> &str {
970 "default"
971 }
972 async fn handle(&self, _request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
973 unreachable!()
974 }
975 fn supported_actions(&self) -> &[&str] {
976 &[]
977 }
978 }
979
980 struct PopulatedService;
981
982 #[async_trait]
983 impl AwsService for PopulatedService {
984 fn service_name(&self) -> &str {
985 "populated"
986 }
987 async fn handle(&self, _request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
988 unreachable!()
989 }
990 fn supported_actions(&self) -> &[&str] {
991 &[]
992 }
993 fn iam_condition_keys_for(
994 &self,
995 _request: &AwsRequest,
996 _action: &IamAction,
997 ) -> BTreeMap<String, Vec<String>> {
998 let mut m = BTreeMap::new();
999 m.insert("s3:prefix".to_string(), vec!["logs/".to_string()]);
1000 m
1001 }
1002 }
1003
1004 fn sample_request() -> AwsRequest {
1005 AwsRequest {
1006 service: "default".into(),
1007 action: "Noop".into(),
1008 region: "us-east-1".into(),
1009 account_id: "123456789012".into(),
1010 request_id: "req-1".into(),
1011 headers: HeaderMap::new(),
1012 query_params: HashMap::new(),
1013 body: Bytes::new(),
1014 body_stream: parking_lot::Mutex::new(None),
1015 path_segments: vec![],
1016 raw_path: "/".into(),
1017 raw_query: String::new(),
1018 method: Method::GET,
1019 is_query_protocol: false,
1020 access_key_id: None,
1021 principal: None,
1022 }
1023 }
1024
1025 fn sample_action() -> IamAction {
1026 IamAction {
1027 service: "s3",
1028 action: "ListBucket",
1029 resource: "arn:aws:s3:::my-bucket".to_string(),
1030 }
1031 }
1032
1033 #[test]
1034 fn iam_condition_keys_for_default_is_empty() {
1035 let svc = DefaultService;
1036 let keys = svc.iam_condition_keys_for(&sample_request(), &sample_action());
1037 assert!(keys.is_empty());
1038 }
1039
1040 #[test]
1041 fn iam_condition_keys_for_override_returns_map() {
1042 let svc = PopulatedService;
1043 let keys = svc.iam_condition_keys_for(&sample_request(), &sample_action());
1044 assert_eq!(keys.get("s3:prefix"), Some(&vec!["logs/".to_string()]));
1045 }
1046
1047 #[test]
1048 fn response_body_len_and_is_empty_for_bytes() {
1049 let body: ResponseBody = Bytes::from_static(b"hello").into();
1050 assert_eq!(body.len(), 5);
1051 assert!(!body.is_empty());
1052 let empty: ResponseBody = ResponseBody::default();
1053 assert!(empty.is_empty());
1054 }
1055
1056 #[test]
1057 fn response_body_from_vec_and_string_and_str() {
1058 let from_vec: ResponseBody = vec![1u8, 2, 3].into();
1059 assert_eq!(from_vec.expect_bytes(), &[1, 2, 3][..]);
1060 let from_string: ResponseBody = String::from("hi").into();
1061 assert_eq!(from_string.expect_bytes(), b"hi");
1062 let from_str: ResponseBody = "hey".into();
1063 assert_eq!(from_str.expect_bytes(), b"hey");
1064 let from_static: ResponseBody = (b"123" as &'static [u8]).into();
1065 assert_eq!(from_static.expect_bytes(), b"123");
1066 }
1067
1068 #[test]
1069 fn response_body_partial_eq_bytes() {
1070 let body: ResponseBody = Bytes::from_static(b"x").into();
1071 assert!(body == Bytes::from_static(b"x"));
1072 assert!(!(body == Bytes::from_static(b"y")));
1073 }
1074
1075 #[test]
1076 fn aws_request_json_body_empty_returns_null() {
1077 let req = sample_request();
1078 assert_eq!(req.json_body(), serde_json::Value::Null);
1079 }
1080
1081 #[test]
1082 fn aws_request_json_body_parses_valid() {
1083 let mut req = sample_request();
1084 req.body = Bytes::from_static(br#"{"a":1}"#);
1085 assert_eq!(req.json_body(), serde_json::json!({"a": 1}));
1086 }
1087
1088 #[test]
1089 fn aws_response_xml_constructor() {
1090 let resp = AwsResponse::xml(StatusCode::OK, Bytes::from_static(b"<ok/>"));
1091 assert_eq!(resp.status, StatusCode::OK);
1092 assert_eq!(resp.content_type, "text/xml");
1093 }
1094
1095 #[test]
1096 fn aws_response_json_constructor() {
1097 let resp = AwsResponse::json(StatusCode::CREATED, "{}");
1098 assert_eq!(resp.status, StatusCode::CREATED);
1099 assert_eq!(resp.content_type, "application/x-amz-json-1.1");
1100 }
1101
1102 #[test]
1103 fn aws_response_ok_json_helper() {
1104 let resp = AwsResponse::ok_json(serde_json::json!({"ok": true}));
1105 assert_eq!(resp.status, StatusCode::OK);
1106 assert!(resp.body.expect_bytes().starts_with(b"{"));
1107 }
1108
1109 #[test]
1110 fn aws_error_service_not_found_fields() {
1111 let err = AwsServiceError::ServiceNotFound {
1112 service: "sqs".to_string(),
1113 };
1114 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1115 assert_eq!(err.code(), "UnknownService");
1116 assert!(err.message().contains("sqs"));
1117 assert!(err.extra_fields().is_empty());
1118 assert!(err.response_headers().is_empty());
1119 }
1120
1121 #[test]
1122 fn aws_error_action_not_implemented_fields() {
1123 let err = AwsServiceError::action_not_implemented("sns", "FutureAction");
1124 assert_eq!(err.status(), StatusCode::NOT_IMPLEMENTED);
1125 assert_eq!(err.code(), "InvalidAction");
1126 assert!(err.message().contains("FutureAction"));
1127 assert!(err.message().contains("sns"));
1128 }
1129
1130 #[test]
1131 fn aws_error_aws_error_helpers() {
1132 let e = AwsServiceError::aws_error(StatusCode::FORBIDDEN, "Denied", "no");
1133 assert_eq!(e.status(), StatusCode::FORBIDDEN);
1134 assert_eq!(e.code(), "Denied");
1135 assert_eq!(e.message(), "no");
1136
1137 let fields = vec![("Bucket".to_string(), "b".to_string())];
1138 let ef = AwsServiceError::aws_error_with_fields(
1139 StatusCode::NOT_FOUND,
1140 "Missing",
1141 "gone",
1142 fields.clone(),
1143 );
1144 assert_eq!(ef.extra_fields(), fields.as_slice());
1145
1146 let hdrs = vec![("X-Retry".to_string(), "1".to_string())];
1147 let eh = AwsServiceError::aws_error_with_headers(
1148 StatusCode::TOO_MANY_REQUESTS,
1149 "Throttled",
1150 "slow",
1151 hdrs.clone(),
1152 );
1153 assert_eq!(eh.response_headers(), hdrs.as_slice());
1154 }
1155
1156 #[test]
1157 #[should_panic(expected = "expect_bytes called on ResponseBody::File")]
1158 fn response_body_expect_bytes_panics_on_file() {
1159 let f = std::fs::File::create(std::env::temp_dir().join("fc-test-expect-file")).unwrap();
1160 let async_f = tokio::fs::File::from_std(f);
1161 let body = ResponseBody::File {
1162 file: async_f,
1163 size: 0,
1164 };
1165 let _ = body.expect_bytes();
1166 }
1167}