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