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 pub md5: [u8; 16],
175 pub sha256: [u8; 32],
177 pub trailers: Vec<(String, String)>,
182}
183
184#[derive(Default)]
200pub struct AwsChunkedDecoder {
201 state: ChunkState,
202 line: Vec<u8>,
203 remaining: usize,
204 done: bool,
205 trailers: Vec<(String, String)>,
206}
207
208#[derive(Default, PartialEq)]
209enum ChunkState {
210 #[default]
211 Header,
212 Data,
213 AfterData,
214 Trailer,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub struct MalformedChunk;
220
221impl AwsChunkedDecoder {
222 pub fn feed(&mut self, input: &[u8]) -> Result<Vec<u8>, MalformedChunk> {
225 let mut out = Vec::new();
226 let mut i = 0;
227 while i < input.len() && !self.done {
228 match self.state {
229 ChunkState::Data => {
230 let take = self.remaining.min(input.len() - i);
231 out.extend_from_slice(&input[i..i + take]);
232 i += take;
233 self.remaining -= take;
234 if self.remaining == 0 {
235 self.state = ChunkState::AfterData;
236 }
237 }
238 ChunkState::AfterData => {
239 while i < input.len() {
241 let b = input[i];
242 i += 1;
243 if b == b'\n' {
244 self.state = ChunkState::Header;
245 break;
246 }
247 }
248 }
249 ChunkState::Header | ChunkState::Trailer => {
250 let is_header = self.state == ChunkState::Header;
251 while i < input.len() {
252 let b = input[i];
253 i += 1;
254 if b == b'\n' {
255 let line = std::mem::take(&mut self.line);
256 if is_header {
257 let hex_part: &[u8] =
259 line.split(|&c| c == b';').next().unwrap_or(&[]);
260 let hex = std::str::from_utf8(hex_part)
261 .map_err(|_| MalformedChunk)?
262 .trim();
263 let size =
264 usize::from_str_radix(hex, 16).map_err(|_| MalformedChunk)?;
265 if size == 0 {
266 self.state = ChunkState::Trailer;
267 } else {
268 self.remaining = size;
269 self.state = ChunkState::Data;
270 }
271 } else if line.is_empty() {
272 self.done = true;
274 } else {
275 self.push_trailer(&line);
276 }
277 break;
278 } else if b != b'\r' {
279 self.line.push(b);
280 }
281 }
282 }
283 }
284 }
285 Ok(out)
286 }
287
288 fn push_trailer(&mut self, line: &[u8]) {
292 let text = String::from_utf8_lossy(line);
293 let Some((name, value)) = text.split_once(':') else {
294 return;
295 };
296 let name = name.trim().to_ascii_lowercase();
297 if name.is_empty() || name == "x-amz-trailer-signature" {
298 return;
299 }
300 self.trailers.push((name, value.trim().to_string()));
301 }
302
303 pub fn trailers(&self) -> &[(String, String)] {
305 &self.trailers
306 }
307
308 pub fn into_trailers(self) -> Vec<(String, String)> {
310 self.trailers
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct DecodedAwsChunked {
317 pub payload: Vec<u8>,
318 pub trailers: Vec<(String, String)>,
319}
320
321pub fn decode_aws_chunked(body: &[u8]) -> Result<DecodedAwsChunked, MalformedChunk> {
325 let mut decoder = AwsChunkedDecoder::default();
326 let payload = decoder.feed(body)?;
327 Ok(DecodedAwsChunked {
328 payload,
329 trailers: decoder.into_trailers(),
330 })
331}
332
333pub fn is_aws_chunked(headers: &http::HeaderMap) -> bool {
338 headers
339 .get("content-encoding")
340 .and_then(|v| v.to_str().ok())
341 .is_some_and(|v| {
342 v.split(',')
343 .any(|t| t.trim().eq_ignore_ascii_case("aws-chunked"))
344 })
345 || headers
346 .get("x-amz-content-sha256")
347 .and_then(|v| v.to_str().ok())
348 .is_some_and(|v| v.starts_with("STREAMING-"))
349}
350
351pub fn strip_aws_chunked_encoding(content_encoding: Option<&str>) -> Option<String> {
356 let ce = content_encoding?;
357 let kept: Vec<&str> = ce
358 .split(',')
359 .map(|t| t.trim())
360 .filter(|t| !t.is_empty() && !t.eq_ignore_ascii_case("aws-chunked"))
361 .collect();
362 if kept.is_empty() {
363 None
364 } else {
365 Some(kept.join(", "))
366 }
367}
368
369pub async fn spool_request_stream(
387 stream: RequestBodyStream,
388 dir: Option<&std::path::Path>,
389 aws_chunked: bool,
390) -> Result<SpooledBody, AwsServiceError> {
391 spool_request_stream_observed(stream, dir, aws_chunked, &mut |_| {}).await
392}
393
394pub async fn spool_request_stream_observed(
399 stream: RequestBodyStream,
400 dir: Option<&std::path::Path>,
401 aws_chunked: bool,
402 observer: &mut (dyn FnMut(&[u8]) + Send),
403) -> Result<SpooledBody, AwsServiceError> {
404 use http_body_util::BodyExt;
405 use tokio::io::AsyncWriteExt;
406
407 let dir = dir.map(|d| d.to_path_buf());
408 if let Some(d) = dir.as_ref() {
409 let _ = tokio::fs::create_dir_all(d).await;
411 }
412
413 let mut builder = tempfile::Builder::new();
414 builder.prefix("fc-spool-");
415 let named = match dir.as_ref() {
416 Some(d) => builder.tempfile_in(d),
417 None => builder.tempfile(),
418 }
419 .map_err(|e| {
420 AwsServiceError::aws_error(
421 StatusCode::INTERNAL_SERVER_ERROR,
422 "InternalError",
423 format!("failed to create spool tempfile: {e}"),
424 )
425 })?;
426
427 let (std_file, temp_path) = named.into_parts();
430 let path: PathBuf = temp_path.keep().map_err(|e| {
433 AwsServiceError::aws_error(
434 StatusCode::INTERNAL_SERVER_ERROR,
435 "InternalError",
436 format!("failed to persist spool tempfile: {e}"),
437 )
438 })?;
439
440 let mut file = tokio::fs::File::from_std(std_file);
441 let mut hasher = Md5::new();
442 let mut sha = sha2::Sha256::new();
443 let mut size: u64 = 0;
444 let mut body = stream;
445 let mut decoder = aws_chunked.then(AwsChunkedDecoder::default);
446 let mut http_trailers: Vec<(String, String)> = Vec::new();
447
448 async fn cleanup(file: tokio::fs::File, path: &std::path::Path) {
453 drop(file);
454 let _ = tokio::fs::remove_file(path).await;
455 }
456
457 loop {
458 match body.frame().await {
459 Some(Ok(frame)) => {
460 let frame = match frame.into_trailers() {
461 Ok(trailers) => {
462 for (name, value) in trailers.iter() {
463 if let Ok(v) = value.to_str() {
464 http_trailers
465 .push((name.as_str().to_string(), v.trim().to_string()));
466 }
467 }
468 continue;
469 }
470 Err(frame) => frame,
471 };
472 if let Ok(raw) = frame.into_data() {
473 if !raw.is_empty() {
474 let payload = match decoder.as_mut() {
477 Some(d) => match d.feed(&raw) {
478 Ok(decoded) => decoded,
479 Err(_) => {
480 cleanup(file, &path).await;
481 return Err(AwsServiceError::aws_error(
482 StatusCode::BAD_REQUEST,
483 "InvalidChunkSizeError",
484 "Malformed aws-chunked request body",
485 ));
486 }
487 },
488 None => raw.to_vec(),
489 };
490 if !payload.is_empty() {
491 hasher.update(&payload);
492 sha.update(&payload);
493 observer(&payload);
494 size += payload.len() as u64;
495 if let Err(e) = file.write_all(&payload).await {
496 cleanup(file, &path).await;
497 return Err(AwsServiceError::aws_error(
498 StatusCode::INTERNAL_SERVER_ERROR,
499 "InternalError",
500 format!("failed to spool request body: {e}"),
501 ));
502 }
503 }
504 }
505 }
506 }
507 Some(Err(e)) => {
508 cleanup(file, &path).await;
509 return Err(stream_error_to_aws(&e.to_string()));
510 }
511 None => break,
512 }
513 }
514
515 if let Err(e) = file.flush().await {
516 cleanup(file, &path).await;
517 return Err(AwsServiceError::aws_error(
518 StatusCode::INTERNAL_SERVER_ERROR,
519 "InternalError",
520 format!("failed to flush spool tempfile: {e}"),
521 ));
522 }
523 drop(file);
524
525 let md5: [u8; 16] = hasher.finalize().into();
526 let sha256: [u8; 32] = sha.finalize().into();
527 let md5_hex = hex_lower(&md5);
528 let sha256_hex = hex_lower(&sha256);
529 let mut trailers = decoder
530 .map(AwsChunkedDecoder::into_trailers)
531 .unwrap_or_default();
532 trailers.extend(http_trailers);
533 Ok(SpooledBody {
534 path,
535 size,
536 md5_hex,
537 sha256_hex,
538 md5,
539 sha256,
540 trailers,
541 })
542}
543
544fn hex_lower(bytes: &[u8]) -> String {
545 const HEX: &[u8] = b"0123456789abcdef";
546 let mut out = String::with_capacity(bytes.len() * 2);
547 for b in bytes {
548 out.push(HEX[(b >> 4) as usize] as char);
549 out.push(HEX[(b & 0x0f) as usize] as char);
550 }
551 out
552}
553
554#[derive(Debug)]
563pub enum ResponseBody {
564 Bytes(Bytes),
565 File { file: tokio::fs::File, size: u64 },
566}
567
568impl ResponseBody {
569 pub fn len(&self) -> u64 {
570 match self {
571 ResponseBody::Bytes(b) => b.len() as u64,
572 ResponseBody::File { size, .. } => *size,
573 }
574 }
575
576 pub fn is_empty(&self) -> bool {
577 self.len() == 0
578 }
579
580 pub fn expect_bytes(&self) -> &[u8] {
584 match self {
585 ResponseBody::Bytes(b) => b,
586 ResponseBody::File { .. } => {
587 panic!("expect_bytes called on ResponseBody::File")
588 }
589 }
590 }
591}
592
593impl Default for ResponseBody {
594 fn default() -> Self {
595 ResponseBody::Bytes(Bytes::new())
596 }
597}
598
599impl From<Bytes> for ResponseBody {
600 fn from(b: Bytes) -> Self {
601 ResponseBody::Bytes(b)
602 }
603}
604
605impl From<Vec<u8>> for ResponseBody {
606 fn from(v: Vec<u8>) -> Self {
607 ResponseBody::Bytes(Bytes::from(v))
608 }
609}
610
611impl From<&'static [u8]> for ResponseBody {
612 fn from(s: &'static [u8]) -> Self {
613 ResponseBody::Bytes(Bytes::from_static(s))
614 }
615}
616
617impl From<String> for ResponseBody {
618 fn from(s: String) -> Self {
619 ResponseBody::Bytes(Bytes::from(s))
620 }
621}
622
623impl From<&'static str> for ResponseBody {
624 fn from(s: &'static str) -> Self {
625 ResponseBody::Bytes(Bytes::from_static(s.as_bytes()))
626 }
627}
628
629impl PartialEq<Bytes> for ResponseBody {
630 fn eq(&self, other: &Bytes) -> bool {
631 match self {
632 ResponseBody::Bytes(b) => b == other,
633 ResponseBody::File { .. } => false,
634 }
635 }
636}
637
638pub struct AwsResponse {
640 pub status: StatusCode,
641 pub content_type: String,
642 pub body: ResponseBody,
643 pub headers: HeaderMap,
644}
645
646impl AwsResponse {
647 pub fn xml(status: StatusCode, body: impl Into<Bytes>) -> Self {
648 Self {
649 status,
650 content_type: "text/xml".to_string(),
651 body: ResponseBody::Bytes(body.into()),
652 headers: HeaderMap::new(),
653 }
654 }
655
656 pub fn json(status: StatusCode, body: impl Into<Bytes>) -> Self {
657 Self {
658 status,
659 content_type: "application/x-amz-json-1.1".to_string(),
660 body: ResponseBody::Bytes(body.into()),
661 headers: HeaderMap::new(),
662 }
663 }
664
665 pub fn json_value(status: StatusCode, value: serde_json::Value) -> Self {
671 Self::json(
672 status,
673 serde_json::to_vec(&value).expect("serde_json::Value serialization is infallible"),
674 )
675 }
676
677 pub fn ok_json(value: serde_json::Value) -> Self {
679 Self::json_value(StatusCode::OK, value)
680 }
681}
682
683#[derive(Debug, thiserror::Error)]
685pub enum AwsServiceError {
686 #[error("service not found: {service}")]
687 ServiceNotFound { service: String },
688
689 #[error("action {action} not implemented for service {service}")]
690 ActionNotImplemented { service: String, action: String },
691
692 #[error("{code}: {message}")]
693 AwsError {
694 status: StatusCode,
695 code: String,
696 message: String,
697 extra_fields: Vec<(String, String)>,
699 headers: Vec<(String, String)>,
701 },
702}
703
704impl AwsServiceError {
705 pub fn action_not_implemented(service: &str, action: &str) -> Self {
706 Self::ActionNotImplemented {
707 service: service.to_string(),
708 action: action.to_string(),
709 }
710 }
711
712 pub fn aws_error(
713 status: StatusCode,
714 code: impl Into<String>,
715 message: impl Into<String>,
716 ) -> Self {
717 Self::AwsError {
718 status,
719 code: code.into(),
720 message: message.into(),
721 extra_fields: Vec::new(),
722 headers: Vec::new(),
723 }
724 }
725
726 pub fn aws_error_with_fields(
727 status: StatusCode,
728 code: impl Into<String>,
729 message: impl Into<String>,
730 extra_fields: Vec<(String, String)>,
731 ) -> Self {
732 Self::AwsError {
733 status,
734 code: code.into(),
735 message: message.into(),
736 extra_fields,
737 headers: Vec::new(),
738 }
739 }
740
741 pub fn aws_error_with_headers(
742 status: StatusCode,
743 code: impl Into<String>,
744 message: impl Into<String>,
745 headers: Vec<(String, String)>,
746 ) -> Self {
747 Self::AwsError {
748 status,
749 code: code.into(),
750 message: message.into(),
751 extra_fields: Vec::new(),
752 headers,
753 }
754 }
755
756 pub fn extra_fields(&self) -> &[(String, String)] {
757 match self {
758 Self::AwsError { extra_fields, .. } => extra_fields,
759 _ => &[],
760 }
761 }
762
763 pub fn status(&self) -> StatusCode {
764 match self {
765 Self::ServiceNotFound { .. } => StatusCode::BAD_REQUEST,
766 Self::ActionNotImplemented { .. } => StatusCode::NOT_IMPLEMENTED,
767 Self::AwsError { status, .. } => *status,
768 }
769 }
770
771 pub fn code(&self) -> &str {
772 match self {
773 Self::ServiceNotFound { .. } => "UnknownService",
774 Self::ActionNotImplemented { .. } => "InvalidAction",
775 Self::AwsError { code, .. } => code,
776 }
777 }
778
779 pub fn message(&self) -> String {
780 match self {
781 Self::ServiceNotFound { service } => format!("service not found: {service}"),
782 Self::ActionNotImplemented { service, action } => {
783 format!("action {action} not implemented for service {service}")
784 }
785 Self::AwsError { message, .. } => message.clone(),
786 }
787 }
788
789 pub fn response_headers(&self) -> &[(String, String)] {
790 match self {
791 Self::AwsError { headers, .. } => headers,
792 _ => &[],
793 }
794 }
795}
796
797#[async_trait]
799pub trait AwsService: Send + Sync {
800 fn service_name(&self) -> &str;
802
803 async fn handle(&self, request: AwsRequest) -> Result<AwsResponse, AwsServiceError>;
805
806 fn supported_actions(&self) -> &[&str];
808
809 fn iam_enforceable(&self) -> bool {
824 false
825 }
826
827 fn iam_action_for(&self, _request: &AwsRequest) -> Option<crate::auth::IamAction> {
840 None
841 }
842
843 fn iam_actions_for(&self, request: &AwsRequest) -> Vec<crate::auth::IamAction> {
854 self.iam_action_for(request).into_iter().collect()
855 }
856
857 fn iam_resource_in_caller_account(&self, _request: &AwsRequest) -> bool {
865 false
866 }
867
868 fn iam_condition_keys_for(
888 &self,
889 _request: &AwsRequest,
890 _action: &crate::auth::IamAction,
891 ) -> BTreeMap<String, Vec<String>> {
892 BTreeMap::new()
893 }
894
895 fn resource_tags_for(
907 &self,
908 _resource_arn: &str,
909 ) -> Option<std::collections::HashMap<String, String>> {
910 None
911 }
912
913 fn request_tags_from(
923 &self,
924 _request: &AwsRequest,
925 _action: &str,
926 ) -> Option<std::collections::HashMap<String, String>> {
927 None
928 }
929}
930
931#[cfg(test)]
932mod tests {
933 use super::*;
934 use crate::auth::IamAction;
935 use async_trait::async_trait;
936
937 fn aws_chunked_body(payload: &[u8], chunk_size: usize, with_trailer: bool) -> Vec<u8> {
940 let sig = "0".repeat(64);
941 let mut out = Vec::new();
942 for c in payload.chunks(chunk_size.max(1)) {
943 out.extend_from_slice(format!("{:x};chunk-signature={sig}\r\n", c.len()).as_bytes());
944 out.extend_from_slice(c);
945 out.extend_from_slice(b"\r\n");
946 }
947 out.extend_from_slice(format!("0;chunk-signature={sig}\r\n").as_bytes());
948 if with_trailer {
949 out.extend_from_slice(b"x-amz-checksum-crc32:AAAAAA==\r\n");
950 }
951 out.extend_from_slice(b"\r\n");
952 out
953 }
954
955 fn decode_all(body: &[u8], feed_size: usize) -> Vec<u8> {
956 let mut d = AwsChunkedDecoder::default();
957 let mut out = Vec::new();
958 for frame in body.chunks(feed_size.max(1)) {
959 out.extend(d.feed(frame).expect("valid chunked body"));
960 }
961 out
962 }
963
964 #[test]
965 fn aws_chunked_decoder_roundtrips_across_frame_boundaries() {
966 let payload: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
967 for with_trailer in [false, true] {
969 let body = aws_chunked_body(&payload, 1024, with_trailer);
970 for feed in [1usize, 7, 64, 1000, body.len()] {
973 let decoded = decode_all(&body, feed);
974 assert_eq!(decoded, payload, "feed={feed} trailer={with_trailer}");
975 }
976 }
977 }
978
979 #[test]
980 fn aws_chunked_decoder_collects_trailers_across_frame_boundaries() {
981 let payload: Vec<u8> = (0..3000u32).map(|i| (i % 251) as u8).collect();
982 let body = aws_chunked_body(&payload, 1024, true);
983 for feed in [1usize, 5, 333, body.len()] {
984 let mut d = AwsChunkedDecoder::default();
985 for frame in body.chunks(feed) {
986 d.feed(frame).unwrap();
987 }
988 assert_eq!(
989 d.trailers(),
990 &[("x-amz-checksum-crc32".to_string(), "AAAAAA==".to_string())],
991 "feed={feed}"
992 );
993 }
994 }
995
996 #[test]
997 fn aws_chunked_trailer_signature_and_optional_newline_are_handled() {
998 let body = b"5\r\nhello\r\n0\r\nX-Amz-Checksum-Sha256: abc= \n\r\n";
1001 let decoded = decode_aws_chunked(body).unwrap();
1002 assert_eq!(decoded.payload, b"hello");
1003 assert_eq!(
1004 decoded.trailers,
1005 vec![("x-amz-checksum-sha256".to_string(), "abc=".to_string())]
1006 );
1007 let signed =
1008 b"3\r\nabc\r\n0\r\nx-amz-checksum-crc32c:Zm9v\r\nx-amz-trailer-signature:dead\r\n\r\n";
1009 let decoded = decode_aws_chunked(signed).unwrap();
1010 assert_eq!(decoded.payload, b"abc");
1011 assert_eq!(
1012 decoded.trailers,
1013 vec![("x-amz-checksum-crc32c".to_string(), "Zm9v".to_string())]
1014 );
1015 assert!(decode_aws_chunked(b"zz\r\nabc").is_err());
1016 }
1017
1018 #[tokio::test]
1019 async fn spool_observer_sees_every_decoded_byte_and_trailers_are_returned() {
1020 let payload: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
1021 let body = aws_chunked_body(&payload, 1024, true);
1022 let mut seen = Vec::new();
1023 let spooled =
1024 spool_request_stream_observed(axum::body::Body::from(body), None, true, &mut |chunk| {
1025 seen.extend_from_slice(chunk)
1026 })
1027 .await
1028 .expect("spool ok");
1029 assert_eq!(seen, payload);
1030 assert_eq!(
1031 spooled.trailers,
1032 vec![("x-amz-checksum-crc32".to_string(), "AAAAAA==".to_string())]
1033 );
1034 let _ = std::fs::remove_file(&spooled.path);
1035 }
1036
1037 #[test]
1038 fn aws_chunked_decoder_handles_empty_payload() {
1039 let body = aws_chunked_body(b"", 1024, false);
1040 assert_eq!(decode_all(&body, 3), Vec::<u8>::new());
1041 }
1042
1043 fn sha256_hex(bytes: &[u8]) -> String {
1044 let mut h = sha2::Sha256::new();
1045 h.update(bytes);
1046 hex_lower(&h.finalize())
1047 }
1048
1049 #[tokio::test]
1050 async fn spool_computes_sha256_over_plain_payload() {
1051 let payload = b"hello world".to_vec();
1052 let spooled = spool_request_stream(axum::body::Body::from(payload.clone()), None, false)
1053 .await
1054 .expect("spool ok");
1055 assert_eq!(spooled.size, payload.len() as u64);
1056 assert_eq!(spooled.sha256_hex, sha256_hex(&payload));
1057 let _ = std::fs::remove_file(&spooled.path);
1058 }
1059
1060 #[tokio::test]
1061 async fn spool_sha256_is_over_decoded_aws_chunked_payload() {
1062 let payload: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
1066 let body = aws_chunked_body(&payload, 1024, true);
1067 let spooled = spool_request_stream(axum::body::Body::from(body), None, true)
1068 .await
1069 .expect("spool ok");
1070 assert_eq!(spooled.size, payload.len() as u64);
1071 assert_eq!(spooled.sha256_hex, sha256_hex(&payload));
1072 let _ = std::fs::remove_file(&spooled.path);
1073 }
1074
1075 #[test]
1076 fn aws_chunked_decoder_rejects_bad_size_line() {
1077 let mut d = AwsChunkedDecoder::default();
1078 assert!(d.feed(b"zz;chunk-signature=x\r\n").is_err());
1079 }
1080
1081 #[test]
1082 fn is_aws_chunked_detects_streaming_markers() {
1083 let mut h = http::HeaderMap::new();
1084 assert!(!is_aws_chunked(&h));
1085 h.insert("content-encoding", "aws-chunked".parse().unwrap());
1086 assert!(is_aws_chunked(&h));
1087 let mut h2 = http::HeaderMap::new();
1088 h2.insert(
1089 "x-amz-content-sha256",
1090 "STREAMING-AWS4-HMAC-SHA256-PAYLOAD".parse().unwrap(),
1091 );
1092 assert!(is_aws_chunked(&h2));
1093 let mut h3 = http::HeaderMap::new();
1095 h3.insert("content-encoding", "gzip".parse().unwrap());
1096 assert!(!is_aws_chunked(&h3));
1097 }
1098
1099 #[test]
1100 fn strip_aws_chunked_keeps_real_encoding() {
1101 assert_eq!(strip_aws_chunked_encoding(Some("aws-chunked")), None);
1102 assert_eq!(
1103 strip_aws_chunked_encoding(Some("aws-chunked, gzip")).as_deref(),
1104 Some("gzip")
1105 );
1106 assert_eq!(
1107 strip_aws_chunked_encoding(Some("gzip")).as_deref(),
1108 Some("gzip")
1109 );
1110 assert_eq!(strip_aws_chunked_encoding(None), None);
1111 }
1112
1113 struct DefaultService;
1114
1115 #[async_trait]
1116 impl AwsService for DefaultService {
1117 fn service_name(&self) -> &str {
1118 "default"
1119 }
1120 async fn handle(&self, _request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1121 unreachable!()
1122 }
1123 fn supported_actions(&self) -> &[&str] {
1124 &[]
1125 }
1126 }
1127
1128 struct PopulatedService;
1129
1130 #[async_trait]
1131 impl AwsService for PopulatedService {
1132 fn service_name(&self) -> &str {
1133 "populated"
1134 }
1135 async fn handle(&self, _request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1136 unreachable!()
1137 }
1138 fn supported_actions(&self) -> &[&str] {
1139 &[]
1140 }
1141 fn iam_condition_keys_for(
1142 &self,
1143 _request: &AwsRequest,
1144 _action: &IamAction,
1145 ) -> BTreeMap<String, Vec<String>> {
1146 let mut m = BTreeMap::new();
1147 m.insert("s3:prefix".to_string(), vec!["logs/".to_string()]);
1148 m
1149 }
1150 }
1151
1152 fn sample_request() -> AwsRequest {
1153 AwsRequest {
1154 service: "default".into(),
1155 action: "Noop".into(),
1156 region: "us-east-1".into(),
1157 account_id: "123456789012".into(),
1158 request_id: "req-1".into(),
1159 headers: HeaderMap::new(),
1160 query_params: HashMap::new(),
1161 body: Bytes::new(),
1162 body_stream: parking_lot::Mutex::new(None),
1163 path_segments: vec![],
1164 raw_path: "/".into(),
1165 raw_query: String::new(),
1166 method: Method::GET,
1167 is_query_protocol: false,
1168 access_key_id: None,
1169 principal: None,
1170 }
1171 }
1172
1173 fn sample_action() -> IamAction {
1174 IamAction {
1175 service: "s3",
1176 action: "ListBucket",
1177 resource: "arn:aws:s3:::my-bucket".to_string(),
1178 }
1179 }
1180
1181 #[test]
1182 fn iam_condition_keys_for_default_is_empty() {
1183 let svc = DefaultService;
1184 let keys = svc.iam_condition_keys_for(&sample_request(), &sample_action());
1185 assert!(keys.is_empty());
1186 }
1187
1188 #[test]
1189 fn iam_condition_keys_for_override_returns_map() {
1190 let svc = PopulatedService;
1191 let keys = svc.iam_condition_keys_for(&sample_request(), &sample_action());
1192 assert_eq!(keys.get("s3:prefix"), Some(&vec!["logs/".to_string()]));
1193 }
1194
1195 #[test]
1196 fn response_body_len_and_is_empty_for_bytes() {
1197 let body: ResponseBody = Bytes::from_static(b"hello").into();
1198 assert_eq!(body.len(), 5);
1199 assert!(!body.is_empty());
1200 let empty: ResponseBody = ResponseBody::default();
1201 assert!(empty.is_empty());
1202 }
1203
1204 #[test]
1205 fn response_body_from_vec_and_string_and_str() {
1206 let from_vec: ResponseBody = vec![1u8, 2, 3].into();
1207 assert_eq!(from_vec.expect_bytes(), &[1, 2, 3][..]);
1208 let from_string: ResponseBody = String::from("hi").into();
1209 assert_eq!(from_string.expect_bytes(), b"hi");
1210 let from_str: ResponseBody = "hey".into();
1211 assert_eq!(from_str.expect_bytes(), b"hey");
1212 let from_static: ResponseBody = (b"123" as &'static [u8]).into();
1213 assert_eq!(from_static.expect_bytes(), b"123");
1214 }
1215
1216 #[test]
1217 fn response_body_partial_eq_bytes() {
1218 let body: ResponseBody = Bytes::from_static(b"x").into();
1219 assert!(body == Bytes::from_static(b"x"));
1220 assert!(!(body == Bytes::from_static(b"y")));
1221 }
1222
1223 #[test]
1224 fn aws_request_json_body_empty_returns_null() {
1225 let req = sample_request();
1226 assert_eq!(req.json_body(), serde_json::Value::Null);
1227 }
1228
1229 #[test]
1230 fn aws_request_json_body_parses_valid() {
1231 let mut req = sample_request();
1232 req.body = Bytes::from_static(br#"{"a":1}"#);
1233 assert_eq!(req.json_body(), serde_json::json!({"a": 1}));
1234 }
1235
1236 #[test]
1237 fn aws_response_xml_constructor() {
1238 let resp = AwsResponse::xml(StatusCode::OK, Bytes::from_static(b"<ok/>"));
1239 assert_eq!(resp.status, StatusCode::OK);
1240 assert_eq!(resp.content_type, "text/xml");
1241 }
1242
1243 #[test]
1244 fn aws_response_json_constructor() {
1245 let resp = AwsResponse::json(StatusCode::CREATED, "{}");
1246 assert_eq!(resp.status, StatusCode::CREATED);
1247 assert_eq!(resp.content_type, "application/x-amz-json-1.1");
1248 }
1249
1250 #[test]
1251 fn aws_response_ok_json_helper() {
1252 let resp = AwsResponse::ok_json(serde_json::json!({"ok": true}));
1253 assert_eq!(resp.status, StatusCode::OK);
1254 assert!(resp.body.expect_bytes().starts_with(b"{"));
1255 }
1256
1257 #[test]
1258 fn aws_error_service_not_found_fields() {
1259 let err = AwsServiceError::ServiceNotFound {
1260 service: "sqs".to_string(),
1261 };
1262 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1263 assert_eq!(err.code(), "UnknownService");
1264 assert!(err.message().contains("sqs"));
1265 assert!(err.extra_fields().is_empty());
1266 assert!(err.response_headers().is_empty());
1267 }
1268
1269 #[test]
1270 fn aws_error_action_not_implemented_fields() {
1271 let err = AwsServiceError::action_not_implemented("sns", "FutureAction");
1272 assert_eq!(err.status(), StatusCode::NOT_IMPLEMENTED);
1273 assert_eq!(err.code(), "InvalidAction");
1274 assert!(err.message().contains("FutureAction"));
1275 assert!(err.message().contains("sns"));
1276 }
1277
1278 #[test]
1279 fn aws_error_aws_error_helpers() {
1280 let e = AwsServiceError::aws_error(StatusCode::FORBIDDEN, "Denied", "no");
1281 assert_eq!(e.status(), StatusCode::FORBIDDEN);
1282 assert_eq!(e.code(), "Denied");
1283 assert_eq!(e.message(), "no");
1284
1285 let fields = vec![("Bucket".to_string(), "b".to_string())];
1286 let ef = AwsServiceError::aws_error_with_fields(
1287 StatusCode::NOT_FOUND,
1288 "Missing",
1289 "gone",
1290 fields.clone(),
1291 );
1292 assert_eq!(ef.extra_fields(), fields.as_slice());
1293
1294 let hdrs = vec![("X-Retry".to_string(), "1".to_string())];
1295 let eh = AwsServiceError::aws_error_with_headers(
1296 StatusCode::TOO_MANY_REQUESTS,
1297 "Throttled",
1298 "slow",
1299 hdrs.clone(),
1300 );
1301 assert_eq!(eh.response_headers(), hdrs.as_slice());
1302 }
1303
1304 #[test]
1305 #[should_panic(expected = "expect_bytes called on ResponseBody::File")]
1306 fn response_body_expect_bytes_panics_on_file() {
1307 let f = std::fs::File::create(std::env::temp_dir().join("fc-test-expect-file")).unwrap();
1308 let async_f = tokio::fs::File::from_std(f);
1309 let body = ResponseBody::File {
1310 file: async_f,
1311 size: 0,
1312 };
1313 let _ = body.expect_bytes();
1314 }
1315}