Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/core/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,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