Skip to content
10 changes: 9 additions & 1 deletion crates/core/src/validation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1903,7 +1903,15 @@ fn validate_lsi_requires_range_key(input: &CreateTableInput) -> Result<(), Dynam
Ok(())
}

fn validate_attribute_number(value: &AttributeValue) -> Result<(), DynamoDbError> {
/// Validate every number inside one attribute value (`N`, `NS` members, and
/// the values nested in `L` and `M`) against the `DynamoDB` number rules, with
/// the bare numeric message.
///
/// # Errors
///
/// Returns `DynamoDbError::ValidationException` for the first number that is
/// not a number, has more than 38 significant digits, or is out of range.
pub fn validate_attribute_number(value: &AttributeValue) -> Result<(), DynamoDbError> {
match value {
AttributeValue::N(n) => {
number::validate_and_normalize_number(n)?;
Expand Down
127 changes: 127 additions & 0 deletions crates/engine/src/decimal_digits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// SPDX-License-Identifier: Apache-2.0

//! Unicode decimal digits (general category `Nd`) folded to ASCII for the
//! request-time number check.
//!
//! Measured 2026-10-25 (us-east-1, each request twice): the service reads a
//! fullwidth, Arabic-Indic or Devanagari digit in an `N` value as the digit it
//! stands for (`{"N": "\u{ff11}"}` in a `FilterExpression` answers OK; thirty
//! nine Devanagari ones answer `Attempting to store more than 38 significant
//! digits in a Number`), and refuses a superscript (`\u{b2}`), a vulgar
//! fraction (`\u{bd}`), a fullwidth minus (`\u{ff0d}`) and a fullwidth full
//! stop (`\u{ff0e}`) with `The parameter cannot be converted to a numeric
//! value: <value>`. The accepted characters are exactly those of category `Nd`;
//! the refused ones are `No` or `Pd` and `Po`.
//!
//! Every `Nd` run in Unicode 15.0 is ten consecutive code points starting at
//! its zero, so the table below holds the 68 zeros (generated with Python's
//! `unicodedata`, Unicode 15.0.0) and a digit's value is its offset from the
//! run's zero.
//!
//! The fold applies to the check only. The handler and the server's
//! `dynamodb:LeadingKeys` binding read the value as the request wrote it, so
//! both see the same text; reading a Unicode digit as the digit has to change
//! both together.

use std::borrow::Cow;

/// The zero of every `Nd` run in Unicode 15.0, ascending.
const ND_ZEROS: [u32; 68] = [
0x0030, 0x0660, 0x06F0, 0x07C0, 0x0966, 0x09E6, 0x0A66, 0x0AE6, 0x0B66, 0x0BE6, 0x0C66, 0x0CE6,
0x0D66, 0x0DE6, 0x0E50, 0x0ED0, 0x0F20, 0x1040, 0x1090, 0x17E0, 0x1810, 0x1946, 0x19D0, 0x1A80,
0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0,
0xFF10, 0x104A0, 0x10D30, 0x11066, 0x110F0, 0x11136, 0x111D0, 0x112F0, 0x11450, 0x114D0,
0x11650, 0x116C0, 0x11730, 0x118E0, 0x11950, 0x11C50, 0x11D50, 0x11DA0, 0x11F50, 0x16A60,
0x16AC0, 0x16B50, 0x1D7CE, 0x1D7D8, 0x1D7E2, 0x1D7EC, 0x1D7F6, 0x1E140, 0x1E2F0, 0x1E4F0,
0x1E950, 0x1FBF0,
];

/// The ASCII digit `c` stands for when `c` is a Unicode decimal digit
/// (general category `Nd`), or `None` for every other character.
pub(crate) fn ascii_digit(c: char) -> Option<char> {
let cp = u32::from(c);
// The zero of the run that could hold `cp`: the last zero at or below it.
let run = ND_ZEROS
.partition_point(|&zero| zero <= cp)
.checked_sub(1)?;
let offset = cp - ND_ZEROS[run];
u8::try_from(offset)
.ok()
.filter(|offset| *offset < 10)
.map(|offset| char::from(b'0' + offset))
}

/// `s` with every Unicode decimal digit replaced by the ASCII digit it stands
/// for. Borrows `s` when it has no such digit outside ASCII.
pub(crate) fn fold_decimal_digits(s: &str) -> Cow<'_, str> {
if s.is_ascii() || !s.chars().any(|c| !c.is_ascii() && ascii_digit(c).is_some()) {
return Cow::Borrowed(s);
}
Cow::Owned(s.chars().map(|c| ascii_digit(c).unwrap_or(c)).collect())
}

#[cfg(test)]
mod tests {
use super::{ND_ZEROS, ascii_digit, fold_decimal_digits};

#[test]
fn measured_digits_fold_to_their_ascii_value() {
// Fullwidth one, Arabic-Indic one, Devanagari one and fullwidth seven,
// measured 2026-10-25.
assert_eq!(ascii_digit('\u{ff11}'), Some('1'));
assert_eq!(ascii_digit('\u{661}'), Some('1'));
assert_eq!(ascii_digit('\u{967}'), Some('1'));
assert_eq!(ascii_digit('\u{ff17}'), Some('7'));
assert_eq!(ascii_digit('0'), Some('0'));
assert_eq!(ascii_digit('9'), Some('9'));
}

#[test]
fn measured_controls_are_not_digits() {
// Superscript two, vulgar half, fullwidth minus, fullwidth full stop:
// refused by the service (measured 2026-10-25).
for c in ['\u{b2}', '\u{bd}', '\u{ff0d}', '\u{ff0e}'] {
assert_eq!(ascii_digit(c), None, "{c:?}");
}
// Neighbours of a run and non-digit characters.
assert_eq!(ascii_digit('\u{ff10}'), Some('0'));
assert_eq!(ascii_digit('\u{ff19}'), Some('9'));
assert_eq!(ascii_digit('\u{ff1a}'), None);
assert_eq!(ascii_digit('\u{ff0f}'), None);
assert_eq!(ascii_digit('a'), None);
assert_eq!(ascii_digit('.'), None);
assert_eq!(ascii_digit('e'), None);
assert_eq!(ascii_digit('\u{1FBF9}'), Some('9'));
assert_eq!(ascii_digit('\u{1FBFA}'), None);
assert_eq!(ascii_digit(char::MAX), None);
assert_eq!(ascii_digit('\u{0}'), None);
}

#[test]
fn zeros_are_ascending_and_distinct_runs() {
for pair in ND_ZEROS.windows(2) {
assert!(pair[0] + 10 <= pair[1], "{pair:?}");
}
}

#[test]
fn folding_keeps_every_other_character() {
assert_eq!(fold_decimal_digits("\u{ff11}"), "1");
assert_eq!(fold_decimal_digits("1\u{ff12}"), "12");
assert_eq!(fold_decimal_digits("1e\u{ff12}"), "1e2");
assert_eq!(fold_decimal_digits(&"\u{967}".repeat(39)), "1".repeat(39));
assert_eq!(fold_decimal_digits("\u{ff0d}1"), "\u{ff0d}1");
assert_eq!(fold_decimal_digits("1\u{ff0e}5"), "1\u{ff0e}5");
assert_eq!(fold_decimal_digits("\u{b2}"), "\u{b2}");
assert_eq!(fold_decimal_digits("-1.5e3"), "-1.5e3");
assert_eq!(fold_decimal_digits(""), "");
assert!(matches!(
fold_decimal_digits("bob"),
std::borrow::Cow::Borrowed(_)
));
assert!(matches!(
fold_decimal_digits("\u{ff0d}"),
std::borrow::Cow::Borrowed(_)
));
}
}
2 changes: 2 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod batch_get_item;
mod batch_write_item;
pub mod capacity_helpers;
mod create_table;
mod decimal_digits;
mod delete_item;
mod delete_table;
mod describe_endpoints;
Expand All @@ -27,6 +28,7 @@ mod legacy_filter;
mod list_tables;
mod put_item;
mod query;
pub mod query_key_values;
mod read_helpers;
mod request_limits;
mod scan;
Expand Down
84 changes: 23 additions & 61 deletions crates/engine/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,32 +43,13 @@ pub async fn handle_query(
body: Value,
ctx: &OperationContext,
) -> Result<DispatchResult, DynamoDbError> {
// Pre-scanned before typed deserialization, sequentially: Query stops at
// the FIRST invalid enum, checking ReturnConsumedCapacity ahead of Select.
// Measured 2026-08-24 (us-east-1): both invalid answers "1 validation
// error detected" naming returnConsumedCapacity alone. (Scan is the
// opposite on both counts: it aggregates, select-first; see handle_scan.)
crate::validate_enum_fields(
&body,
&[crate::EnumField {
json_name: "ReturnConsumedCapacity",
valid: &["INDEXES", "TOTAL", "NONE"],
clause: crate::EnumClause::Named("returnConsumedCapacity"),
}],
)?;
crate::validate_enum_fields(
&body,
&[crate::EnumField {
json_name: "Select",
valid: &[
"SPECIFIC_ATTRIBUTES",
"COUNT",
"ALL_ATTRIBUTES",
"ALL_PROJECTED_ATTRIBUTES",
],
clause: crate::EnumClause::Named("select"),
}],
)?;
// Request-time checks on the raw body, in the service's order: Limit, the
// expression and non-expression parameter mix, the ReturnConsumedCapacity
// enum, every number in ExpressionAttributeValues, the Select enum. They
// run before typed deserialization so a field that does not deserialize
// does not stop the ones before it. The server runs the same function
// ahead of the IAM decision; see `query_key_values`.
crate::query_key_values::validate_query_request_body(&body)?;
let input: QueryInput = serde_json::from_value(body).map_err(crate::deserialize_error)?;

// P118: Fetch key_info first so we can use table_id for index lookup.
Expand Down Expand Up @@ -137,14 +118,7 @@ pub async fn handle_query(
)?;
}

// Validate Limit >= 1 (REQ-QUERY-001)
if let Some(limit) = input.limit
&& limit < 1
{
return Err(DynamoDbError::ValidationException(
"1 validation error detected: Value at 'Limit' failed to satisfy constraint: Member must have value greater than or equal to 1".to_owned(),
));
}
// Limit >= 1 is checked at request time in validate_query_request_body.

// For index queries, build a key_info that reflects the index's key schema
// so the storage layer uses the correct SK column for the index table.
Expand All @@ -166,35 +140,10 @@ pub async fn handle_query(
key_info.clone()
};

// --- Legacy vs expression mutual exclusivity checks ---
let kce_present = input.key_condition_expression.is_some();
let has_kc = input.key_conditions.as_ref().is_some_and(|m| !m.is_empty());
let has_fe = input.filter_expression.is_some();
let has_qf = input.query_filter.as_ref().is_some_and(|m| !m.is_empty());
let has_pe = input.projection_expression.is_some();
let has_atg = input
.attributes_to_get
.as_ref()
.is_some_and(|a| !a.is_empty());
// The legacy versus expression mutual exclusivity check runs at request
// time in validate_query_request_body.
let has_cond_op = input.conditional_operator.is_some();

// KeyConditions legitimately combines with FilterExpression and
// ProjectionExpression, so it joins the mixing check only when
// KeyConditionExpression is also present.
extenddb_core::validation::validate_no_expression_param_mixing(
&[
("AttributesToGet", has_atg),
("QueryFilter", has_qf),
("ConditionalOperator", has_cond_op),
("KeyConditions", has_kc && kce_present),
],
&[
("ProjectionExpression", has_pe),
("FilterExpression", has_fe),
("KeyConditionExpression", kce_present),
],
)?;

// An explicitly empty KeyConditionExpression is rejected up front, before
// the expression-parameter-usage checks (matching real DynamoDB).
reject_empty_key_condition_expression(input.key_condition_expression.as_deref())?;
Expand Down Expand Up @@ -471,6 +420,19 @@ pub async fn handle_query(
)?;
}

// A key condition value whose type is not the key attribute's type is
// refused after the key schema, filter, projection, unused value and
// Select checks and before the ExclusiveStartKey check (measured
// 2026-10-24). The legacy KeyConditions path makes its own checks when it
// desugars.
if legacy_kc_maps.is_none() {
crate::query_key_values::validate_key_condition_types(
&key_condition,
effective_maps,
&query_key_info.attribute_definitions,
)?;
}

// Validate ExclusiveStartKey matches the key schema
if let Some(ref start_key) = input.exclusive_start_key {
validate_query_exclusive_start_key(start_key, &key_info, index_info.as_ref())?;
Expand Down
Loading
Loading