use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use crate::error::{RegistryError, RegistryResult};
pub const MAX_DEPTH: usize = 50;
pub const MAX_KEYS_PER_MAPPING: usize = 10_000;
pub const MAX_STRING_LENGTH: usize = 1_024 * 1_024;
pub const MAX_TOTAL_SIZE: usize = 10 * 1_024 * 1_024;
pub const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_992;
pub const MIN_SAFE_INTEGER: i64 = -9_007_199_254_740_992;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CanonicalizeError {
AnchorFound { position: String },
AliasFound { position: String },
TagFound { tag: String },
MultiDocumentFound,
DuplicateKey { key: String },
FloatNotAllowed { value: String },
IntegerOutOfRange { value: i64 },
MaxDepthExceeded { depth: usize },
MaxKeysExceeded { count: usize },
StringTooLong { length: usize },
InputTooLarge { size: usize },
ParseError { message: String },
SerializeError { message: String },
}
impl std::fmt::Display for CanonicalizeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AnchorFound { position } => write!(f, "YAML anchor found at {}", position),
Self::AliasFound { position } => write!(f, "YAML alias found at {}", position),
Self::TagFound { tag } => write!(f, "YAML tag not allowed: {}", tag),
Self::MultiDocumentFound => write!(f, "multi-document YAML not allowed"),
Self::DuplicateKey { key } => write!(f, "duplicate key: {}", key),
Self::FloatNotAllowed { value } => write!(f, "float values not allowed: {}", value),
Self::IntegerOutOfRange { value } => {
write!(f, "integer {} out of safe range (±2^53)", value)
}
Self::MaxDepthExceeded { depth } => {
write!(f, "nesting depth {} exceeds limit {}", depth, MAX_DEPTH)
}
Self::MaxKeysExceeded { count } => write!(
f,
"mapping has {} keys, exceeds limit {}",
count, MAX_KEYS_PER_MAPPING
),
Self::StringTooLong { length } => write!(
f,
"string length {} exceeds limit {}",
length, MAX_STRING_LENGTH
),
Self::InputTooLarge { size } => {
write!(f, "input size {} exceeds limit {}", size, MAX_TOTAL_SIZE)
}
Self::ParseError { message } => write!(f, "YAML parse error: {}", message),
Self::SerializeError { message } => write!(f, "JSON serialize error: {}", message),
}
}
}
impl std::error::Error for CanonicalizeError {}
impl From<CanonicalizeError> for RegistryError {
fn from(err: CanonicalizeError) -> Self {
RegistryError::InvalidResponse {
message: format!("canonicalization failed: {}", err),
}
}
}
pub type CanonicalizeResult<T> = Result<T, CanonicalizeError>;
pub fn parse_yaml_strict(content: &str) -> CanonicalizeResult<JsonValue> {
if content.len() > MAX_TOTAL_SIZE {
return Err(CanonicalizeError::InputTooLarge {
size: content.len(),
});
}
pre_scan_yaml(content)?;
let yaml_value: serde_yaml::Value =
serde_yaml::from_str(content).map_err(|e| CanonicalizeError::ParseError {
message: e.to_string(),
})?;
let json_value = yaml_to_json(&yaml_value, 0)?;
Ok(json_value)
}
fn pre_scan_yaml(content: &str) -> CanonicalizeResult<()> {
let mut key_stack: Vec<(usize, std::collections::HashSet<String>)> =
vec![(0, std::collections::HashSet::new())];
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let indent = line.len() - line.trim_start().len();
if trimmed == "---" || trimmed.starts_with("--- ") || trimmed == "..." {
return Err(CanonicalizeError::MultiDocumentFound);
}
if let Some(colon_pos) = trimmed.find(':') {
let value_part = trimmed[colon_pos + 1..].trim_start();
if value_part.starts_with('&') && value_part.len() > 1 {
let next_char = value_part.chars().nth(1).unwrap_or(' ');
if next_char.is_alphanumeric() || next_char == '_' {
return Err(CanonicalizeError::AnchorFound {
position: format!("line {}", line_num + 1),
});
}
}
if value_part.starts_with('*') && value_part.len() > 1 {
let next_char = value_part.chars().nth(1).unwrap_or(' ');
if next_char.is_alphanumeric() || next_char == '_' {
return Err(CanonicalizeError::AliasFound {
position: format!("line {}", line_num + 1),
});
}
}
}
if trimmed.contains("!!") || trimmed.contains("!<") {
if !is_inside_quotes(trimmed, "!!") && !is_inside_quotes(trimmed, "!<") {
let tag_start = trimmed.find("!!").or_else(|| trimmed.find("!<")).unwrap();
let tag_end = trimmed[tag_start..]
.find(|c: char| c.is_whitespace() || c == ':')
.map(|p| tag_start + p)
.unwrap_or(trimmed.len().min(tag_start + 20));
return Err(CanonicalizeError::TagFound {
tag: trimmed[tag_start..tag_end].to_string(),
});
}
}
let is_list_item = trimmed.starts_with('-');
let key_source = if is_list_item {
trimmed
.strip_prefix('-')
.map(|s| s.trim_start())
.unwrap_or("")
} else {
trimmed
};
if is_list_item {
while key_stack.len() > 1
&& key_stack.last().map(|(i, _)| *i >= indent).unwrap_or(false)
{
key_stack.pop();
}
key_stack.push((indent + 1, std::collections::HashSet::new()));
}
if let Some(key) = extract_yaml_key(key_source) {
if !is_list_item {
while key_stack.len() > 1
&& key_stack.last().map(|(i, _)| *i > indent).unwrap_or(false)
{
key_stack.pop();
}
if key_stack.last().map(|(i, _)| *i < indent).unwrap_or(true) {
key_stack.push((indent, std::collections::HashSet::new()));
}
}
if let Some((_, keys)) = key_stack.last_mut() {
if !keys.insert(key.clone()) {
return Err(CanonicalizeError::DuplicateKey { key });
}
}
}
}
Ok(())
}
fn is_inside_quotes(line: &str, pattern: &str) -> bool {
if let Some(pos) = line.find(pattern) {
let before = &line[..pos];
let double_quotes = before.matches('"').count() - before.matches("\\\"").count();
let single_quotes = before.matches('\'').count() - before.matches("\\'").count();
double_quotes % 2 == 1 || single_quotes % 2 == 1
} else {
false
}
}
fn extract_yaml_key(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.starts_with('-') {
return None;
}
if trimmed == "|" || trimmed == ">" || trimmed == "|-" || trimmed == ">-" {
return None;
}
if let Some(after_dquote) = trimmed.strip_prefix('"') {
if let Some(end_quote) = after_dquote.find('"') {
let key = &after_dquote[..end_quote];
let after_key = &after_dquote[end_quote + 1..];
if after_key.trim_start().starts_with(':') {
return Some(key.to_string());
}
}
return None;
}
if let Some(after_squote) = trimmed.strip_prefix('\'') {
if let Some(end_quote) = after_squote.find('\'') {
let key = &after_squote[..end_quote];
let after_key = &after_squote[end_quote + 1..];
if after_key.trim_start().starts_with(':') {
return Some(key.to_string());
}
}
return None;
}
let mut depth: usize = 0;
for (i, c) in trimmed.char_indices() {
match c {
'[' | '{' => depth += 1,
']' | '}' => depth = depth.saturating_sub(1),
':' if depth == 0 => {
let key = trimmed[..i].trim();
if !key.is_empty() && !key.contains(' ') {
return Some(key.to_string());
}
return None;
}
_ => {}
}
}
None
}
fn yaml_to_json(yaml: &serde_yaml::Value, depth: usize) -> CanonicalizeResult<JsonValue> {
if depth > MAX_DEPTH {
return Err(CanonicalizeError::MaxDepthExceeded { depth });
}
match yaml {
serde_yaml::Value::Null => Ok(JsonValue::Null),
serde_yaml::Value::Bool(b) => Ok(JsonValue::Bool(*b)),
serde_yaml::Value::Number(n) => {
if n.is_f64() {
return Err(CanonicalizeError::FloatNotAllowed {
value: n.to_string(),
});
}
if let Some(i) = n.as_i64() {
if !(MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
return Err(CanonicalizeError::IntegerOutOfRange { value: i });
}
Ok(JsonValue::Number(serde_json::Number::from(i)))
} else if let Some(u) = n.as_u64() {
if u > MAX_SAFE_INTEGER as u64 {
return Err(CanonicalizeError::IntegerOutOfRange { value: u as i64 });
}
Ok(JsonValue::Number(serde_json::Number::from(u)))
} else {
Err(CanonicalizeError::FloatNotAllowed {
value: n.to_string(),
})
}
}
serde_yaml::Value::String(s) => {
if s.len() > MAX_STRING_LENGTH {
return Err(CanonicalizeError::StringTooLong { length: s.len() });
}
Ok(JsonValue::String(s.clone()))
}
serde_yaml::Value::Sequence(seq) => {
let items: CanonicalizeResult<Vec<JsonValue>> = seq
.iter()
.map(|item| yaml_to_json(item, depth + 1))
.collect();
Ok(JsonValue::Array(items?))
}
serde_yaml::Value::Mapping(map) => {
if map.len() > MAX_KEYS_PER_MAPPING {
return Err(CanonicalizeError::MaxKeysExceeded { count: map.len() });
}
let mut json_map = serde_json::Map::new();
let mut seen_keys = std::collections::HashSet::new();
for (key, value) in map {
let key_str = match key {
serde_yaml::Value::String(s) => s.clone(),
_ => {
return Err(CanonicalizeError::ParseError {
message: format!("non-string key: {:?}", key),
})
}
};
if !seen_keys.insert(key_str.clone()) {
return Err(CanonicalizeError::DuplicateKey { key: key_str });
}
let json_value = yaml_to_json(value, depth + 1)?;
json_map.insert(key_str, json_value);
}
Ok(JsonValue::Object(json_map))
}
serde_yaml::Value::Tagged(tagged) => Err(CanonicalizeError::TagFound {
tag: format!("{:?}", tagged.tag),
}),
}
}
pub fn to_canonical_jcs_bytes(value: &JsonValue) -> CanonicalizeResult<Vec<u8>> {
serde_jcs::to_vec(value).map_err(|e| CanonicalizeError::SerializeError {
message: e.to_string(),
})
}
pub fn compute_canonical_digest(content: &str) -> CanonicalizeResult<String> {
let json_value = parse_yaml_strict(content)?;
let jcs_bytes = to_canonical_jcs_bytes(&json_value)?;
let hash = Sha256::digest(&jcs_bytes);
Ok(format!("sha256:{:x}", hash))
}
pub fn compute_canonical_digest_result(content: &str) -> RegistryResult<String> {
compute_canonical_digest(content).map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_golden_vector_basic_pack() {
let yaml = "name: eu-ai-act-baseline\nversion: \"1.0.0\"\nkind: compliance";
let digest = compute_canonical_digest(yaml).unwrap();
assert_eq!(
digest,
"sha256:f47d932cdad4bde369ed0a7cf26fdcf4077777296346c4102d9017edbc62a070"
);
}
#[test]
fn test_jcs_key_ordering() {
let yaml1 = "z: 1\na: 2\nm: 3";
let yaml2 = "a: 2\nm: 3\nz: 1";
let yaml3 = "m: 3\nz: 1\na: 2";
let digest1 = compute_canonical_digest(yaml1).unwrap();
let digest2 = compute_canonical_digest(yaml2).unwrap();
let digest3 = compute_canonical_digest(yaml3).unwrap();
assert_eq!(digest1, digest2);
assert_eq!(digest2, digest3);
}
#[test]
fn test_jcs_bytes_output() {
let yaml = "name: test\nversion: \"1.0.0\"";
let json = parse_yaml_strict(yaml).unwrap();
let bytes = to_canonical_jcs_bytes(&json).unwrap();
let expected = r#"{"name":"test","version":"1.0.0"}"#;
assert_eq!(String::from_utf8(bytes).unwrap(), expected);
}
#[test]
fn test_reject_anchor() {
let yaml = "anchor: &myanchor value\nref: *myanchor";
let result = parse_yaml_strict(yaml);
assert!(matches!(result, Err(CanonicalizeError::AnchorFound { .. })));
}
#[test]
fn test_reject_alias() {
let yaml = "ref: *undefined";
let result = parse_yaml_strict(yaml);
assert!(matches!(result, Err(CanonicalizeError::AliasFound { .. })));
}
#[test]
fn test_reject_tag_timestamp() {
let yaml = "date: !!timestamp 2026-01-29";
let result = parse_yaml_strict(yaml);
assert!(matches!(result, Err(CanonicalizeError::TagFound { .. })));
}
#[test]
fn test_reject_tag_binary() {
let yaml = "data: !!binary R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
let result = parse_yaml_strict(yaml);
assert!(matches!(result, Err(CanonicalizeError::TagFound { .. })));
}
#[test]
fn test_reject_custom_tag() {
let yaml = "value: !<tag:custom> data";
let result = parse_yaml_strict(yaml);
assert!(matches!(result, Err(CanonicalizeError::TagFound { .. })));
}
#[test]
fn test_reject_multi_document() {
let yaml = "doc1: value\n---\ndoc2: value";
let result = parse_yaml_strict(yaml);
assert!(matches!(result, Err(CanonicalizeError::MultiDocumentFound)));
}
#[test]
fn test_reject_multi_document_start() {
let yaml = "---\ndoc: value";
let result = parse_yaml_strict(yaml);
assert!(matches!(result, Err(CanonicalizeError::MultiDocumentFound)));
}
#[test]
fn test_reject_float() {
let yaml = "value: 3.14159";
let result = parse_yaml_strict(yaml);
assert!(matches!(
result,
Err(CanonicalizeError::FloatNotAllowed { .. })
));
}
#[test]
fn test_reject_float_scientific() {
let yaml = "value: 1.5e10";
let result = parse_yaml_strict(yaml);
assert!(matches!(
result,
Err(CanonicalizeError::FloatNotAllowed { .. })
));
}
#[test]
fn test_reject_integer_too_large() {
let yaml = "value: 9007199254740993"; let result = parse_yaml_strict(yaml);
assert!(matches!(
result,
Err(CanonicalizeError::IntegerOutOfRange { .. })
));
}
#[test]
fn test_accept_max_safe_integer() {
let yaml = "value: 9007199254740992"; let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
}
#[test]
fn test_accept_max_safe_integer_minus_one() {
let yaml = "value: 9007199254740991"; let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
}
#[test]
fn test_accept_min_safe_integer() {
let yaml = "value: -9007199254740992"; let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
}
#[test]
fn test_accept_min_safe_integer_plus_one() {
let yaml = "value: -9007199254740991"; let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
}
#[test]
fn test_reject_integer_too_negative() {
let yaml = "value: -9007199254740993"; let result = parse_yaml_strict(yaml);
assert!(matches!(
result,
Err(CanonicalizeError::IntegerOutOfRange { .. })
));
}
#[test]
fn test_reject_deep_nesting() {
let mut yaml = String::from("a:\n");
for i in 0..60 {
yaml.push_str(&" ".repeat(i + 1));
yaml.push_str("b:\n");
}
yaml.push_str(&" ".repeat(61));
yaml.push_str("c: value");
let result = parse_yaml_strict(&yaml);
assert!(matches!(
result,
Err(CanonicalizeError::MaxDepthExceeded { .. })
));
}
#[test]
fn test_accept_reasonable_depth() {
let mut yaml = String::from("a:\n");
for i in 0..10 {
yaml.push_str(&" ".repeat(i + 1));
yaml.push_str("b:\n");
}
yaml.push_str(&" ".repeat(11));
yaml.push_str("c: value");
let result = parse_yaml_strict(&yaml);
assert!(result.is_ok());
}
#[test]
fn test_reject_input_too_large() {
let yaml = "x".repeat(MAX_TOTAL_SIZE + 1);
let result = parse_yaml_strict(&yaml);
assert!(matches!(
result,
Err(CanonicalizeError::InputTooLarge { .. })
));
}
#[test]
fn test_ampersand_in_string_allowed() {
let yaml = r#"text: "this & that""#;
let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
}
#[test]
fn test_asterisk_in_string_allowed() {
let yaml = r#"pattern: "*.txt""#;
let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
}
#[test]
fn test_triple_dash_in_string_allowed() {
let yaml = r#"divider: "---""#;
let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
}
#[test]
fn test_exclamation_in_string_allowed() {
let yaml = r#"message: "Hello!! World!!""#;
let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
}
#[test]
fn test_empty_yaml() {
let yaml = "";
let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
assert_eq!(result.unwrap(), JsonValue::Null);
}
#[test]
fn test_null_value() {
let yaml = "value: null";
let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
}
#[test]
fn test_boolean_values() {
let yaml = "enabled: true\ndisabled: false";
let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
let json = result.unwrap();
assert_eq!(json["enabled"], true);
assert_eq!(json["disabled"], false);
}
#[test]
fn test_integer_values() {
let yaml = "positive: 42\nnegative: -17\nzero: 0";
let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
let json = result.unwrap();
assert_eq!(json["positive"], 42);
assert_eq!(json["negative"], -17);
assert_eq!(json["zero"], 0);
}
#[test]
fn test_array_values() {
let yaml = "items:\n - one\n - two\n - three";
let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
let json = result.unwrap();
let items = json["items"].as_array().unwrap();
assert_eq!(items.len(), 3);
}
#[test]
fn test_nested_structure() {
let yaml = "outer:\n inner:\n value: test";
let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
let json = result.unwrap();
assert_eq!(json["outer"]["inner"]["value"], "test");
}
#[test]
fn test_digest_deterministic() {
let yaml = "name: test\nversion: \"1.0.0\"\nkind: pack";
let digest1 = compute_canonical_digest(yaml).unwrap();
let digest2 = compute_canonical_digest(yaml).unwrap();
let digest3 = compute_canonical_digest(yaml).unwrap();
assert_eq!(digest1, digest2);
assert_eq!(digest2, digest3);
}
#[test]
fn test_digest_format() {
let yaml = "test: value";
let digest = compute_canonical_digest(yaml).unwrap();
assert!(digest.starts_with("sha256:"));
assert_eq!(digest.len(), 7 + 64); assert!(digest[7..].chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_whitespace_normalization() {
let yaml1 = "a: 1\nb: 2";
let yaml2 = "a: 1\nb: 2"; let yaml3 = "a: 1\n\nb: 2";
let digest1 = compute_canonical_digest(yaml1).unwrap();
let digest2 = compute_canonical_digest(yaml2).unwrap();
let digest3 = compute_canonical_digest(yaml3).unwrap();
assert_eq!(digest1, digest2);
assert_eq!(digest2, digest3);
}
#[test]
fn test_reject_duplicate_keys_top_level() {
let yaml = "name: first\nversion: \"1.0.0\"\nname: second";
let result = parse_yaml_strict(yaml);
assert!(
matches!(result, Err(CanonicalizeError::DuplicateKey { ref key }) if key == "name"),
"Should reject duplicate top-level key 'name': {:?}",
result
);
}
#[test]
fn test_reject_duplicate_keys_nested() {
let yaml = "outer:\n inner: 1\n inner: 2";
let result = parse_yaml_strict(yaml);
assert!(
matches!(
result,
Err(CanonicalizeError::DuplicateKey { .. })
| Err(CanonicalizeError::ParseError { .. })
),
"Should reject duplicate nested key 'inner' (via DuplicateKey or ParseError): {:?}",
result
);
if let Err(e) = result {
let msg = e.to_string();
assert!(
msg.contains("inner") || msg.contains("duplicate"),
"Error should mention duplicate: {}",
msg
);
}
}
#[test]
fn test_reject_duplicate_keys_different_values() {
let yaml = "config: true\nconfig: some_string";
let result = parse_yaml_strict(yaml);
assert!(
matches!(result, Err(CanonicalizeError::DuplicateKey { .. })),
"Should reject duplicate key 'config': {:?}",
result
);
}
#[test]
fn test_allow_same_key_different_levels() {
let yaml = "name: outer\nnested:\n name: inner";
let result = parse_yaml_strict(yaml);
assert!(
result.is_ok(),
"Same key at different levels should be allowed: {:?}",
result
);
}
#[test]
fn test_allow_unique_keys() {
let yaml = "name: test\nversion: \"1.0.0\"\nkind: pack";
let result = parse_yaml_strict(yaml);
assert!(result.is_ok());
}
#[test]
fn test_ampersand_in_single_quoted_string() {
let yaml = "text: 'this & that'";
let result = parse_yaml_strict(yaml);
assert!(
result.is_ok(),
"Single-quoted ampersand should be allowed: {:?}",
result
);
}
#[test]
fn test_asterisk_in_single_quoted_string() {
let yaml = "pattern: '*.txt'";
let result = parse_yaml_strict(yaml);
assert!(
result.is_ok(),
"Single-quoted asterisk should be allowed: {:?}",
result
);
}
#[test]
fn test_tag_in_quoted_string_allowed() {
let yaml = r#"message: "Use !!binary for base64""#;
let result = parse_yaml_strict(yaml);
assert!(
result.is_ok(),
"Tag syntax in quoted string should be allowed: {:?}",
result
);
}
#[test]
fn test_quoted_key_with_special_chars() {
let yaml = r#""key:with:colons": value"#;
let result = parse_yaml_strict(yaml);
assert!(
result.is_ok(),
"Quoted key with colons should be allowed: {:?}",
result
);
}
#[test]
fn test_duplicate_quoted_keys() {
let yaml = r#""name": first
"name": second"#;
let result = parse_yaml_strict(yaml);
assert!(
matches!(result, Err(CanonicalizeError::DuplicateKey { .. })),
"Should reject duplicate quoted keys: {:?}",
result
);
}
#[test]
fn test_flow_mapping_simple_allowed() {
let yaml = "config: {a: 1, b: 2}";
let result = parse_yaml_strict(yaml);
assert!(
result.is_ok(),
"Simple flow mapping should be allowed: {:?}",
result
);
}
#[test]
fn test_flow_mapping_duplicate_detected_by_serde() {
let yaml = "config: {a: 1, a: 2}";
let result = parse_yaml_strict(yaml);
assert!(
matches!(result, Err(CanonicalizeError::ParseError { .. })),
"Flow mapping duplicates should be rejected (via serde_yaml): {:?}",
result
);
}
#[test]
fn test_top_level_flow_mapping_duplicate() {
let yaml = "{a: 1, a: 2}";
let result = parse_yaml_strict(yaml);
assert!(
matches!(result, Err(CanonicalizeError::ParseError { .. })),
"Top-level flow mapping duplicates should be rejected: {:?}",
result
);
}
#[test]
fn test_complex_key_rejected() {
let yaml = "? [a, b]\n: value";
let result = parse_yaml_strict(yaml);
assert!(
result.is_err(),
"Complex keys should be rejected: {:?}",
result
);
}
#[test]
fn test_list_items_same_key_allowed() {
let yaml = "items:\n - name: first\n - name: second";
let result = parse_yaml_strict(yaml);
assert!(
result.is_ok(),
"Same keys in different list items should be allowed: {:?}",
result
);
}
#[test]
fn test_list_item_duplicate_within_same_item() {
let yaml = "items:\n - name: first\n name: second";
let result = parse_yaml_strict(yaml);
assert!(
matches!(
result,
Err(CanonicalizeError::DuplicateKey { .. })
| Err(CanonicalizeError::ParseError { .. })
),
"Duplicate keys within same list item should be rejected: {:?}",
result
);
}
#[test]
fn test_top_level_sequence_same_keys() {
let yaml = "- a: 1\n- a: 2";
let result = parse_yaml_strict(yaml);
assert!(
result.is_ok(),
"Top-level sequence items with same keys should be allowed: {:?}",
result
);
}
#[test]
fn test_top_level_sequence_duplicate_in_item() {
let yaml = "- a: 1\n a: 2";
let result = parse_yaml_strict(yaml);
assert!(
matches!(
result,
Err(CanonicalizeError::DuplicateKey { .. })
| Err(CanonicalizeError::ParseError { .. })
),
"Duplicate within top-level sequence item should be rejected: {:?}",
result
);
}
#[test]
fn test_nested_list_with_mappings() {
let yaml = r#"rules:
- id: rule1
name: First Rule
- id: rule2
name: Second Rule"#;
let result = parse_yaml_strict(yaml);
assert!(
result.is_ok(),
"Nested list with multiple keys per item should work: {:?}",
result
);
}
#[test]
fn test_deeply_nested_list_same_keys() {
let yaml = r#"outer:
inner:
- key: val1
other: a
- key: val2
other: b"#;
let result = parse_yaml_strict(yaml);
assert!(
result.is_ok(),
"Deeply nested list items with same keys should be allowed: {:?}",
result
);
}
#[test]
fn test_mixed_sequence_and_mapping() {
let yaml = r#"items:
- name: item1
- name: item2
metadata:
name: should_not_conflict"#;
let result = parse_yaml_strict(yaml);
assert!(
result.is_ok(),
"Sequence keys should not conflict with sibling mapping keys: {:?}",
result
);
}
}