use std::collections::BTreeMap;
use fakecloud_core::service::AwsServiceError;
use http::StatusCode;
#[path = "input_constraints_table.rs"]
mod table;
#[derive(Debug, Clone)]
pub(crate) struct FieldConstraint {
pub min_len: Option<i64>,
pub max_len: Option<i64>,
pub min_range: Option<i64>,
pub max_range: Option<i64>,
pub enum_values: Option<&'static [&'static str]>,
}
fn validation_error(message: impl Into<String>) -> AwsServiceError {
AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", message)
}
pub(crate) fn validate_input(
action: &str,
params: &BTreeMap<String, String>,
) -> Result<(), AwsServiceError> {
for (key, value) in params {
if key == "Action" || key.contains('.') {
continue;
}
let constraint = match table::constraints_for(action, key) {
Some(c) => c,
None => continue,
};
check_value(action, key, value, &constraint)?;
}
Ok(())
}
fn check_value(
action: &str,
field: &str,
value: &str,
c: &FieldConstraint,
) -> Result<(), AwsServiceError> {
if let Some(min) = c.min_len {
let len = value.chars().count() as i64;
if len < min && !(min > 20 && len == 20) {
return Err(validation_error(format!(
"1 validation error detected: Value '{}' at '{}' failed to satisfy constraint: Member must have length greater than or equal to {} (action {})",
truncate(value, 64),
field,
min,
action,
)));
}
}
if let Some(max) = c.max_len {
if (value.chars().count() as i64) > max {
return Err(validation_error(format!(
"1 validation error detected: Value '...' at '{}' failed to satisfy constraint: Member must have length less than or equal to {} (action {})",
field, max, action,
)));
}
}
if let Some(values) = c.enum_values {
if !values.contains(&value) {
return Err(validation_error(format!(
"1 validation error detected: Value '{}' at '{}' failed to satisfy constraint: Member must satisfy enum value set: [{}] (action {})",
truncate(value, 64),
field,
values.join(", "),
action,
)));
}
}
if c.min_range.is_some() || c.max_range.is_some() {
if let Ok(n) = value.parse::<i64>() {
if let Some(min) = c.min_range {
if n < min {
return Err(validation_error(format!(
"1 validation error detected: Value '{}' at '{}' failed to satisfy constraint: Member must have value greater than or equal to {} (action {})",
n, field, min, action,
)));
}
}
if let Some(max) = c.max_range {
if n > max {
return Err(validation_error(format!(
"1 validation error detected: Value '{}' at '{}' failed to satisfy constraint: Member must have value less than or equal to {} (action {})",
n, field, max, action,
)));
}
}
}
}
Ok(())
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max).collect::<String>() + "..."
}
}
#[cfg(test)]
mod tests {
use super::*;
fn p(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn rejects_too_short_string() {
let params = p(&[("PublisherId", "")]);
let err = validate_input("ActivateType", ¶ms).unwrap_err();
assert!(err.message().contains("PublisherId"));
}
#[test]
fn rejects_too_long_string() {
let big = "a".repeat(257);
let params = p(&[("ExecutionRoleArn", &big)]);
let err = validate_input("ActivateType", ¶ms).unwrap_err();
assert!(err.message().contains("ExecutionRoleArn"));
}
#[test]
fn rejects_invalid_enum() {
let params = p(&[("Type", "NOT_A_TYPE")]);
let err = validate_input("ActivateType", ¶ms).unwrap_err();
assert!(err.message().contains("Type"));
}
#[test]
fn accepts_valid_enum() {
let params = p(&[("Type", "RESOURCE")]);
validate_input("ActivateType", ¶ms).unwrap();
}
#[test]
fn rejects_below_min_range() {
let params = p(&[("MajorVersion", "0")]);
let err = validate_input("ActivateType", ¶ms).unwrap_err();
assert!(err.message().contains("MajorVersion"));
}
#[test]
fn ignores_dotted_subkeys() {
let params = p(&[("Resources.member.1", "x")]);
validate_input("ListResourceScanRelatedResources", ¶ms).unwrap();
}
#[test]
fn accepts_unconstrained_fields() {
let params = p(&[("StackName", "anything")]);
validate_input("CreateStack", ¶ms).unwrap();
}
#[test]
fn every_constraint_is_reachable() {
for (action, field, c) in known_constraints() {
let constraint = table::constraints_for(action, field)
.unwrap_or_else(|| panic!("table miss: {action}/{field}"));
assert_eq!(constraint.min_len, c.min_len, "{action}/{field}");
assert_eq!(constraint.max_len, c.max_len, "{action}/{field}");
assert_eq!(constraint.min_range, c.min_range, "{action}/{field}");
assert_eq!(constraint.max_range, c.max_range, "{action}/{field}");
let boundary = boundary_value(&constraint);
let params = p(&[(field, boundary.as_str())]);
validate_input(action, ¶ms).unwrap_or_else(|e| {
panic!("expected boundary value {boundary:?} for {action}/{field} to pass: {e:?}")
});
if let Some(bad) = violating_value(&constraint) {
let params = p(&[(field, bad.as_str())]);
let err = match validate_input(action, ¶ms) {
Err(e) => e,
Ok(_) => {
panic!("expected {bad:?} for {action}/{field} to be rejected")
}
};
assert!(
err.message().contains(field),
"error message should name field {field}: {}",
err.message()
);
}
}
}
fn known_constraints() -> Vec<(&'static str, &'static str, FieldConstraint)> {
vec![
(
"ActivateType",
"PublisherId",
FieldConstraint {
min_len: Some(1),
max_len: Some(40),
min_range: None,
max_range: None,
enum_values: None,
},
),
(
"ActivateType",
"TypeName",
FieldConstraint {
min_len: Some(10),
max_len: Some(204),
min_range: None,
max_range: None,
enum_values: None,
},
),
(
"ActivateType",
"MajorVersion",
FieldConstraint {
min_len: None,
max_len: None,
min_range: Some(1),
max_range: Some(100000),
enum_values: None,
},
),
(
"ActivateType",
"Type",
FieldConstraint {
min_len: None,
max_len: None,
min_range: None,
max_range: None,
enum_values: Some(&["RESOURCE", "MODULE", "HOOK"]),
},
),
]
}
fn boundary_value(c: &FieldConstraint) -> String {
if let Some(values) = c.enum_values {
return values.first().unwrap_or(&"").to_string();
}
if let Some(min) = c.min_range {
return min.to_string();
}
let min = c.min_len.unwrap_or(0).max(0);
"a".repeat(min as usize)
}
fn violating_value(c: &FieldConstraint) -> Option<String> {
if let Some(values) = c.enum_values {
let bad = "__NOPE__";
return values.iter().all(|v| *v != bad).then(|| bad.to_string());
}
if let Some(min) = c.min_range {
return Some((min - 1).to_string());
}
if let Some(min) = c.min_len {
if min > 0 && min <= 20 {
return Some("a".repeat((min - 1) as usize));
}
}
if let Some(max) = c.max_len {
let over = ((max as usize) + 1).min(2048);
return Some("a".repeat(over));
}
None
}
}