Skip to main content

dynoxide/
validation.rs

1use crate::errors::{DynoxideError, Result};
2use crate::types::{
3    AttributeDefinition, AttributeValue, GlobalSecondaryIndex, Item, KeySchemaElement, KeyType,
4    ScalarAttributeType,
5};
6
7/// Per-operation context for table-name validation.
8///
9/// AWS DynamoDB applies different constraints to `tableName` depending on the
10/// operation. CreateTable enforces a minimum of 3 characters and a regex
11/// pattern. Read/write operations (PutItem, GetItem, Query, Scan, UpdateItem,
12/// DeleteItem, BatchGet/Write, TransactGet/Write) only enforce a minimum of 1
13/// character; the regex pattern only fires on a non-empty invalid name.
14#[derive(Copy, Clone, Debug)]
15pub enum TableNameContext {
16    /// CreateTable: regex pattern + minimum length 3.
17    CreateTable,
18    /// PutItem and friends: minimum length 1, regex pattern only on non-empty input.
19    ReadWrite,
20}
21
22/// Validate a DynamoDB table name for a read/write operation.
23///
24/// Equivalent to `table_name_constraint_errors(Some(name), TableNameContext::ReadWrite)`
25/// followed by formatting into the multi-error envelope. CreateTable callers must use
26/// `table_name_constraint_errors` directly with `TableNameContext::CreateTable` because
27/// CreateTable's full validation produces additional errors that need to be folded into
28/// a single envelope.
29pub fn validate_table_name(name: &str) -> Result<()> {
30    let errors = table_name_constraint_errors(Some(name), TableNameContext::ReadWrite);
31    if errors.is_empty() {
32        return Ok(());
33    }
34    let count = errors.len();
35    let msg = format!(
36        "{count} validation error{} detected: {}",
37        if count == 1 { "" } else { "s" },
38        errors.join("; ")
39    );
40    Err(DynoxideError::ValidationException(msg))
41}
42
43/// Collect table-name constraint errors for the multi-error validation format.
44///
45/// Returns a (possibly empty) list of error strings. If `table_name` is `None`,
46/// a "must not be null" error is emitted. If it is present but invalid, pattern
47/// and/or length errors are emitted, gated by `context`.
48pub fn table_name_constraint_errors(
49    table_name: Option<&str>,
50    context: TableNameContext,
51) -> Vec<String> {
52    let mut errors = Vec::new();
53    match table_name {
54        None => {
55            errors.push(
56                "Value null at 'tableName' failed to satisfy constraint: \
57                 Member must not be null"
58                    .to_string(),
59            );
60        }
61        Some(name) => match context {
62            TableNameContext::CreateTable => {
63                if name.is_empty()
64                    || !name
65                        .chars()
66                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
67                {
68                    errors.push(format!(
69                        "Value '{}' at 'tableName' failed to satisfy constraint: \
70                         Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
71                        name
72                    ));
73                }
74                if name.len() < 3 {
75                    errors.push(format!(
76                        "Value '{}' at 'tableName' failed to satisfy constraint: \
77                         Member must have length greater than or equal to 3",
78                        name
79                    ));
80                }
81                if name.len() > 255 {
82                    errors.push(format!(
83                        "Value '{}' at 'tableName' failed to satisfy constraint: \
84                         Member must have length less than or equal to 255",
85                        name
86                    ));
87                }
88            }
89            TableNameContext::ReadWrite => {
90                if name.is_empty() {
91                    errors.push(
92                        "Value '' at 'tableName' failed to satisfy constraint: \
93                         Member must have length greater than or equal to 1"
94                            .to_string(),
95                    );
96                } else if !name
97                    .chars()
98                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
99                {
100                    errors.push(format!(
101                        "Value '{}' at 'tableName' failed to satisfy constraint: \
102                         Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
103                        name
104                    ));
105                }
106                if name.len() > 255 {
107                    errors.push(format!(
108                        "Value '{}' at 'tableName' failed to satisfy constraint: \
109                         Member must have length less than or equal to 255",
110                        name
111                    ));
112                }
113            }
114        },
115    }
116    errors
117}
118
119/// Format a list of constraint validation errors into the DynamoDB multi-error format.
120///
121/// Returns `Some(message)` if there are errors, `None` if empty.
122pub fn format_validation_errors(errors: &[String]) -> Option<String> {
123    if errors.is_empty() {
124        return None;
125    }
126    if let [single] = errors {
127        return Some(envelope_message(single));
128    }
129    Some(format!(
130        "{} validation errors detected: {}",
131        errors.len(),
132        errors.join("; ")
133    ))
134}
135
136/// The single-error form of the request-validation envelope. This and
137/// `format_validation_errors` are the only producers of the prefix wording.
138pub(crate) fn envelope_message(msg: &str) -> String {
139    format!("1 validation error detected: {msg}")
140}
141
142/// Wrap an `EnvelopedValidation` error in the `1 validation error detected: `
143/// envelope, converting it to a plain `ValidationException`. Every other error
144/// passes through unchanged.
145///
146/// PutItem and UpdateItem apply this at their operation boundary.
147pub(crate) fn envelope_request_validation(err: DynoxideError) -> DynoxideError {
148    match err {
149        DynoxideError::EnvelopedValidation(msg) => {
150            DynoxideError::ValidationException(envelope_message(&msg))
151        }
152        other => other,
153    }
154}
155
156/// Convert the wire-invisible `EnvelopedValidation` tag back to a plain,
157/// unenveloped `ValidationException`. Every other error passes through
158/// unchanged.
159///
160/// Operations other than PutItem and UpdateItem report the request-validation
161/// family bare, and the tag must never reach the wire.
162///
163/// Compiled only for the surfaces that resolve the tag: the HTTP server, the
164/// MCP tools, the wasm engine API, and the unit tests.
165#[cfg(any(
166    feature = "http-server",
167    feature = "mcp-server",
168    feature = "wasm-sqlite",
169    test
170))]
171pub(crate) fn strip_request_validation_tag(err: DynoxideError) -> DynoxideError {
172    match err {
173        DynoxideError::EnvelopedValidation(msg) => DynoxideError::ValidationException(msg),
174        other => other,
175    }
176}
177
178/// Resolve the wire-invisible `EnvelopedValidation` tag for a named operation:
179/// PutItem and UpdateItem wrap the request-validation family in the
180/// `1 validation error detected: ` envelope, every other operation reports it
181/// bare. Either way the tag never reaches the wire. Enveloping is idempotent
182/// for untagged errors, so applying this to an already-enveloped action error
183/// is safe.
184///
185/// Owner of the operation split for the HTTP and wasm dispatch seams, which
186/// both route through here. The same PutItem/UpdateItem membership is encoded
187/// at the action boundaries (`put_item::execute` / `update_item::execute`
188/// apply the envelope for the in-process API) and in the MCP tools' choice of
189/// resolver, so a future enveloped operation must update all three places.
190///
191/// Compiled only for the dispatch seams that consume it: the HTTP server, the
192/// wasm engine API, and the unit tests.
193#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
194pub(crate) fn resolve_request_validation_tag(operation: &str, err: DynoxideError) -> DynoxideError {
195    match operation {
196        "PutItem" | "UpdateItem" => envelope_request_validation(err),
197        _ => strip_request_validation_tag(err),
198    }
199}
200
201/// A validation failure from a shared validator, classified so PutItem and
202/// UpdateItem can tell which families DynamoDB wraps in the
203/// `1 validation error detected: ` envelope.
204///
205/// Every other caller converts it straight back to the original error via the
206/// `From` impl, so plain `?` propagation keeps their behaviour byte-identical.
207#[derive(Debug)]
208pub(crate) enum ClassifiedValidationError {
209    /// A family DynamoDB reports bare, or an error adopted from an
210    /// unclassified helper.
211    Bare(DynoxideError),
212    /// A family DynamoDB wraps in the request-validation envelope on PutItem
213    /// and UpdateItem.
214    Enveloped(String),
215}
216
217impl ClassifiedValidationError {
218    /// A family DynamoDB reports bare.
219    pub(crate) fn bare(message: impl Into<String>) -> Self {
220        Self::Bare(DynoxideError::ValidationException(message.into()))
221    }
222
223    /// A family DynamoDB wraps in the request-validation envelope on PutItem
224    /// and UpdateItem.
225    pub(crate) fn enveloped(message: impl Into<String>) -> Self {
226        Self::Enveloped(message.into())
227    }
228
229    /// Convert for a PutItem/UpdateItem call site: enveloped families become
230    /// the wire-invisible `EnvelopedValidation` tag (unwrapped at the
231    /// operation boundary), everything else passes through unchanged.
232    pub(crate) fn into_tagged(self) -> DynoxideError {
233        match self {
234            Self::Enveloped(msg) => DynoxideError::EnvelopedValidation(msg),
235            Self::Bare(err) => err,
236        }
237    }
238}
239
240impl From<ClassifiedValidationError> for DynoxideError {
241    fn from(e: ClassifiedValidationError) -> Self {
242        match e {
243            ClassifiedValidationError::Enveloped(msg) => DynoxideError::ValidationException(msg),
244            ClassifiedValidationError::Bare(err) => err,
245        }
246    }
247}
248
249impl From<DynoxideError> for ClassifiedValidationError {
250    /// Errors adopted from unclassified helpers stay bare; only call sites
251    /// that know a family is enveloped construct the enveloped form.
252    fn from(error: DynoxideError) -> Self {
253        Self::Bare(error)
254    }
255}
256
257/// Validate key schema: exactly one HASH key, optionally one RANGE key.
258///
259/// DynamoDB validates positionally: the first element must be HASH and, if a
260/// second element is present, it must be RANGE.
261pub fn validate_key_schema(key_schema: &[KeySchemaElement]) -> Result<()> {
262    if key_schema.is_empty() || key_schema.len() > 2 {
263        return Err(DynoxideError::ValidationException(
264            "1 validation error detected: Value null at 'keySchema' failed to satisfy constraint: \
265             Member must have length less than or equal to 2"
266                .to_string(),
267        ));
268    }
269
270    // First element must be HASH.
271    if key_schema[0].key_type != KeyType::HASH {
272        return Err(DynoxideError::ValidationException(
273            "Invalid KeySchema: The first KeySchemaElement is not a HASH key type".to_string(),
274        ));
275    }
276
277    // Check for duplicate attribute names (before type check, matching DynamoDB ordering).
278    if key_schema.len() == 2 && key_schema[0].attribute_name == key_schema[1].attribute_name {
279        return Err(DynoxideError::ValidationException(
280            "Both the Hash Key and the Range Key element in the KeySchema have the same name"
281                .to_string(),
282        ));
283    }
284
285    // Second element, if present, must be RANGE.
286    if key_schema.len() == 2 && key_schema[1].key_type != KeyType::RANGE {
287        return Err(DynoxideError::ValidationException(
288            "Invalid KeySchema: The second KeySchemaElement is not a RANGE key type".to_string(),
289        ));
290    }
291
292    Ok(())
293}
294
295/// Validate attribute definitions: types must be S, N, or B.
296pub fn validate_attribute_definitions(defs: &[AttributeDefinition]) -> Result<()> {
297    if defs.is_empty() {
298        return Err(DynoxideError::ValidationException(
299            "1 validation error detected: Value null at 'attributeDefinitions' failed to satisfy \
300             constraint: Member must have length greater than or equal to 1"
301                .to_string(),
302        ));
303    }
304
305    for def in defs {
306        match def.attribute_type {
307            ScalarAttributeType::S | ScalarAttributeType::N | ScalarAttributeType::B => {}
308        }
309    }
310
311    Ok(())
312}
313
314/// Validate that all key schema attributes are defined in attribute definitions.
315pub fn validate_key_attributes_in_definitions(
316    key_schema: &[KeySchemaElement],
317    definitions: &[AttributeDefinition],
318) -> Result<()> {
319    for key_elem in key_schema {
320        let found = definitions
321            .iter()
322            .any(|def| def.attribute_name == key_elem.attribute_name);
323        if !found {
324            return Err(DynoxideError::ValidationException(format!(
325                "One or more parameter values were invalid: Some index key attributes are not \
326                 defined in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
327                key_elem.attribute_name,
328                definitions
329                    .iter()
330                    .map(|d| d.attribute_name.as_str())
331                    .collect::<Vec<_>>()
332                    .join(", ")
333            )));
334        }
335    }
336
337    Ok(())
338}
339
340/// Validate a Global Secondary Index definition.
341///
342/// `request_definitions` must be the AttributeDefinitions declared in the
343/// current request, not the table's merged stored set: DynamoDB requires a new
344/// index's key attributes to be (re)declared in the request itself.
345pub fn validate_gsi(
346    gsi: &GlobalSecondaryIndex,
347    request_definitions: &[AttributeDefinition],
348) -> Result<()> {
349    // Validate index name length
350    if gsi.index_name.len() < 3 || gsi.index_name.len() > 255 {
351        return Err(DynoxideError::ValidationException(format!(
352            "1 validation error detected: Value '{}' at 'globalSecondaryIndexes.1.member.indexName' \
353             failed to satisfy constraint: Member must have length greater than or equal to 3",
354            gsi.index_name
355        )));
356    }
357
358    // Validate index name character set (same as table names)
359    if !gsi
360        .index_name
361        .chars()
362        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
363    {
364        return Err(DynoxideError::ValidationException(format!(
365            "1 validation error detected: Value '{}' at 'globalSecondaryIndexes.1.member.indexName' \
366             failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
367            gsi.index_name
368        )));
369    }
370
371    // Validate key schema
372    validate_key_schema(&gsi.key_schema)?;
373
374    // Validate projection
375    validate_projection(&gsi.projection, &gsi.index_name)?;
376
377    // Validate GSI key attributes exist in definitions
378    validate_key_attributes_in_definitions(&gsi.key_schema, request_definitions)?;
379
380    Ok(())
381}
382
383/// Validate a Projection (for GSI or LSI).
384///
385/// DynamoDB checks:
386/// 1. ProjectionType must be present (not null)
387/// 2. If NonKeyAttributes is specified, ProjectionType must be INCLUDE
388pub fn validate_projection(projection: &crate::types::Projection, _index_name: &str) -> Result<()> {
389    match &projection.projection_type {
390        None => {
391            return Err(DynoxideError::ValidationException(
392                "One or more parameter values were invalid: Unknown ProjectionType: null"
393                    .to_string(),
394            ));
395        }
396        Some(pt) => {
397            if let Some(ref nka) = projection.non_key_attributes {
398                // NonKeyAttributes is present; check ProjectionType compatibility
399                match pt {
400                    crate::types::ProjectionType::ALL => {
401                        return Err(DynoxideError::ValidationException(
402                            "One or more parameter values were invalid: \
403                             ProjectionType is ALL, but NonKeyAttributes is specified"
404                                .to_string(),
405                        ));
406                    }
407                    crate::types::ProjectionType::KEYS_ONLY => {
408                        return Err(DynoxideError::ValidationException(
409                            "One or more parameter values were invalid: \
410                             ProjectionType is KEYS_ONLY, but NonKeyAttributes is specified"
411                                .to_string(),
412                        ));
413                    }
414                    crate::types::ProjectionType::INCLUDE => {
415                        // NonKeyAttributes with INCLUDE is valid, but must not be empty
416                        if nka.is_empty() {
417                            return Err(DynoxideError::ValidationException(
418                                "One or more parameter values were invalid: \
419                                 NonKeyAttributes must not be empty"
420                                    .to_string(),
421                            ));
422                        }
423                    }
424                }
425            }
426        }
427    }
428    Ok(())
429}
430
431/// Extract the partition key name from a key schema.
432pub fn partition_key_name(key_schema: &[KeySchemaElement]) -> Option<&str> {
433    key_schema
434        .iter()
435        .find(|k| k.key_type == KeyType::HASH)
436        .map(|k| k.attribute_name.as_str())
437}
438
439/// DynamoDB caps document nesting at 32 levels, counting the top-level attribute
440/// value as level 1. Values are validated 0-indexed (top-level = depth 0), so the
441/// deepest permitted leaf sits at depth 31 and a value reaching depth 32 is rejected.
442const MAX_NESTING_DEPTH: usize = 32;
443
444/// Real DynamoDB's verbatim message when document nesting exceeds the limit. Shared
445/// by the stored-item and ExpressionAttributeValue checks so both match AWS.
446const NESTING_LIMIT_MESSAGE: &str = "Nesting Levels have exceeded supported limits: Attributes in the item have nested levels beyond supported limit";
447
448/// Validate all attribute values in an item.
449///
450/// Rejects:
451/// - Empty sets (`{"SS": []}`, `{"NS": []}`, `{"BS": []}`)
452/// - Numbers that violate DynamoDB's precision/range constraints
453/// - Nesting deeper than 32 levels
454///
455/// Validation is recursive: invalid values nested inside L (list) or M (map) are also rejected.
456///
457/// **Note:** Empty strings (`{"S": ""}`) and empty binary values (`{"B": ""}`) are
458/// permitted in non-key attributes since DynamoDB's 2020 update. Key attributes
459/// are validated separately in `helpers::validate_key_type`.
460///
461/// **Important:** This must only be called on items being persisted, NOT on
462/// `ExpressionAttributeValues` (which may legitimately contain empty strings for comparisons).
463pub fn validate_item_attribute_values(item: &Item) -> crate::Result<()> {
464    validate_item_attribute_values_classified(item).map_err(Into::into)
465}
466
467/// Classified form of [`validate_item_attribute_values`]: the set families
468/// (empty and duplicate SS/NS/BS) are marked as enveloped so PutItem and
469/// UpdateItem can wrap them in the request-validation envelope; number and
470/// nesting errors stay bare. Every other caller uses the public wrapper,
471/// which converts back to the plain error unchanged.
472pub(crate) fn validate_item_attribute_values_classified(
473    item: &Item,
474) -> std::result::Result<(), ClassifiedValidationError> {
475    for value in item.values() {
476        validate_attribute_value(value, 0)?;
477    }
478    Ok(())
479}
480
481fn validate_attribute_value(
482    value: &AttributeValue,
483    depth: usize,
484) -> std::result::Result<(), ClassifiedValidationError> {
485    if depth >= MAX_NESTING_DEPTH {
486        return Err(ClassifiedValidationError::bare(NESTING_LIMIT_MESSAGE));
487    }
488    match value {
489        AttributeValue::SS(set) if set.is_empty() => Err(ClassifiedValidationError::enveloped(
490            "One or more parameter values were invalid: An string set  may not be empty",
491        )),
492        AttributeValue::NS(set) if set.is_empty() => Err(ClassifiedValidationError::enveloped(
493            "One or more parameter values were invalid: An number set  may not be empty",
494        )),
495        AttributeValue::BS(set) if set.is_empty() => Err(ClassifiedValidationError::enveloped(
496            "One or more parameter values were invalid: Binary sets should not be empty",
497        )),
498        AttributeValue::SS(set) if !set.is_empty() => {
499            let mut seen = std::collections::HashSet::new();
500            for s in set {
501                if !seen.insert(s.clone()) {
502                    let display: Vec<&str> = set.iter().map(|s| s.as_str()).collect();
503                    return Err(ClassifiedValidationError::enveloped(format!(
504                        "One or more parameter values were invalid: Input collection [{}] contains duplicates.",
505                        display.join(", ")
506                    )));
507                }
508            }
509            Ok(())
510        }
511        AttributeValue::BS(set) if !set.is_empty() => {
512            let mut seen = std::collections::HashSet::new();
513            for b in set {
514                if !seen.insert(b.clone()) {
515                    use base64::Engine;
516                    let display: Vec<String> = set
517                        .iter()
518                        .map(|s| base64::engine::general_purpose::STANDARD.encode(s))
519                        .collect();
520                    return Err(ClassifiedValidationError::enveloped(format!(
521                        "One or more parameter values were invalid: Input collection [{}]of type BS contains duplicates.",
522                        display.join(", ")
523                    )));
524                }
525            }
526            Ok(())
527        }
528        AttributeValue::NS(set) if !set.is_empty() => {
529            for n in set {
530                crate::types::validate_dynamo_number(n)?;
531            }
532            // Check for numeric duplicates
533            let mut seen = std::collections::HashSet::new();
534            for n in set {
535                let normalized = crate::types::normalize_dynamo_number(n);
536                if !seen.insert(normalized) {
537                    return Err(ClassifiedValidationError::enveloped(
538                        "Input collection contains duplicates",
539                    ));
540                }
541            }
542            Ok(())
543        }
544        AttributeValue::N(n) => {
545            crate::types::validate_dynamo_number(n)?;
546            Ok(())
547        }
548        AttributeValue::L(list) => {
549            for v in list {
550                validate_attribute_value(v, depth + 1)?;
551            }
552            Ok(())
553        }
554        AttributeValue::M(map) => {
555            for v in map.values() {
556                validate_attribute_value(v, depth + 1)?;
557            }
558            Ok(())
559        }
560        _ => Ok(()),
561    }
562}
563
564/// Validate that a single `ExpressionAttributeValue` does not nest deeper than
565/// DynamoDB allows.
566///
567/// Real DynamoDB rejects expression values whose document nesting exceeds 32 levels
568/// up front, before the expression is evaluated, raising the same bare nesting
569/// `ValidationException` it raises for over-deep stored items (no "ExpressionAttributeValues
570/// contains invalid value" wrapper). Only the nesting depth is checked here; empty
571/// strings and other shapes that are legal in comparisons are left untouched.
572pub fn validate_nesting_depth(value: &AttributeValue) -> Result<()> {
573    check_nesting_depth(value, 0)
574}
575
576fn check_nesting_depth(value: &AttributeValue, depth: usize) -> Result<()> {
577    if depth >= MAX_NESTING_DEPTH {
578        return Err(DynoxideError::ValidationException(
579            NESTING_LIMIT_MESSAGE.to_string(),
580        ));
581    }
582    match value {
583        AttributeValue::L(list) => list
584            .iter()
585            .try_for_each(|v| check_nesting_depth(v, depth + 1)),
586        AttributeValue::M(map) => map
587            .values()
588            .try_for_each(|v| check_nesting_depth(v, depth + 1)),
589        _ => Ok(()),
590    }
591}
592
593/// Validate Key attribute values before table-level checks.
594///
595/// This validates the attribute values in a Key map for:
596/// - Invalid/empty numbers
597/// - Empty sets, duplicate sets
598/// - Multiple datatypes
599///
600/// These errors are returned with "One or more parameter values were invalid: " prefix.
601pub fn validate_key_attribute_values(key: &Item) -> Result<()> {
602    for value in key.values() {
603        validate_key_attr_value(value)?;
604    }
605    Ok(())
606}
607
608fn validate_key_attr_value(value: &AttributeValue) -> Result<()> {
609    match value {
610        AttributeValue::SS(set) if set.is_empty() => {
611            return Err(DynoxideError::ValidationException(
612                "One or more parameter values were invalid: An string set  may not be empty"
613                    .to_string(),
614            ));
615        }
616        AttributeValue::NS(set) if set.is_empty() => {
617            return Err(DynoxideError::ValidationException(
618                "One or more parameter values were invalid: An number set  may not be empty"
619                    .to_string(),
620            ));
621        }
622        AttributeValue::BS(set) if set.is_empty() => {
623            return Err(DynoxideError::ValidationException(
624                "One or more parameter values were invalid: Binary sets should not be empty"
625                    .to_string(),
626            ));
627        }
628        AttributeValue::SS(set) => {
629            // Check for duplicates
630            let mut seen = std::collections::HashSet::new();
631            for s in set {
632                if !seen.insert(s.clone()) {
633                    let display: Vec<&str> = set.iter().map(|s| s.as_str()).collect();
634                    return Err(DynoxideError::ValidationException(format!(
635                        "One or more parameter values were invalid: \
636                         Input collection [{}] contains duplicates.",
637                        display.join(", ")
638                    )));
639                }
640            }
641        }
642        AttributeValue::NS(set) if !set.is_empty() => {
643            // Validate numbers and check for duplicates
644            for n in set {
645                crate::types::validate_dynamo_number(n)?;
646            }
647            let mut seen = std::collections::HashSet::new();
648            for n in set {
649                let normalized = crate::types::normalize_dynamo_number(n);
650                if !seen.insert(normalized) {
651                    return Err(DynoxideError::ValidationException(
652                        "Input collection contains duplicates".to_string(),
653                    ));
654                }
655            }
656        }
657        AttributeValue::BS(set) => {
658            // Check for duplicates
659            let mut seen = std::collections::HashSet::new();
660            for b in set {
661                if !seen.insert(b.clone()) {
662                    use base64::Engine;
663                    let display: Vec<String> = set
664                        .iter()
665                        .map(|s| base64::engine::general_purpose::STANDARD.encode(s))
666                        .collect();
667                    return Err(DynoxideError::ValidationException(format!(
668                        "One or more parameter values were invalid: \
669                         Input collection [{}]of type BS contains duplicates.",
670                        display.join(", ")
671                    )));
672                }
673            }
674        }
675        AttributeValue::N(n) => {
676            crate::types::validate_dynamo_number(n)?;
677        }
678        _ => {}
679    }
680    Ok(())
681}
682
683/// Normalize sets within an item by deduplicating them in-place.
684///
685/// - SS: deduplicates by string value
686/// - NS: deduplicates by numeric value (e.g., "1.0" and "1" are the same)
687/// - BS: deduplicates by byte content
688///
689/// Recursively normalizes sets inside L (list) and M (map) values.
690pub fn normalize_item_sets(item: &mut Item) {
691    for value in item.values_mut() {
692        normalize_attribute_sets(value);
693    }
694}
695
696fn normalize_attribute_sets(value: &mut AttributeValue) {
697    match value {
698        AttributeValue::N(n) => {
699            *n = crate::types::normalize_dynamo_number(n);
700        }
701        AttributeValue::SS(set) => {
702            let mut seen = std::collections::HashSet::new();
703            set.retain(|s| seen.insert(s.clone()));
704        }
705        AttributeValue::NS(set) => {
706            let mut seen = std::collections::HashSet::new();
707            set.retain(|n| seen.insert(normalize_number_for_dedup(n)));
708            // Normalize each number in the set
709            for n in set.iter_mut() {
710                *n = crate::types::normalize_dynamo_number(n);
711            }
712        }
713        AttributeValue::BS(set) => {
714            let mut seen = std::collections::HashSet::new();
715            set.retain(|b| seen.insert(b.clone()));
716        }
717        AttributeValue::L(list) => {
718            for v in list.iter_mut() {
719                normalize_attribute_sets(v);
720            }
721        }
722        AttributeValue::M(map) => {
723            for v in map.values_mut() {
724                normalize_attribute_sets(v);
725            }
726        }
727        _ => {}
728    }
729}
730
731/// Produce a canonical string for a DynamoDB number for deduplication purposes.
732/// Strips leading/trailing zeros and normalizes to a canonical form so that
733/// "1.0", "1", "1.00", "01" all map to the same string.
734fn normalize_number_for_dedup(n: &str) -> String {
735    let trimmed = n.trim();
736    let negative = trimmed.starts_with('-');
737    let abs_str = if negative { &trimmed[1..] } else { trimmed };
738
739    let (digits, exponent) = crate::types::parse_number_parts(abs_str);
740
741    if digits.is_empty() {
742        return "0".to_string();
743    }
744
745    let mantissa: String = digits.iter().map(|&d| (b'0' + d) as char).collect();
746    let sign = if negative { "-" } else { "" };
747    format!("{sign}{mantissa}E{exponent}")
748}
749
750/// Validate a Local Secondary Index definition.
751pub fn validate_lsi(
752    lsi: &crate::types::LocalSecondaryIndex,
753    table_key_schema: &[KeySchemaElement],
754    all_definitions: &[AttributeDefinition],
755) -> Result<()> {
756    // Validate index name length
757    if lsi.index_name.len() < 3 || lsi.index_name.len() > 255 {
758        return Err(DynoxideError::ValidationException(format!(
759            "1 validation error detected: Value '{}' at 'localSecondaryIndexes.1.member.indexName' \
760             failed to satisfy constraint: Member must have length greater than or equal to 3",
761            lsi.index_name
762        )));
763    }
764
765    // Validate index name character set
766    if !lsi
767        .index_name
768        .chars()
769        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
770    {
771        return Err(DynoxideError::ValidationException(format!(
772            "1 validation error detected: Value '{}' at 'localSecondaryIndexes.1.member.indexName' \
773             failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
774            lsi.index_name
775        )));
776    }
777
778    // Validate key schema
779    validate_key_schema(&lsi.key_schema)?;
780
781    // Validate projection (DynamoDB checks this before hash key / sort key checks)
782    validate_projection(&lsi.projection, &lsi.index_name)?;
783
784    // LSI must have a RANGE key (sort key)
785    let lsi_pk = lsi
786        .key_schema
787        .iter()
788        .find(|k| k.key_type == KeyType::HASH)
789        .map(|k| k.attribute_name.as_str());
790    let lsi_sk = lsi
791        .key_schema
792        .iter()
793        .find(|k| k.key_type == KeyType::RANGE)
794        .map(|k| k.attribute_name.as_str());
795
796    let table_pk = partition_key_name(table_key_schema);
797    let table_sk = sort_key_name(table_key_schema);
798
799    // LSI partition key MUST match table partition key
800    if lsi_pk != table_pk {
801        return Err(DynoxideError::ValidationException(
802            "One or more parameter values were invalid: Table KeySchema: The AttributeValue for a key attribute for the table must match the AttributeValue definition".to_string(),
803        ));
804    }
805
806    // LSI sort key must be different from table sort key
807    if lsi_sk.is_some() && lsi_sk == table_sk {
808        return Err(DynoxideError::ValidationException(
809            "One or more parameter values were invalid: Index KeySchema: The index KeySchema must not be the same as the table KeySchema".to_string(),
810        ));
811    }
812
813    // LSI sort key must be in AttributeDefinitions
814    validate_key_attributes_in_definitions(&lsi.key_schema, all_definitions)?;
815
816    Ok(())
817}
818
819/// Extract the sort key name from a key schema (if present).
820pub fn sort_key_name(key_schema: &[KeySchemaElement]) -> Option<&str> {
821    key_schema
822        .iter()
823        .find(|k| k.key_type == KeyType::RANGE)
824        .map(|k| k.attribute_name.as_str())
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    fn hash_key(name: &str) -> KeySchemaElement {
832        KeySchemaElement {
833            attribute_name: name.to_string(),
834            key_type: KeyType::HASH,
835        }
836    }
837
838    fn range_key(name: &str) -> KeySchemaElement {
839        KeySchemaElement {
840            attribute_name: name.to_string(),
841            key_type: KeyType::RANGE,
842        }
843    }
844
845    fn attr_def(name: &str, attr_type: ScalarAttributeType) -> AttributeDefinition {
846        AttributeDefinition {
847            attribute_name: name.to_string(),
848            attribute_type: attr_type,
849        }
850    }
851
852    #[test]
853    fn test_valid_table_name() {
854        assert!(validate_table_name("MyTable").is_ok());
855        assert!(validate_table_name("my-table.v2").is_ok());
856        assert!(validate_table_name("a_b").is_ok());
857    }
858
859    #[test]
860    fn test_short_table_name_accepted_for_read_write() {
861        // ReadWrite context (the default for validate_table_name) only enforces min length 1,
862        // matching AWS's per-operation rules. CreateTable's min-length-3 lives behind
863        // table_name_constraint_errors with TableNameContext::CreateTable.
864        assert!(validate_table_name("ab").is_ok());
865        assert!(validate_table_name("a").is_ok());
866    }
867
868    #[test]
869    fn test_empty_table_name_rejected_for_read_write() {
870        let err = validate_table_name("").unwrap_err().to_string();
871        assert!(err.contains("Member must have length greater than or equal to 1"));
872        assert!(!err.contains("greater than or equal to 3"));
873    }
874
875    #[test]
876    fn test_invalid_table_name_bad_chars() {
877        assert!(validate_table_name("my table").is_err());
878        assert!(validate_table_name("my@table").is_err());
879    }
880
881    #[test]
882    fn test_create_table_context_keeps_min_length_3() {
883        let errs = table_name_constraint_errors(Some("ab"), TableNameContext::CreateTable);
884        assert!(
885            errs.iter()
886                .any(|e| e.contains("Member must have length greater than or equal to 3"))
887        );
888    }
889
890    #[test]
891    fn test_valid_key_schema() {
892        let schema = vec![hash_key("pk")];
893        assert!(validate_key_schema(&schema).is_ok());
894
895        let schema = vec![hash_key("pk"), range_key("sk")];
896        assert!(validate_key_schema(&schema).is_ok());
897    }
898
899    #[test]
900    fn test_invalid_key_schema_empty() {
901        assert!(validate_key_schema(&[]).is_err());
902    }
903
904    #[test]
905    fn test_invalid_key_schema_no_hash() {
906        let schema = vec![range_key("sk")];
907        assert!(validate_key_schema(&schema).is_err());
908    }
909
910    #[test]
911    fn test_valid_key_attributes_in_definitions() {
912        let schema = vec![hash_key("pk"), range_key("sk")];
913        let defs = vec![
914            attr_def("pk", ScalarAttributeType::S),
915            attr_def("sk", ScalarAttributeType::N),
916        ];
917        assert!(validate_key_attributes_in_definitions(&schema, &defs).is_ok());
918    }
919
920    #[test]
921    fn test_missing_key_attribute_in_definitions() {
922        let schema = vec![hash_key("pk"), range_key("sk")];
923        let defs = vec![attr_def("pk", ScalarAttributeType::S)];
924        assert!(validate_key_attributes_in_definitions(&schema, &defs).is_err());
925    }
926
927    #[test]
928    fn test_partition_key_name() {
929        let schema = vec![hash_key("pk"), range_key("sk")];
930        assert_eq!(partition_key_name(&schema), Some("pk"));
931    }
932
933    #[test]
934    fn test_sort_key_name() {
935        let schema = vec![hash_key("pk"), range_key("sk")];
936        assert_eq!(sort_key_name(&schema), Some("sk"));
937
938        let schema = vec![hash_key("pk")];
939        assert_eq!(sort_key_name(&schema), None);
940    }
941
942    #[test]
943    fn test_envelope_request_validation_wraps_tagged_error() {
944        let msg = "Value '' at 'expressionAttributeNames' failed to satisfy constraint: \
945                   Map value must satisfy constraint";
946        let err = envelope_request_validation(DynoxideError::EnvelopedValidation(msg.to_string()));
947        match err {
948            DynoxideError::ValidationException(m) => {
949                assert_eq!(m, format!("1 validation error detected: {msg}"));
950            }
951            other => panic!("expected ValidationException, got {other:?}"),
952        }
953    }
954
955    #[test]
956    fn test_envelope_request_validation_passes_other_errors_through() {
957        let plain = envelope_request_validation(DynoxideError::ValidationException("msg".into()));
958        assert!(matches!(
959            &plain,
960            DynoxideError::ValidationException(m) if m == "msg"
961        ));
962
963        let key_empty =
964            envelope_request_validation(DynoxideError::KeyEmptyValueValidation("msg".into()));
965        assert!(matches!(
966            &key_empty,
967            DynoxideError::KeyEmptyValueValidation(m) if m == "msg"
968        ));
969
970        let not_found =
971            envelope_request_validation(DynoxideError::ResourceNotFoundException("msg".into()));
972        assert!(matches!(
973            &not_found,
974            DynoxideError::ResourceNotFoundException(m) if m == "msg"
975        ));
976    }
977
978    #[test]
979    fn test_strip_request_validation_tag_untags_without_envelope() {
980        let err = strip_request_validation_tag(DynoxideError::EnvelopedValidation("msg".into()));
981        assert!(matches!(
982            &err,
983            DynoxideError::ValidationException(m) if m == "msg"
984        ));
985    }
986
987    #[test]
988    fn test_strip_request_validation_tag_passes_other_errors_through() {
989        let plain = strip_request_validation_tag(DynoxideError::ValidationException("msg".into()));
990        assert!(matches!(
991            &plain,
992            DynoxideError::ValidationException(m) if m == "msg"
993        ));
994
995        let not_found =
996            strip_request_validation_tag(DynoxideError::ResourceNotFoundException("msg".into()));
997        assert!(matches!(
998            &not_found,
999            DynoxideError::ResourceNotFoundException(m) if m == "msg"
1000        ));
1001    }
1002}