diff --git a/crates/fakecloud-dynamodb/src/service/tables.rs b/crates/fakecloud-dynamodb/src/service/tables.rs index 516971986..2fb6369e6 100644 --- a/crates/fakecloud-dynamodb/src/service/tables.rs +++ b/crates/fakecloud-dynamodb/src/service/tables.rs @@ -772,8 +772,10 @@ impl DynamoDbService { "Policy": policy, "RevisionId": policy_revision_id(policy) })), + // DynamoDB is awsJson1.0 — client errors are HTTP 400 with the + // error type in the body, never 404. None => Err(AwsServiceError::aws_error( - StatusCode::NOT_FOUND, + StatusCode::BAD_REQUEST, "PolicyNotFoundException", "No resource-based policy is attached to the resource.", )), diff --git a/crates/fakecloud-dynamodb/src/service/tests.rs b/crates/fakecloud-dynamodb/src/service/tests.rs index 730970a1d..e7e97afd8 100644 --- a/crates/fakecloud-dynamodb/src/service/tests.rs +++ b/crates/fakecloud-dynamodb/src/service/tests.rs @@ -948,7 +948,11 @@ fn resource_policy_lifecycle() { // Get should now return PolicyNotFoundException, matching real DynamoDB. let req = make_request("GetResourcePolicy", json!({ "ResourceArn": table_arn })); match svc.get_resource_policy(&req) { - Err(e) => assert_eq!(e.code(), "PolicyNotFoundException"), + Err(e) => { + assert_eq!(e.code(), "PolicyNotFoundException"); + // awsJson1.0: client errors are HTTP 400, never 404. + assert_eq!(e.status(), http::StatusCode::BAD_REQUEST); + } Ok(_) => panic!("GetResourcePolicy after delete must error"), } } diff --git a/crates/fakecloud-iam/src/sts_service/assume.rs b/crates/fakecloud-iam/src/sts_service/assume.rs index bd20ec6dd..75ca4b07b 100644 --- a/crates/fakecloud-iam/src/sts_service/assume.rs +++ b/crates/fakecloud-iam/src/sts_service/assume.rs @@ -11,7 +11,7 @@ impl StsService { "The request must contain the parameter RoleArn", ) })?; - validate_string_length("roleArn", role_arn, 20, 2048)?; + sts_validate_string_length("roleArn", role_arn, 20, 2048)?; let role_session_name = req.query_params.get("RoleSessionName").ok_or_else(|| { AwsServiceError::aws_error( @@ -20,7 +20,7 @@ impl StsService { "The request must contain the parameter RoleSessionName", ) })?; - validate_string_length("roleSessionName", role_session_name, 2, 64)?; + sts_validate_string_length("roleSessionName", role_session_name, 2, 64)?; validate_session_name(role_session_name)?; // Validate optional DurationSeconds (used below for expiration) @@ -36,7 +36,7 @@ impl StsService { ), ) })?; - validate_range_i64("durationSeconds", v, 900, 43200)?; + sts_validate_range_i64("durationSeconds", v, 900, 43200)?; } // Validate optional ExternalId @@ -318,7 +318,7 @@ impl StsService { "The request must contain the parameter RoleArn", ) })?; - validate_string_length("roleArn", role_arn, 20, 2048)?; + sts_validate_string_length("roleArn", role_arn, 20, 2048)?; let role_session_name = req.query_params.get("RoleSessionName").ok_or_else(|| { AwsServiceError::aws_error( @@ -327,7 +327,7 @@ impl StsService { "The request must contain the parameter RoleSessionName", ) })?; - validate_string_length("roleSessionName", role_session_name, 2, 64)?; + sts_validate_string_length("roleSessionName", role_session_name, 2, 64)?; validate_session_name(role_session_name)?; // WebIdentityToken is required @@ -338,7 +338,7 @@ impl StsService { "The request must contain the parameter WebIdentityToken", ) })?; - validate_string_length("webIdentityToken", web_identity_token, 4, 20000)?; + sts_validate_string_length("webIdentityToken", web_identity_token, 4, 20000)?; let web_identity_token_owned = web_identity_token.clone(); // Validate optional Policy @@ -370,7 +370,7 @@ impl StsService { ), ) })?; - validate_range_i64("durationSeconds", v, 900, 43200)?; + sts_validate_range_i64("durationSeconds", v, 900, 43200)?; } // Compute expiration from DurationSeconds (default 3600s) @@ -630,7 +630,7 @@ impl StsService { "The request must contain the parameter RoleArn", ) })?; - validate_string_length("roleArn", role_arn, 20, 2048)?; + sts_validate_string_length("roleArn", role_arn, 20, 2048)?; // PrincipalArn is required let principal_arn = req.query_params.get("PrincipalArn").ok_or_else(|| { @@ -640,7 +640,7 @@ impl StsService { "The request must contain the parameter PrincipalArn", ) })?; - validate_string_length("principalArn", principal_arn, 20, 2048)?; + sts_validate_string_length("principalArn", principal_arn, 20, 2048)?; // Snapshot the SAML provider ARN so we can stash it on the // session as `aws:FederatedProvider` after this scope ends. let saml_provider_arn = principal_arn.clone(); @@ -653,7 +653,7 @@ impl StsService { "The request must contain the parameter SAMLAssertion", ) })?; - validate_string_length("sAMLAssertion", saml_assertion, 4, 100000)?; + sts_validate_string_length("sAMLAssertion", saml_assertion, 4, 100000)?; // Validate optional Policy validate_optional_string_length( @@ -676,7 +676,7 @@ impl StsService { ), ) })?; - validate_range_i64("durationSeconds", v, 900, 43200)?; + sts_validate_range_i64("durationSeconds", v, 900, 43200)?; } // Compute expiration from DurationSeconds (default 3600s) diff --git a/crates/fakecloud-iam/src/sts_service/caller.rs b/crates/fakecloud-iam/src/sts_service/caller.rs index 0a918b5ef..ac6731767 100644 --- a/crates/fakecloud-iam/src/sts_service/caller.rs +++ b/crates/fakecloud-iam/src/sts_service/caller.rs @@ -63,7 +63,7 @@ impl StsService { "The request must contain the parameter AccessKeyId", ) })?; - validate_string_length("accessKeyId", access_key_id, 16, 128)?; + sts_validate_string_length("accessKeyId", access_key_id, 16, 128)?; // Try to resolve account from known access keys across all accounts let accounts = self.state.read(); diff --git a/crates/fakecloud-iam/src/sts_service/federation.rs b/crates/fakecloud-iam/src/sts_service/federation.rs index e3a9a4b00..199c10ae5 100644 --- a/crates/fakecloud-iam/src/sts_service/federation.rs +++ b/crates/fakecloud-iam/src/sts_service/federation.rs @@ -14,7 +14,7 @@ impl StsService { "The request must contain the parameter Name", ) })?; - validate_string_length("name", name, 2, 32)?; + sts_validate_string_length("name", name, 2, 32)?; // Validate optional DurationSeconds (used below for expiration) if let Some(ds) = req.query_params.get("DurationSeconds") { @@ -29,7 +29,7 @@ impl StsService { ), ) })?; - validate_range_i64("durationSeconds", v, 900, 129600)?; + sts_validate_range_i64("durationSeconds", v, 900, 129600)?; } // Validate and store optional policy diff --git a/crates/fakecloud-iam/src/sts_service/mod.rs b/crates/fakecloud-iam/src/sts_service/mod.rs index ded59bf3f..1b964f11e 100644 --- a/crates/fakecloud-iam/src/sts_service/mod.rs +++ b/crates/fakecloud-iam/src/sts_service/mod.rs @@ -17,6 +17,37 @@ use crate::state::{CredentialIdentity, IamState, SharedIamState, StsTempCredenti use crate::xml_responses::{self, StsCredentials}; use fakecloud_core::auth::{Principal, PrincipalType}; +/// STS speaks the awsQuery protocol, whose constraint violations surface as +/// `ValidationError` (no `Exception` suffix). The shared awsJson validators +/// emit `ValidationException`; these wrappers reuse their logic/messages but +/// re-stamp the code so STS matches the AWS wire shape (bug-hunt: STS length / +/// range checks were leaking `ValidationException`). +fn to_validation_error(e: AwsServiceError) -> AwsServiceError { + if e.code() == "ValidationException" { + AwsServiceError::aws_error(e.status(), "ValidationError", e.message()) + } else { + e + } +} + +fn sts_validate_string_length( + field: &str, + value: &str, + min: usize, + max: usize, +) -> Result<(), AwsServiceError> { + validate_string_length(field, value, min, max).map_err(to_validation_error) +} + +fn sts_validate_range_i64( + field: &str, + value: i64, + min: i64, + max: i64, +) -> Result<(), AwsServiceError> { + validate_range_i64(field, value, min, max).map_err(to_validation_error) +} + /// Default duration for AssumeRole and similar operations (1 hour). const DEFAULT_ASSUME_ROLE_DURATION: i64 = 3600; @@ -1861,6 +1892,24 @@ mod tests { assert!(svc.handle(req).await.is_err()); } + #[tokio::test] + async fn assume_role_length_violation_uses_query_protocol_error_code() { + // STS is awsQuery: a length-constraint violation must surface as + // `ValidationError`, not the awsJson `ValidationException` the shared + // validator emits (bug-hunt LOW error-code). + let (svc, _) = make_sts_service(); + // RoleArn shorter than the 20-char minimum. + let req = sts_request( + "AssumeRole", + vec![("RoleArn", "arn:short"), ("RoleSessionName", "sess")], + ); + let err = svc + .assume_role(&req) + .err() + .expect("too-short RoleArn errors"); + assert_eq!(err.code(), "ValidationError"); + } + #[tokio::test] async fn assume_role_missing_role_arn_errors() { let (svc, _) = make_sts_service(); diff --git a/crates/fakecloud-iam/src/sts_service/session.rs b/crates/fakecloud-iam/src/sts_service/session.rs index ebe3b4645..e86a83550 100644 --- a/crates/fakecloud-iam/src/sts_service/session.rs +++ b/crates/fakecloud-iam/src/sts_service/session.rs @@ -20,7 +20,7 @@ impl StsService { ), ) })?; - validate_range_i64("durationSeconds", v, 900, 129600)?; + sts_validate_range_i64("durationSeconds", v, 900, 129600)?; } // Validate and accept optional MFA SerialNumber (no verification in emulator) @@ -118,7 +118,7 @@ impl StsService { "The request must contain the parameter EncodedMessage", ) })?; - validate_string_length("encodedMessage", encoded_message, 1, 10240)?; + sts_validate_string_length("encodedMessage", encoded_message, 1, 10240)?; // Round-trip the deflated/base64'd token produced by // `auth_message::encode_deny`. Tokens that don't decode are diff --git a/crates/fakecloud-s3/src/service/multipart.rs b/crates/fakecloud-s3/src/service/multipart.rs index 1f26c61d5..cfd96d498 100644 --- a/crates/fakecloud-s3/src/service/multipart.rs +++ b/crates/fakecloud-s3/src/service/multipart.rs @@ -201,11 +201,10 @@ impl S3Service { upload_id: &str, part_number: i64, ) -> Result { - // Validate part number - if part_number < 1 { - return Err(no_such_upload(upload_id)); - } - if part_number > 10000 { + // Validate part number. An out-of-range partNumber is an invalid + // argument (400 InvalidArgument), not a missing upload (404) — the + // low end previously returned NoSuchUpload. + if !(1..=10000).contains(&part_number) { return Err(AwsServiceError::aws_error_with_fields( StatusCode::BAD_REQUEST, "InvalidArgument", diff --git a/crates/fakecloud-s3/src/service/tests.rs b/crates/fakecloud-s3/src/service/tests.rs index dd7fd5a31..3e6767afb 100644 --- a/crates/fakecloud-s3/src/service/tests.rs +++ b/crates/fakecloud-s3/src/service/tests.rs @@ -842,13 +842,16 @@ async fn upload_part_rejects_invalid_part_number() { seed_bucket(&svc, "b"); let upload_id = initiate_mpu(&svc, "b", "k"); - // part_number < 1 is masked as NoSuchUpload (matching AWS behavior). + // part_number < 1 is an out-of-range argument: 400 InvalidArgument, same + // as the > 10000 case (previously masked as a 404 NoSuchUpload). let req = make_request(Method::PUT, "/b/k", &[("partNumber", "0")], b"body"); - assert_aws_err( - svc.upload_part("123456789012", &req, "b", "k", &upload_id, 0) - .await, - "NoSuchUpload", - ); + let err = svc + .upload_part("123456789012", &req, "b", "k", &upload_id, 0) + .await + .err() + .expect("partNumber 0 must error"); + assert_eq!(err.code(), "InvalidArgument"); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); // part_number > 10000 returns InvalidArgument. let req2 = make_request(Method::PUT, "/b/k", &[("partNumber", "10001")], b"body"); diff --git a/crates/fakecloud-secretsmanager/src/service.rs b/crates/fakecloud-secretsmanager/src/service.rs index 274f182d4..b88ce0287 100644 --- a/crates/fakecloud-secretsmanager/src/service.rs +++ b/crates/fakecloud-secretsmanager/src/service.rs @@ -1215,14 +1215,14 @@ impl SecretsManagerService { return Err(AwsServiceError::aws_error( StatusCode::BAD_REQUEST, "InvalidParameterException", - "InvalidParameterException", + "Invalid value for parameter PasswordLength; must be 4..=4096.", )); } if length > 4096 { return Err(AwsServiceError::aws_error( StatusCode::BAD_REQUEST, - "InvalidParameterValue", - "InvalidParameterValue", + "InvalidParameterException", + "Invalid value for parameter PasswordLength; must be 4..=4096.", )); } diff --git a/crates/fakecloud-secretsmanager/src/service_tests.rs b/crates/fakecloud-secretsmanager/src/service_tests.rs index 47dda129b..c4a2f6c53 100644 --- a/crates/fakecloud-secretsmanager/src/service_tests.rs +++ b/crates/fakecloud-secretsmanager/src/service_tests.rs @@ -757,7 +757,13 @@ async fn test_get_random_password_too_long() { let svc = SecretsManagerService::new(state); let req = make_request("GetRandomPassword", r#"{"PasswordLength": 4097}"#); - assert!(svc.handle(req).await.is_err()); + let err = svc + .handle(req) + .await + .err() + .expect("over-long PasswordLength errors"); + // AWS SecretsManager uses InvalidParameterException, not InvalidParameterValue. + assert_eq!(err.code(), "InvalidParameterException"); } #[tokio::test] diff --git a/crates/fakecloud-sns/src/service_publish.rs b/crates/fakecloud-sns/src/service_publish.rs index eb58717e5..8b4e17804 100644 --- a/crates/fakecloud-sns/src/service_publish.rs +++ b/crates/fakecloud-sns/src/service_publish.rs @@ -35,9 +35,10 @@ impl SnsService { let message_dedup_id = param(req, "MessageDeduplicationId"); let message_structure = param(req, "MessageStructure"); - // Validate subject length + // Validate subject length. AWS caps Subject at 100 *characters*, not + // bytes — count chars so a multibyte subject is not wrongly rejected. if let Some(ref subj) = subject { - if subj.len() > 100 { + if subj.chars().count() > 100 { return Err(AwsServiceError::aws_error( StatusCode::BAD_REQUEST, "InvalidParameter", diff --git a/crates/fakecloud-sns/src/service_tests.rs b/crates/fakecloud-sns/src/service_tests.rs index 3d824ff48..25cb0ee3a 100644 --- a/crates/fakecloud-sns/src/service_tests.rs +++ b/crates/fakecloud-sns/src/service_tests.rs @@ -708,6 +708,40 @@ fn list_subscriptions_by_topic_filters_correctly() { // --- Publish / PublishBatch --- +#[test] +fn publish_subject_length_counts_characters_not_bytes() { + // AWS caps Subject at 100 characters. A 100-char multibyte subject is 200 + // bytes; a byte-length check wrongly rejected it. A 101-char subject is + // still rejected. + let (svc, _state) = make_sns(); + assert_ok(&svc.create_topic(&sns_request("CreateTopic", vec![("Name", "subj-topic")]))); + let topic_arn = "arn:aws:sns:us-east-1:123456789012:subj-topic"; + + let ok_subject = "é".repeat(100); // 100 chars, 200 bytes + assert_ok(&svc.publish(&sns_request( + "Publish", + vec![ + ("TopicArn", topic_arn), + ("Message", "hi"), + ("Subject", &ok_subject), + ], + ))); + + let too_long = "é".repeat(101); + let err = svc + .publish(&sns_request( + "Publish", + vec![ + ("TopicArn", topic_arn), + ("Message", "hi"), + ("Subject", &too_long), + ], + )) + .err() + .expect("101-char subject must be rejected"); + assert_eq!(err.code(), "InvalidParameter"); +} + #[test] fn publish_to_topic_stores_message() { let (svc, state) = make_sns();