Skip to content
Open
4 changes: 3 additions & 1 deletion crates/core/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ pub use key_schema::{
ScalarAttributeType, TableKeyInfo, VectorIndexKeyInfo, hash_key_elements,
is_multipart_key_schema, partition_indexes, range_key_elements,
};
pub use query::{Condition, QueryInput, QueryOutput, ScanInput, ScanOutput, Select};
pub use query::{
Condition, QueryInput, QueryOutput, ScanInput, ScanOutput, Select, saturated_int32,
};
pub use stream::{
DescribeStreamInput, DescribeStreamOutput, GetRecordsInput, GetRecordsOutput,
GetShardIteratorInput, GetShardIteratorOutput, ListStreamsInput, ListStreamsOutput,
Expand Down
124 changes: 121 additions & 3 deletions crates/core/src/types/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,30 @@ pub struct ScanInput {
deserialize_with = "crate::serde_helpers::deserialize_expression_values"
)]
pub expression_attribute_values: Option<HashMap<String, AttributeValue>>,
#[serde(rename = "Limit")]
/// Any JSON number; see [`saturated_int32`].
#[serde(
rename = "Limit",
default,
deserialize_with = "deserialize_int32_field"
)]
pub limit: Option<i64>,
#[serde(rename = "ExclusiveStartKey")]
pub exclusive_start_key: Option<Item>,
#[serde(rename = "Select")]
pub select: Option<Select>,
#[serde(rename = "Segment")]
/// Any JSON number; see [`saturated_int32`].
#[serde(
rename = "Segment",
default,
deserialize_with = "deserialize_int32_field"
)]
pub segment: Option<i64>,
#[serde(rename = "TotalSegments")]
/// Any JSON number; see [`saturated_int32`].
#[serde(
rename = "TotalSegments",
default,
deserialize_with = "deserialize_int32_field"
)]
pub total_segments: Option<i64>,
#[serde(rename = "ConsistentRead")]
pub consistent_read: Option<bool>,
Expand All @@ -168,6 +183,41 @@ pub struct ScanInput {
pub conditional_operator: Option<ConditionalOperator>,
}

/// The value the service validates and uses for a `Scan` `Limit`, `Segment`
/// or `TotalSegments` written as `number`: a fraction is truncated toward zero
/// and a value outside the int32 range is saturated to the nearer bound.
///
/// Measured 2026-10-25 (us-east-1): `Limit: 1.5` and `Limit: 1.0` return one
/// item, `Limit: 1e2`, `Limit: 2147483648` and `Limit: 9223372036854775808`
/// are accepted, `Segment: 1.5` of `TotalSegments: 2` returns the items of
/// segment 1, `Limit: 0.0` is reported as `0`, `Limit: -2147483649` as
/// `-2147483648` and `TotalSegments: 2147483648` as `2147483647`. Measured
/// 2026-10-30 12:40 UTC: `Limit: -1.5` is reported as `-1`, and `Limit: -0.5`
/// and `Limit: -0` as `0`.
#[must_use]
pub fn saturated_int32(number: &serde_json::Number) -> i32 {
if let Some(value) = number.as_i64() {
return i32::try_from(value).unwrap_or(if value < 0 { i32::MIN } else { i32::MAX });
}
if number.is_u64() {
return i32::MAX;
}
// A float: `as` truncates toward zero and saturates at the int32 bounds.
#[allow(clippy::cast_possible_truncation)]
number.as_f64().map_or(0, |value| value as i32)
}

/// Deserialize an optional `Scan` range field from any JSON number, as
/// [`saturated_int32`] reads it. A JSON value of another type is refused, as
/// it was when the field deserialized as an integer.
fn deserialize_int32_field<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<serde_json::Number>::deserialize(deserializer)
.map(|number| number.map(|n| i64::from(saturated_int32(&n))))
}

/// `Scan` response body.
#[derive(Debug, Clone, Serialize)]
pub struct ScanOutput {
Expand All @@ -183,3 +233,71 @@ pub struct ScanOutput {
#[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
pub consumed_capacity: Option<ConsumedCapacity>,
}

#[cfg(test)]
mod tests {
use serde_json::json;

use super::{ScanInput, saturated_int32};

fn number(text: &str) -> serde_json::Number {
serde_json::from_str(text).expect("a JSON number")
}

#[test]
fn range_fields_are_truncated_and_saturated_to_int32() {
for (text, expected) in [
("1", 1),
("-2147483648", i32::MIN),
("2147483647", i32::MAX),
("-2147483649", i32::MIN),
("2147483648", i32::MAX),
("9223372036854775808", i32::MAX),
("-9223372036854775809", i32::MIN),
("1.5", 1),
("1.0", 1),
("0.0", 0),
("-1.5", -1),
("-0.5", 0),
("-0", 0),
("1e2", 100),
("1e40", i32::MAX),
("-1e40", i32::MIN),
] {
assert_eq!(saturated_int32(&number(text)), expected, "{text}");
}
}

#[test]
fn scan_input_reads_any_number_in_the_range_fields() {
let input: ScanInput = serde_json::from_value(json!({
"TableName": "t", "Limit": 1.5, "Segment": 1.5, "TotalSegments": 2_147_483_648_i64,
}))
.expect("accepted");
assert_eq!(
(input.limit, input.segment, input.total_segments),
(Some(1), Some(1), Some(i64::from(i32::MAX)))
);
let input: ScanInput = serde_json::from_str(
r#"{"TableName": "t", "Limit": 9223372036854775808, "Segment": null}"#,
)
.expect("accepted");
assert_eq!(
(input.limit, input.segment),
(Some(i64::from(i32::MAX)), None)
);
let input: ScanInput = serde_json::from_value(json!({"TableName": "t"})).expect("accepted");
assert_eq!(input.total_segments, None);
// Another JSON type is still refused.
for body in [
json!({"TableName": "t", "Limit": "1"}),
json!({"TableName": "t", "Segment": true}),
json!({"TableName": "t", "TotalSegments": [1]}),
] {
assert!(
serde_json::from_value::<ScanInput>(body.clone()).is_err(),
"{body}"
);
}
}
}
91 changes: 91 additions & 0 deletions crates/engine/src/query_key_values.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ fn query_request_checks(body: &Value) -> Result<(), DynamoDbError> {
if let Some(limit) = body.get("Limit").and_then(Value::as_i64)
&& limit < 1
{
// Beside a value that is not one AttributeValue the service answers
// its older form, with the value echoed (measured 2026-10-25 12:56 UTC
// on Query and Scan, and 2026-10-31 for the key and the lists).
if has_unreadable_attribute_value(body, QUERY_CONDITIONS) {
return Err(DynamoDbError::ValidationException(format!(
"1 validation error detected: Value '{}' at 'limit' failed to satisfy constraint: \
Member must have value greater than or equal to 1",
limit.clamp(i64::from(i32::MIN), i64::from(i32::MAX))
)));
}
return Err(DynamoDbError::ValidationException(
LIMIT_BELOW_ONE.to_owned(),
));
Expand Down Expand Up @@ -194,6 +204,87 @@ pub(crate) fn non_empty(body: &Value, field: &str) -> bool {
}
}

/// The type descriptors an AttributeValue may carry.
const TYPE_DESCRIPTORS: &[&str] = &["S", "N", "B", "SS", "NS", "BS", "M", "L", "NULL", "BOOL"];

/// The legacy condition maps of `Scan` whose `AttributeValueList` entries
/// [`has_unreadable_attribute_value`] reads.
pub(crate) const SCAN_CONDITIONS: &[&str] = &["ScanFilter"];

/// The legacy condition maps of `Query` whose `AttributeValueList` entries
/// [`has_unreadable_attribute_value`] reads.
pub(crate) const QUERY_CONDITIONS: &[&str] = &["KeyConditions", "QueryFilter"];

/// Whether the request holds an object that is not one AttributeValue: empty,
/// with a type descriptor the service does not have (a lower-case `s`
/// included), or with more than one, at the top or as a member of an `L` or
/// `M` at any depth. The values read are the `ExpressionAttributeValues`
/// entries, the `ExclusiveStartKey` values and the `AttributeValueList`
/// entries of the `conditions` maps. Beside such a value the service reports a
/// range fault in its older form, and beside a readable value, of the wrong
/// type for a key or not, the newer form.
///
/// Measured on 2026-10-25 at 12:56 UTC: `Limit: 0` beside an entry `{}`,
/// `{"X": "1"}` or `{"S": "x", "N": "1"}` answers `Value '0' at 'limit'` on
/// Query and Scan, and beside an empty or duplicate `NS` the newer form. On
/// 2026-10-30 at 12:40 UTC on Scan and 2026-10-31 at 12:36 UTC on Query:
/// beside an entry `{"L": [{}]}`, `{"M": {"k": {"X": "1"}}}` or `{"s": "x"}`,
/// the older form. On 2026-10-31 between 23:54 and 23:56 UTC: beside `{}` or
/// `{"X": "1"}` as an `ExclusiveStartKey` value or a `ScanFilter`
/// `AttributeValueList` entry on Scan, and `{}` as an `ExclusiveStartKey`
/// value or a `KeyConditions` or `QueryFilter` `AttributeValueList` entry on
/// Query, the older form, for a `Segment` fault too; beside `{"N": "1"}` as
/// the value of an `S` key, `{"N": "1"}` in a `ScanFilter` list and `{"S":
/// "p0"}` in a `KeyConditions` list, the newer form. An `L` or `M` member of
/// an `ExclusiveStartKey` value or of a list entry is read as one of an
/// `ExpressionAttributeValues` entry is; that is not measured.
///
/// A value that is not an object is a shape fault, reported ahead of the
/// checks.
pub(crate) fn has_unreadable_attribute_value(body: &Value, conditions: &[&str]) -> bool {
let in_map = |field: &str| {
body.get(field)
.and_then(Value::as_object)
.is_some_and(|values| values.values().any(is_unreadable_attribute_value))
};
let in_lists = |field: &&str| {
body.get(*field)
.and_then(Value::as_object)
.is_some_and(|conditions| {
conditions.values().any(|condition| {
condition
.get("AttributeValueList")
.and_then(Value::as_array)
.is_some_and(|list| list.iter().any(is_unreadable_attribute_value))
})
})
};
in_map("ExpressionAttributeValues")
|| in_map("ExclusiveStartKey")
|| conditions.iter().any(in_lists)
}

/// Whether `entry` is an object that is not one AttributeValue, or an `L` or
/// `M` with such a member; see [`has_unreadable_attribute_value`].
fn is_unreadable_attribute_value(entry: &Value) -> bool {
let Some(descriptors) = entry.as_object() else {
return false;
};
let mut fields = descriptors.iter();
match (fields.next(), fields.next()) {
(Some((descriptor, value)), None) => match descriptor.as_str() {
"L" => value
.as_array()
.is_some_and(|members| members.iter().any(is_unreadable_attribute_value)),
"M" => value
.as_object()
.is_some_and(|members| members.values().any(is_unreadable_attribute_value)),
descriptor => !TYPE_DESCRIPTORS.contains(&descriptor),
},
_ => true,
}
}

/// Refuse the first `N` value in `ExpressionAttributeValues` (including `NS`
/// members and values nested in `L` and `M`) that is not a number, with the
/// bare numeric message: no `ExpressionAttributeValues contains invalid value`
Expand Down
78 changes: 66 additions & 12 deletions crates/engine/src/query_key_values/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,18 +562,33 @@ fn a_shape_fault_in_another_field_is_reported_ahead_of_the_checks() {
Some(LIMIT_BELOW_ONE)
);

// Neither does an unknown type descriptor, which the deserializer reports
// as a SerializationException: the service answers the Limit
// ValidationException (measured 2026-10-25), in its older
// `Value '0' at 'limit'` form; ExtendDB answers its usual Limit text.
let unknown_type = serde_json::json!({
"TableName": "t", "KeyConditionExpression": "pk = :p",
"Limit": 0, "ExpressionAttributeValues": {":p": {"S": "x"}, ":a": {"X": "1"}},
});
assert_eq!(
message(validate_query_request_body(&unknown_type)).as_deref(),
Some(LIMIT_BELOW_ONE)
);
// Neither does an entry the service cannot read as one AttributeValue (an
// unknown type descriptor, which the deserializer reports as a
// SerializationException, an empty one or one with two descriptors): the
// service answers the Limit ValidationException in its older form, with
// the value echoed (measured 2026-10-25 12:56 UTC). An unreadable member
// of an L or M and a lower-case descriptor do the same (measured on Scan
// 2026-10-30 12:40 UTC and on Query 2026-10-31 12:36 UTC).
for entry in [
serde_json::json!({"X": "1"}),
serde_json::json!({}),
serde_json::json!({"S": "x", "N": "1"}),
serde_json::json!({"L": [{}]}),
serde_json::json!({"M": {"k": {"X": "1"}}}),
serde_json::json!({"s": "x"}),
] {
let unreadable = serde_json::json!({
"TableName": "t", "KeyConditionExpression": "pk = :p",
"Limit": 0, "ExpressionAttributeValues": {":p": {"S": "x"}, ":a": entry},
});
assert_eq!(
message(validate_query_request_body(&unreadable)).as_deref(),
Some(
"1 validation error detected: Value '0' at 'limit' failed to satisfy constraint: Member must have value greater than or equal to 1"
),
"{unreadable}"
);
}

// A body that passes every check is left to the handler's deserializer.
let shape_fault_only = serde_json::json!({
Expand All @@ -582,3 +597,42 @@ fn a_shape_fault_in_another_field_is_reported_ahead_of_the_checks() {
});
assert!(validate_query_request_body(&shape_fault_only).is_ok());
}

/// Measured 2026-10-31 between 23:54 and 23:56 UTC: an empty value in
/// `ExclusiveStartKey`, or as an `AttributeValueList` entry of `KeyConditions`
/// or `QueryFilter`, answers a `Limit` of 0 in the older form, as an entry of
/// `ExpressionAttributeValues` does; a readable `KeyConditions` entry keeps the
/// newer form.
#[test]
fn an_unreadable_value_in_the_start_key_or_a_condition_list_switches_to_the_older_form() {
let older_limit = "1 validation error detected: Value '0' at 'limit' failed to satisfy constraint: Member must have value greater than or equal to 1";
let key_conditions = |entry: serde_json::Value| serde_json::json!({"pk": {"ComparisonOperator": "EQ", "AttributeValueList": [entry]}});
let start_key = serde_json::json!({
"TableName": "t", "KeyConditionExpression": "pk = :p", "Limit": 0,
"ExpressionAttributeValues": {":p": {"S": "p0"}},
"ExclusiveStartKey": {"pk": {}, "sk": {"S": "x"}},
});
let query_filter = serde_json::json!({
"TableName": "t", "KeyConditions": key_conditions(serde_json::json!({"S": "p0"})),
"Limit": 0,
"QueryFilter": {"a": {"ComparisonOperator": "EQ", "AttributeValueList": [{}]}},
});
let key_condition = serde_json::json!({
"TableName": "t", "KeyConditions": key_conditions(serde_json::json!({})), "Limit": 0,
});
for body in [start_key, query_filter, key_condition] {
assert_eq!(
message(validate_query_request_body(&body)).as_deref(),
Some(older_limit),
"{body}"
);
}
let readable = serde_json::json!({
"TableName": "t", "KeyConditions": key_conditions(serde_json::json!({"S": "p0"})),
"Limit": 0,
});
assert_eq!(
message(validate_query_request_body(&readable)).as_deref(),
Some(LIMIT_BELOW_ONE)
);
}
Loading
Loading