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_actions_for(&self, request: &AwsRequest) -> Vec<crate::auth::IamAction> {
761 self.iam_action_for(request).into_iter().collect()
762 }
763
764 fn iam_condition_keys_for(
784 &self,
785 _request: &AwsRequest,
786 _action: &crate::auth::IamAction,
787 ) -> BTreeMap<String, Vec<String>> {
788 BTreeMap::new()
789 }
790
791 fn resource_tags_for(
803 &self,
804 _resource_arn: &str,
805 ) -> Option<std::collections::HashMap<String, String>> {
806 None
807 }
808
809 fn request_tags_from(
819 &self,
820 _request: &AwsRequest,
821 _action: &str,
822 ) -> Option<std::collections::HashMap<String, String>> {
823 None
824 }
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use crate::auth::IamAction;
831 use async_trait::async_trait;
832
833 fn aws_chunked_body(payload: &[u8], chunk_size: usize, with_trailer: bool) -> Vec<u8> {
836 let sig = "0".repeat(64);
837 let mut out = Vec::new();
838 for c in payload.chunks(chunk_size.max(1)) {
839 out.extend_from_slice(format!("{:x};chunk-signature={sig}\r\n", c.len()).as_bytes());
840 out.extend_from_slice(c);
841 out.extend_from_slice(b"\r\n");
842 }
843 out.extend_from_slice(format!("0;chunk-signature={sig}\r\n").as_bytes());
844 if with_trailer {
845 out.extend_from_slice(b"x-amz-checksum-crc32:AAAAAA==\r\n");
846 }
847 out.extend_from_slice(b"\r\n");
848 out
849 }
850
851 fn decode_all(body: &[u8], feed_size: usize) -> Vec<u8> {
852 let mut d = AwsChunkedDecoder::default();
853 let mut out = Vec::new();
854 for frame in body.chunks(feed_size.max(1)) {
855 out.extend(d.feed(frame).expect("valid chunked body"));
856 }
857 out
858 }
859
860 #[test]
861 fn aws_chunked_decoder_roundtrips_across_frame_boundaries() {
862 let payload: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
863 for with_trailer in [false, true] {
865 let body = aws_chunked_body(&payload, 1024, with_trailer);
866 for feed in [1usize, 7, 64, 1000, body.len()] {
869 let decoded = decode_all(&body, feed);
870 assert_eq!(decoded, payload, "feed={feed} trailer={with_trailer}");
871 }
872 }
873 }
874
875 #[test]
876 fn aws_chunked_decoder_handles_empty_payload() {
877 let body = aws_chunked_body(b"", 1024, false);
878 assert_eq!(decode_all(&body, 3), Vec::<u8>::new());
879 }
880
881 fn sha256_hex(bytes: &[u8]) -> String {
882 let mut h = sha2::Sha256::new();
883 h.update(bytes);
884 hex_lower(&h.finalize())
885 }
886
887 #[tokio::test]
888 async fn spool_computes_sha256_over_plain_payload() {
889 let payload = b"hello world".to_vec();
890 let spooled = spool_request_stream(axum::body::Body::from(payload.clone()), None, false)
891 .await
892 .expect("spool ok");
893 assert_eq!(spooled.size, payload.len() as u64);
894 assert_eq!(spooled.sha256_hex, sha256_hex(&payload));
895 let _ = std::fs::remove_file(&spooled.path);
896 }
897
898 #[tokio::test]
899 async fn spool_sha256_is_over_decoded_aws_chunked_payload() {
900 let payload: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
904 let body = aws_chunked_body(&payload, 1024, true);
905 let spooled = spool_request_stream(axum::body::Body::from(body), None, true)
906 .await
907 .expect("spool ok");
908 assert_eq!(spooled.size, payload.len() as u64);
909 assert_eq!(spooled.sha256_hex, sha256_hex(&payload));
910 let _ = std::fs::remove_file(&spooled.path);
911 }
912
913 #[test]
914 fn aws_chunked_decoder_rejects_bad_size_line() {
915 let mut d = AwsChunkedDecoder::default();
916 assert!(d.feed(b"zz;chunk-signature=x\r\n").is_err());
917 }
918
919 #[test]
920 fn is_aws_chunked_detects_streaming_markers() {
921 let mut h = http::HeaderMap::new();
922 assert!(!is_aws_chunked(&h));
923 h.insert("content-encoding", "aws-chunked".parse().unwrap());
924 assert!(is_aws_chunked(&h));
925 let mut h2 = http::HeaderMap::new();
926 h2.insert(
927 "x-amz-content-sha256",
928 "STREAMING-AWS4-HMAC-SHA256-PAYLOAD".parse().unwrap(),
929 );
930 assert!(is_aws_chunked(&h2));
931 let mut h3 = http::HeaderMap::new();
933 h3.insert("content-encoding", "gzip".parse().unwrap());
934 assert!(!is_aws_chunked(&h3));
935 }
936
937 #[test]
938 fn strip_aws_chunked_keeps_real_encoding() {
939 assert_eq!(strip_aws_chunked_encoding(Some("aws-chunked")), None);
940 assert_eq!(
941 strip_aws_chunked_encoding(Some("aws-chunked, gzip")).as_deref(),
942 Some("gzip")
943 );
944 assert_eq!(
945 strip_aws_chunked_encoding(Some("gzip")).as_deref(),
946 Some("gzip")
947 );
948 assert_eq!(strip_aws_chunked_encoding(None), None);
949 }
950
951 struct DefaultService;
952
953 #[async_trait]
954 impl AwsService for DefaultService {
955 fn service_name(&self) -> &str {
956 "default"
957 }
958 async fn handle(&self, _request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
959 unreachable!()
960 }
961 fn supported_actions(&self) -> &[&str] {
962 &[]
963 }
964 }
965
966 struct PopulatedService;
967
968 #[async_trait]
969 impl AwsService for PopulatedService {
970 fn service_name(&self) -> &str {
971 "populated"
972 }
973 async fn handle(&self, _request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
974 unreachable!()
975 }
976 fn supported_actions(&self) -> &[&str] {
977 &[]
978 }
979 fn iam_condition_keys_for(
980 &self,
981 _request: &AwsRequest,
982 _action: &IamAction,
983 ) -> BTreeMap<String, Vec<String>> {
984 let mut m = BTreeMap::new();
985 m.insert("s3:prefix".to_string(), vec!["logs/".to_string()]);
986 m
987 }
988 }
989
990 fn sample_request() -> AwsRequest {
991 AwsRequest {
992 service: "default".into(),
993 action: "Noop".into(),
994 region: "us-east-1".into(),
995 account_id: "123456789012".into(),
996 request_id: "req-1".into(),
997 headers: HeaderMap::new(),
998 query_params: HashMap::new(),
999 body: Bytes::new(),
1000 body_stream: parking_lot::Mutex::new(None),
1001 path_segments: vec![],
1002 raw_path: "/".into(),
1003 raw_query: String::new(),
1004 method: Method::GET,
1005 is_query_protocol: false,
1006 access_key_id: None,
1007 principal: None,
1008 }
1009 }
1010
1011 fn sample_action() -> IamAction {
1012 IamAction {
1013 service: "s3",
1014 action: "ListBucket",
1015 resource: "arn:aws:s3:::my-bucket".to_string(),
1016 }
1017 }
1018
1019 #[test]
1020 fn iam_condition_keys_for_default_is_empty() {
1021 let svc = DefaultService;
1022 let keys = svc.iam_condition_keys_for(&sample_request(), &sample_action());
1023 assert!(keys.is_empty());
1024 }
1025
1026 #[test]
1027 fn iam_condition_keys_for_override_returns_map() {
1028 let svc = PopulatedService;
1029 let keys = svc.iam_condition_keys_for(&sample_request(), &sample_action());
1030 assert_eq!(keys.get("s3:prefix"), Some(&vec!["logs/".to_string()]));
1031 }
1032
1033 #[test]
1034 fn response_body_len_and_is_empty_for_bytes() {
1035 let body: ResponseBody = Bytes::from_static(b"hello").into();
1036 assert_eq!(body.len(), 5);
1037 assert!(!body.is_empty());
1038 let empty: ResponseBody = ResponseBody::default();
1039 assert!(empty.is_empty());
1040 }
1041
1042 #[test]
1043 fn response_body_from_vec_and_string_and_str() {
1044 let from_vec: ResponseBody = vec![1u8, 2, 3].into();
1045 assert_eq!(from_vec.expect_bytes(), &[1, 2, 3][..]);
1046 let from_string: ResponseBody = String::from("hi").into();
1047 assert_eq!(from_string.expect_bytes(), b"hi");
1048 let from_str: ResponseBody = "hey".into();
1049 assert_eq!(from_str.expect_bytes(), b"hey");
1050 let from_static: ResponseBody = (b"123" as &'static [u8]).into();
1051 assert_eq!(from_static.expect_bytes(), b"123");
1052 }
1053
1054 #[test]
1055 fn response_body_partial_eq_bytes() {
1056 let body: ResponseBody = Bytes::from_static(b"x").into();
1057 assert!(body == Bytes::from_static(b"x"));
1058 assert!(!(body == Bytes::from_static(b"y")));
1059 }
1060
1061 #[test]
1062 fn aws_request_json_body_empty_returns_null() {
1063 let req = sample_request();
1064 assert_eq!(req.json_body(), serde_json::Value::Null);
1065 }
1066
1067 #[test]
1068 fn aws_request_json_body_parses_valid() {
1069 let mut req = sample_request();
1070 req.body = Bytes::from_static(br#"{"a":1}"#);
1071 assert_eq!(req.json_body(), serde_json::json!({"a": 1}));
1072 }
1073
1074 #[test]
1075 fn aws_response_xml_constructor() {
1076 let resp = AwsResponse::xml(StatusCode::OK, Bytes::from_static(b"<ok/>"));
1077 assert_eq!(resp.status, StatusCode::OK);
1078 assert_eq!(resp.content_type, "text/xml");
1079 }
1080
1081 #[test]
1082 fn aws_response_json_constructor() {
1083 let resp = AwsResponse::json(StatusCode::CREATED, "{}");
1084 assert_eq!(resp.status, StatusCode::CREATED);
1085 assert_eq!(resp.content_type, "application/x-amz-json-1.1");
1086 }
1087
1088 #[test]
1089 fn aws_response_ok_json_helper() {
1090 let resp = AwsResponse::ok_json(serde_json::json!({"ok": true}));
1091 assert_eq!(resp.status, StatusCode::OK);
1092 assert!(resp.body.expect_bytes().starts_with(b"{"));
1093 }
1094
1095 #[test]
1096 fn aws_error_service_not_found_fields() {
1097 let err = AwsServiceError::ServiceNotFound {
1098 service: "sqs".to_string(),
1099 };
1100 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1101 assert_eq!(err.code(), "UnknownService");
1102 assert!(err.message().contains("sqs"));
1103 assert!(err.extra_fields().is_empty());
1104 assert!(err.response_headers().is_empty());
1105 }
1106
1107 #[test]
1108 fn aws_error_action_not_implemented_fields() {
1109 let err = AwsServiceError::action_not_implemented("sns", "FutureAction");
1110 assert_eq!(err.status(), StatusCode::NOT_IMPLEMENTED);
1111 assert_eq!(err.code(), "InvalidAction");
1112 assert!(err.message().contains("FutureAction"));
1113 assert!(err.message().contains("sns"));
1114 }
1115
1116 #[test]
1117 fn aws_error_aws_error_helpers() {
1118 let e = AwsServiceError::aws_error(StatusCode::FORBIDDEN, "Denied", "no");
1119 assert_eq!(e.status(), StatusCode::FORBIDDEN);
1120 assert_eq!(e.code(), "Denied");
1121 assert_eq!(e.message(), "no");
1122
1123 let fields = vec![("Bucket".to_string(), "b".to_string())];
1124 let ef = AwsServiceError::aws_error_with_fields(
1125 StatusCode::NOT_FOUND,
1126 "Missing",
1127 "gone",
1128 fields.clone(),
1129 );
1130 assert_eq!(ef.extra_fields(), fields.as_slice());
1131
1132 let hdrs = vec![("X-Retry".to_string(), "1".to_string())];
1133 let eh = AwsServiceError::aws_error_with_headers(
1134 StatusCode::TOO_MANY_REQUESTS,
1135 "Throttled",
1136 "slow",
1137 hdrs.clone(),
1138 );
1139 assert_eq!(eh.response_headers(), hdrs.as_slice());
1140 }
1141
1142 #[test]
1143 #[should_panic(expected = "expect_bytes called on ResponseBody::File")]
1144 fn response_body_expect_bytes_panics_on_file() {
1145 let f = std::fs::File::create(std::env::temp_dir().join("fc-test-expect-file")).unwrap();
1146 let async_f = tokio::fs::File::from_std(f);
1147 let body = ResponseBody::File {
1148 file: async_f,
1149 size: 0,
1150 };
1151 let _ = body.expect_bytes();
1152 }
1153}