feat: Add Schema Registry and Validation Framework (#456)

* feat: Add Schema Registry and Validation Framework

This commit introduces a comprehensive schema registry and validation framework, providing schema-based validation of resources and policy effects.

- Thread-safe, in-memory registry for schema storage and management
- Global registry patterns for effects and resources
- Concurrent access with proper error handling
- Unicode schema names support

- JSON Schema-compliant validation for all primitive types
- Advanced constraint validation (patterns, ranges, length limits)
- Discriminated union support with anyOf schemas
- Detailed error reporting with nested validation paths
- Discriminated subobject validation for polymorphic schemas

- **Registry Tests**: All registry operations
- **Effect Tests**: Policy effect validation
- **Resource Tests**: Resource validation
- **Validation Tests**: Core validation engine
- Thread-safety, error handling, integration scenarios, edge cases

- **Dependencies**: dashmap, once_cell, regex
- **Thread Safety**: Minimal locking with Rc<Schema> sharing
- **Error Types**: TypeMismatch, OutOfRange, PatternMismatch, etc.

- Complete schema registry and validation subsystem
- Comprehensive test coverage
- Foundation for policy validation in Regorus

Benchmarks:

- Criterion benchmarks for basic types, effects and Azure resources
- Performance range: 3.22ns (string) to 34.74µs (Azure VM resource schema validation)
- String withs patterns validation: 30.2µs. Need to explore whether regex caching helps
  bring this down.
- Azure policy effects: 188ns-1.4µs

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* Address PR feedback

- move error to a separate file
- use meaningful var names

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* Refactor

- Reusable Registry struct
- Split and simplify tests

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

---------

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
This commit is contained in:
Anand Krishnamoorthi
2025-08-14 15:59:30 -05:00
committed by GitHub
parent 77f8544868
commit db718654b5
20 changed files with 7080 additions and 114 deletions
+4
View File
@@ -27,6 +27,8 @@ mod interpreter;
mod lexer;
mod number;
mod parser;
#[cfg(feature = "azure_policy")]
mod registry;
mod scheduler;
#[cfg(feature = "azure_policy")]
mod schema;
@@ -35,6 +37,8 @@ mod value;
pub use engine::Engine;
pub use lexer::Source;
#[cfg(feature = "azure_policy")]
pub use schema::{error::ValidationError, validate::SchemaValidator, Schema};
pub use value::Value;
#[cfg(feature = "arc")]
+289
View File
@@ -0,0 +1,289 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
#![allow(dead_code)]
use crate::*;
use core::fmt;
use dashmap::DashMap;
type String = Rc<str>;
#[cfg(test)]
mod tests {
mod core;
mod effect;
mod resource;
}
/// Errors that can occur when interacting with a Registry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryError {
AlreadyExists { name: String, registry: String },
InvalidName { name: String, registry: String },
}
impl fmt::Display for RegistryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RegistryError::AlreadyExists { name, registry } => {
write!(
f,
"{} registration failed: An item with the name '{name}' is already registered.",
registry
)
}
RegistryError::InvalidName { name, registry } => {
write!(f, "{} registration failed: The name '{name}' is invalid (empty or whitespace-only names are not allowed).", registry)
}
}
}
}
impl core::error::Error for RegistryError {}
/// Validates that a name is not empty or whitespace-only.
pub fn validate_name(name: &str, registry_name: &str) -> Result<(), RegistryError> {
if name.is_empty() || name.trim().is_empty() {
Err(RegistryError::InvalidName {
name: String::from(name),
registry: String::from(registry_name),
})
} else {
Ok(())
}
}
/// Generic thread-safe registry for items of type T using DashMap.
///
/// This template can be used to create registries for any type T.
/// It provides thread-safe storage and retrieval operations with customizable registry names.
#[derive(Clone)]
pub struct Registry<T> {
inner: DashMap<String, Rc<T>>,
name: String,
}
impl<T> Registry<T> {
/// Create a new, empty registry with a given name.
pub fn new(registry_name: impl Into<String>) -> Self {
Self {
inner: DashMap::new(),
name: registry_name.into(),
}
}
/// Get the name of this registry.
pub fn name(&self) -> &str {
&self.name
}
/// Register an item with a given name. Returns Err if name already exists.
pub fn register(&self, name: impl Into<String>, item: Rc<T>) -> Result<(), RegistryError> {
let name = name.into();
// Validate the name first
validate_name(&name, &self.name)?;
use dashmap::mapref::entry::Entry;
match self.inner.entry(name.clone()) {
Entry::Occupied(e) => Err(RegistryError::AlreadyExists {
name: e.key().clone(),
registry: self.name.clone(),
}),
Entry::Vacant(e) => {
e.insert(item);
Ok(())
}
}
}
/// Retrieve an item by name, if it exists.
pub fn get(&self, name: &str) -> Option<Rc<T>> {
self.inner.get(name).map(|entry| Rc::clone(entry.value()))
}
/// Remove an item by name. Returns the removed item if it existed.
pub fn remove(&self, name: &str) -> Option<Rc<T>> {
self.inner.remove(name).map(|(_, v)| v)
}
/// List all registered item names.
pub fn list_names(&self) -> Vec<String> {
self.inner.iter().map(|entry| entry.key().clone()).collect()
}
/// Check if an item with the given name exists.
pub fn contains(&self, name: &str) -> bool {
self.inner.contains_key(name)
}
/// Get the number of registered items.
pub fn len(&self) -> usize {
self.inner.len()
}
/// Check if the registry is empty.
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
/// Clear all items from the registry.
pub fn clear(&self) {
self.inner.clear();
}
/// Get an iterator over all entries in the registry.
/// Returns an iterator of (name, item) pairs.
pub fn iter(&self) -> impl Iterator<Item = (String, Rc<T>)> + '_ {
self.inner
.iter()
.map(|entry| (entry.key().clone(), Rc::clone(entry.value())))
}
/// Get all registered items as a vector.
pub fn list_items(&self) -> Vec<Rc<T>> {
self.inner
.iter()
.map(|entry| Rc::clone(entry.value()))
.collect()
}
/// Try to register an item, but don't fail if the name already exists.
/// Returns Ok(true) if the item was registered, Ok(false) if the name already exists.
pub fn try_register(
&self,
name: impl Into<String>,
item: Rc<T>,
) -> Result<bool, RegistryError> {
match self.register(name, item) {
Ok(()) => Ok(true),
Err(RegistryError::AlreadyExists { .. }) => Ok(false),
Err(e) => Err(e),
}
}
}
/// Type alias for Schema registry
pub type SchemaRegistry = Registry<crate::Schema>;
/// Global registry instances
pub mod instances {
use super::*;
lazy_static::lazy_static! {
/// Global singleton instance of resource schemas registry.
pub static ref RESOURCE_SCHEMA_REGISTRY: Registry<crate::Schema> = Registry::new("RESOURCE_SCHEMA_REGISTRY");
}
lazy_static::lazy_static! {
/// Global singleton instance of effect schemas registry.
pub static ref EFFECT_SCHEMA_REGISTRY: Registry<crate::Schema> = Registry::new("EFFECT_SCHEMA_REGISTRY");
}
}
/// Macro to generate helper functions for registry operations.
///
/// This macro generates helper functions that wrap the registry operations.
/// It reduces code duplication and makes it easier to maintain registry interfaces.
///
/// # Arguments
/// * `$registry_var` - The static registry variable to wrap
/// * `$item_type` - The type of items stored in the registry (e.g., `crate::Schema`)
/// * `$item_description` - Human-readable description of the item type (e.g., "resource schema")
/// * `$item_description_plural` - Plural form of the item description (e.g., "resource schemas")
macro_rules! generate_registry_helpers {
($registry_var:ident, $item_type:ty, $item_description:literal, $item_description_plural:literal) => {
#[doc = concat!("Register a ", $item_description, " with a given name.")]
pub fn register(
name: impl Into<String>,
item: Rc<$item_type>,
) -> Result<(), RegistryError> {
$registry_var.register(name, item)
}
#[doc = concat!("Retrieve a ", $item_description, " by name.")]
pub fn get(name: &str) -> Option<Rc<$item_type>> {
$registry_var.get(name)
}
#[doc = concat!("Remove a ", $item_description, " by name.")]
pub fn remove(name: &str) -> Option<Rc<$item_type>> {
$registry_var.remove(name)
}
#[doc = concat!("List all registered ", $item_description, " names.")]
pub fn list_names() -> Vec<String> {
$registry_var.list_names()
}
#[doc = concat!("Check if a ", $item_description, " with the given name exists.")]
pub fn contains(name: &str) -> bool {
$registry_var.contains(name)
}
#[doc = concat!("Get the number of registered ", $item_description_plural, ".")]
pub fn len() -> usize {
$registry_var.len()
}
#[doc = concat!("Check if the ", $item_description, " registry is empty.")]
pub fn is_empty() -> bool {
$registry_var.is_empty()
}
#[doc = concat!("Clear all ", $item_description_plural, " from the registry.")]
pub fn clear() {
$registry_var.clear();
}
};
}
/// Macro to generate a module with helper functions for registry operations.
///
/// This macro generates a complete module with helper functions that wrap the registry operations.
/// It reduces code duplication and makes it easier to maintain registry interfaces.
///
/// # Arguments
/// * `$mod_name` - The name of the module to generate
/// * `$registry_var` - The static registry variable to wrap
/// * `$item_type` - The type of items stored in the registry (e.g., `crate::Schema`)
/// * `$item_description` - Human-readable description of the item type (e.g., "resource schema")
/// * `$item_description_plural` - Plural form of the item description (e.g., "resource schemas")
macro_rules! generate_registry_module {
($mod_name:ident, $registry_var:ident, $item_type:ty, $item_description:literal, $item_description_plural:literal) => {
#[doc = concat!("Helper functions for ", $item_description, " registry operations.")]
pub mod $mod_name {
use super::*;
generate_registry_helpers!(
$registry_var,
$item_type,
$item_description,
$item_description_plural
);
}
};
}
/// Helper functions for schema registry operations.
pub mod schemas {
use super::*;
use instances::*;
// Generate helper modules for schema registries
generate_registry_module!(
resource,
RESOURCE_SCHEMA_REGISTRY,
crate::Schema,
"resource schema",
"resource schemas"
);
generate_registry_module!(
effect,
EFFECT_SCHEMA_REGISTRY,
crate::Schema,
"effect schema",
"effect schemas"
);
}
+509
View File
@@ -0,0 +1,509 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use super::super::registry::*;
use crate::{schema::Schema, *};
use serde_json::json;
type String = Rc<str>;
type SchemaRegistryError = RegistryError;
#[test]
fn test_schema_registry_new() {
let registry = SchemaRegistry::new("test");
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
}
#[test]
fn test_schema_registry_register_success() {
let registry = SchemaRegistry::new("test");
let schema = create_test_schema();
let result = registry.register("test_schema", schema.clone());
assert!(result.is_ok());
assert_eq!(registry.len(), 1);
assert!(registry.contains("test_schema"));
}
#[test]
fn test_schema_registry_register_duplicate() {
let registry = SchemaRegistry::new("test");
let schema = create_test_schema();
// Register first time - should succeed
let result1 = registry.register("test_schema", schema.clone());
assert!(result1.is_ok());
// Register again with same name - should fail
let result2 = registry.register("test_schema", schema);
assert!(result2.is_err());
if let Err(SchemaRegistryError::AlreadyExists { name, .. }) = result2 {
assert_eq!(name, "test_schema".into());
} else {
panic!("Expected AlreadyExists error");
}
}
#[test]
fn test_schema_registry_get() {
let registry = SchemaRegistry::new("test");
let schema = create_test_schema();
// Get non-existent schema
assert!(registry.get("non_existent").is_none());
// Register and get existing schema
registry.register("test_schema", schema.clone()).unwrap();
let retrieved = registry.get("test_schema");
assert!(retrieved.is_some());
// Verify it's the same schema (Rc comparison)
let retrieved_schema = retrieved.unwrap();
assert!(Rc::ptr_eq(&schema, &retrieved_schema));
}
#[test]
fn test_schema_registry_remove() {
let registry = SchemaRegistry::new("test");
let schema = create_test_schema();
// Remove non-existent schema
assert!(registry.remove("non_existent").is_none());
// Register, then remove
registry.register("test_schema", schema.clone()).unwrap();
assert_eq!(registry.len(), 1);
let removed = registry.remove("test_schema");
assert!(removed.is_some());
assert_eq!(registry.len(), 0);
assert!(!registry.contains("test_schema"));
// Verify it's the same schema
let removed_schema = removed.unwrap();
assert!(Rc::ptr_eq(&schema, &removed_schema));
}
#[test]
fn test_schema_registry_list_names() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
// Empty registry
assert!(registry.list_names().is_empty());
// Add multiple schemas
let schema1 = create_test_schema();
let schema2 = create_test_schema();
registry.register("schema_a", schema1).unwrap();
registry.register("schema_b", schema2).unwrap();
let names = registry.list_names();
assert_eq!(names.len(), 2);
assert!(names.contains(&"schema_a".into()));
assert!(names.contains(&"schema_b".into()));
}
#[test]
fn test_schema_registry_clear() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema = create_test_schema();
// Add some schemas
registry.register("schema1", schema.clone()).unwrap();
registry.register("schema2", schema).unwrap();
assert_eq!(registry.len(), 2);
// Clear all
registry.clear();
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
assert!(registry.list_names().is_empty());
}
#[test]
fn test_error_display() {
let error = SchemaRegistryError::AlreadyExists {
name: "test_schema".into(),
registry: "test".into(),
};
let error_message = format!("{error}");
assert_eq!(
error_message,
"test registration failed: An item with the name 'test_schema' is already registered."
);
let invalid_error = SchemaRegistryError::InvalidName {
name: " ".into(),
registry: "test".into(),
};
let invalid_error_message = format!("{invalid_error}");
assert_eq!(invalid_error_message, "test registration failed: The name ' ' is invalid (empty or whitespace-only names are not allowed).");
}
#[test]
#[cfg(feature = "std")]
fn test_concurrent_access() {
use std::sync::Barrier;
use std::thread;
// Create a fresh registry for this test to avoid interference
let test_registry = Rc::new(SchemaRegistry::new("TestSchemaRegistry"));
let barrier = Rc::new(Barrier::new(4));
let mut handles = vec![];
// Spawn multiple threads trying to register schemas
for i in 0..4 {
let barrier = Rc::clone(&barrier);
let registry = Rc::clone(&test_registry);
let handle = thread::spawn(move || {
let schema = create_test_schema();
barrier.wait();
// Each thread tries to register a schema with unique name
let name = format!("schema_{i}");
registry.register(name, schema)
});
handles.push(handle);
}
// Wait for all threads to complete
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// All registrations should succeed
for result in results {
assert!(result.is_ok());
}
// Should have exactly 4 schemas registered
assert_eq!(test_registry.len(), 4);
}
#[test]
#[cfg(feature = "std")]
fn test_concurrent_duplicate_registration() {
use std::sync::Barrier;
use std::thread;
// Create a fresh registry for this test to avoid interference
let test_registry = Rc::new(SchemaRegistry::new("TestSchemaRegistry"));
let barrier = Rc::new(Barrier::new(3));
let mut handles = vec![];
// Spawn multiple threads trying to register the same schema name
for _ in 0..3 {
let barrier = Rc::clone(&barrier);
let registry = Rc::clone(&test_registry);
let handle = thread::spawn(move || {
let schema = create_test_schema();
barrier.wait();
// All threads try to register with the same name
registry.register("duplicate_name", schema)
});
handles.push(handle);
}
// Wait for all threads to complete
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// Only one should succeed, others should fail
let successes = results.iter().filter(|r| r.is_ok()).count();
let failures = results.iter().filter(|r| r.is_err()).count();
assert_eq!(successes, 1);
assert_eq!(failures, 2);
assert_eq!(test_registry.len(), 1);
}
// Helper function to create a test schema
fn create_test_schema() -> Rc<Schema> {
let schema_json = json!({
"type": "string",
"description": "A test schema"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
// Corner case tests
#[test]
fn test_empty_schema_name() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema = create_test_schema();
// Empty string as schema name should fail
let result = registry.register("", schema);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SchemaRegistryError::InvalidName { .. }
));
assert!(!registry.contains(""));
assert_eq!(registry.len(), 0);
assert!(registry.is_empty());
}
#[test]
fn test_unicode_schema_names() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema = create_test_schema();
// Test various Unicode characters
let unicode_names = vec![
"схема", // Cyrillic
"スキーマ", // Japanese
"模式", // Chinese
"🚀schema", // Emoji
"café-münü", // Accented characters
"ñoño", // Spanish characters
];
for name in &unicode_names {
let result = registry.register(*name, schema.clone());
assert!(
result.is_ok(),
"Failed to register schema with name: {name}"
);
assert!(registry.contains(name));
}
assert_eq!(registry.len(), unicode_names.len());
// Verify all names are listed
let listed_names = registry.list_names();
for name in &unicode_names {
let name: String = (*name).into();
assert!(listed_names.contains(&name));
}
}
#[test]
fn test_very_long_schema_name() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema = create_test_schema();
// Create a very long name (1000 characters)
let long_name: String = "a".repeat(1000).into();
let result = registry.register(long_name.clone(), schema);
assert!(result.is_ok());
assert!(registry.contains(&long_name));
let retrieved = registry.get(&long_name);
assert!(retrieved.is_some());
}
#[test]
fn test_special_character_schema_names() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema = create_test_schema();
let special_names = vec![
"schema-with-dashes",
"schema_with_underscores",
"schema.with.dots",
"schema:with:colons",
"schema/with/slashes",
"schema with spaces",
"schema\twith\ttabs",
"schema\nwith\nnewlines",
"UPPERCASE_SCHEMA",
"MixedCaseSchema",
"123numeric456",
"!@#$%^&*()",
"\"quoted\"",
"'single-quoted'",
"[bracketed]",
"{curly}",
"(parentheses)",
];
for name in &special_names {
let result = registry.register(*name, schema.clone());
assert!(
result.is_ok(),
"Failed to register schema with name: {name}"
);
assert!(registry.contains(name));
}
assert_eq!(registry.len(), special_names.len());
}
#[test]
fn test_whitespace_only_names() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema = create_test_schema();
let whitespace_names = vec![
" ", // Single space
"\t", // Tab
"\n", // Newline
"\r", // Carriage return
" ", // Multiple spaces
"\t\t", // Multiple tabs
" \t\n\r ", // Mixed whitespace
];
for name in &whitespace_names {
let result = registry.register(*name, schema.clone());
assert!(
result.is_err(),
"Expected error for whitespace name: {name:?}"
);
assert!(matches!(
result.unwrap_err(),
SchemaRegistryError::InvalidName { .. }
));
assert!(!registry.contains(name));
}
assert_eq!(registry.len(), 0);
}
#[test]
fn test_valid_names_with_whitespace() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema = create_test_schema();
let valid_names = vec![
"schema name", // Space in the middle
" schema", // Leading space but not only whitespace
"schema ", // Trailing space but not only whitespace
"my\tschema", // Tab in the middle
"multi word schema", // Multiple words
];
for name in &valid_names {
let result = registry.register(*name, schema.clone());
assert!(result.is_ok(), "Expected success for valid name: {name:?}");
assert!(registry.contains(name));
}
assert_eq!(registry.len(), valid_names.len());
}
#[test]
fn test_same_schema_different_names() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema = create_test_schema();
// Register the same schema instance with different names
let names = vec!["name1", "name2", "name3"];
for name in &names {
let result = registry.register(*name, schema.clone());
assert!(result.is_ok());
}
assert_eq!(registry.len(), names.len());
// All should point to the same schema instance
for name in &names {
let retrieved = registry.get(name).unwrap();
assert!(Rc::ptr_eq(&schema, &retrieved));
}
}
#[test]
fn test_register_after_remove_and_clear() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema = create_test_schema();
// Register, remove, then register again with same name
registry.register("test", schema.clone()).unwrap();
assert!(registry.contains("test"));
registry.remove("test");
assert!(!registry.contains("test"));
// Should be able to register again with same name
let result = registry.register("test", schema.clone());
assert!(result.is_ok());
assert!(registry.contains("test"));
// Clear and register again
registry.clear();
assert!(registry.is_empty());
let result = registry.register("test", schema);
assert!(result.is_ok());
assert!(registry.contains("test"));
}
#[test]
fn test_case_sensitive_names() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema = create_test_schema();
// Register schemas with different cases of the same name
let case_variants = vec!["test", "Test", "TEST", "tEsT"];
for name in &case_variants {
let result = registry.register(*name, schema.clone());
assert!(
result.is_ok(),
"Failed to register schema with name: {name}"
);
}
assert_eq!(registry.len(), case_variants.len());
// All should be treated as different schemas
for name in &case_variants {
assert!(registry.contains(name));
let retrieved = registry.get(name);
assert!(retrieved.is_some());
}
}
#[test]
fn test_error_after_schema_removal() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema = create_test_schema();
// Register schema
registry.register("test", schema.clone()).unwrap();
// Remove it
registry.remove("test");
// Try to register again - should succeed
let result = registry.register("test", schema);
assert!(result.is_ok());
}
#[test]
fn test_mixed_operations_sequence() {
let registry = SchemaRegistry::new("TestSchemaRegistry");
let schema1 = create_test_schema();
let schema2 = create_test_schema();
// Complex sequence of operations
registry.register("a", schema1.clone()).unwrap();
registry.register("b", schema2.clone()).unwrap();
assert_eq!(registry.len(), 2);
// Try duplicate - should fail
assert!(registry.register("a", schema1.clone()).is_err());
assert_eq!(registry.len(), 2);
// Remove one
registry.remove("a");
assert_eq!(registry.len(), 1);
// Register with removed name - should succeed
registry.register("a", schema1).unwrap();
assert_eq!(registry.len(), 2);
// Clear and verify
registry.clear();
assert!(registry.is_empty());
assert!(registry.list_names().is_empty());
}
+434
View File
@@ -0,0 +1,434 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use super::super::registry::*;
use crate::{
registry::{instances::EFFECT_SCHEMA_REGISTRY, schemas::effect},
schema::Schema,
*,
};
use serde_json::json;
type String = Rc<str>;
type SchemaRegistryError = RegistryError;
// Helper function to create a schema for Azure Policy effects
fn create_effect_schema() -> Rc<Schema> {
let schema_json = json!({
"enum": ["audit", "deny", "disabled", "modify"],
"description": "Azure Policy effect types"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
// Helper function to create a deny effect schema
fn create_deny_effect_schema() -> Rc<Schema> {
let schema_json = json!({
"type": "object",
"properties": {
"effect": {
"const": "deny"
},
"description": {
"type": "string",
"description": "Explanation of what is being denied"
}
},
"required": ["effect"],
"description": "Schema for deny effect in Azure Policy"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
// Helper function to create an audit effect schema
fn create_audit_effect_schema() -> Rc<Schema> {
let schema_json = json!({
"type": "object",
"properties": {
"effect": {
"const": "audit"
},
"description": {
"type": "string",
"description": "Explanation of what is being audited"
},
"auditDetails": {
"type": "object",
"properties": {
"category": {
"enum": ["security", "compliance", "cost", "operational"]
},
"severity": {
"enum": ["low", "medium", "high", "critical"]
}
}
}
},
"required": ["effect"],
"description": "Schema for audit effect in Azure Policy"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
// Helper function to create a modify effect schema
fn create_modify_effect_schema() -> Rc<Schema> {
let schema_json = json!({
"type": "object",
"properties": {
"effect": {
"const": "modify"
},
"description": {
"type": "string",
"description": "Explanation of what is being modified"
},
"modifyDetails": {
"type": "object",
"properties": {
"roleDefinitionIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of role definition IDs required for modification"
},
"operations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"operation": {
"enum": ["add", "replace", "remove"]
},
"field": {
"type": "string"
},
"value": {
"type": "any",
"description": "Value to add or replace"
}
},
"required": ["operation", "field"]
}
}
},
"required": ["roleDefinitionIds", "operations"]
}
},
"required": ["effect", "modifyDetails"],
"description": "Schema for modify effect in Azure Policy"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
#[test]
fn test_basic_effect_enum_schema() {
let effect_schema = create_effect_schema();
// Test registration of basic effect enum schema
let result =
EFFECT_SCHEMA_REGISTRY.register("azure.policy.effect.basic", effect_schema.clone());
assert!(result.is_ok());
assert!(EFFECT_SCHEMA_REGISTRY.contains("azure.policy.effect.basic"));
// Verify schema can be retrieved
let retrieved = EFFECT_SCHEMA_REGISTRY.get("azure.policy.effect.basic");
assert!(retrieved.is_some());
assert!(Rc::ptr_eq(&effect_schema, &retrieved.unwrap()));
}
#[test]
fn test_deny_effect_schema() {
let deny_schema = create_deny_effect_schema();
// Test registration of deny effect schema
let result = EFFECT_SCHEMA_REGISTRY.register("azure.policy.deny.test", deny_schema.clone());
assert!(result.is_ok());
assert!(EFFECT_SCHEMA_REGISTRY.contains("azure.policy.deny.test"));
// Verify basic functionality - we can't inspect internal structure due to private as_type()
assert!(EFFECT_SCHEMA_REGISTRY.contains("azure.policy.deny.test"));
// The schema should be retrievable
let retrieved = EFFECT_SCHEMA_REGISTRY.get("azure.policy.deny.test");
assert!(retrieved.is_some());
assert!(Rc::ptr_eq(&deny_schema, &retrieved.unwrap()));
}
#[test]
fn test_audit_effect_schema() {
let audit_schema = create_audit_effect_schema();
// Test registration of audit effect schema
let result = EFFECT_SCHEMA_REGISTRY.register("azure.policy.audit.test", audit_schema.clone());
assert!(result.is_ok());
assert!(EFFECT_SCHEMA_REGISTRY.contains("azure.policy.audit.test"));
// Verify basic functionality - we can't inspect internal structure due to private as_type()
assert!(EFFECT_SCHEMA_REGISTRY.contains("azure.policy.audit.test"));
// The schema should be retrievable
let retrieved = EFFECT_SCHEMA_REGISTRY.get("azure.policy.audit.test");
assert!(retrieved.is_some());
assert!(Rc::ptr_eq(&audit_schema, &retrieved.unwrap()));
}
#[test]
fn test_modify_effect_schema() {
let modify_schema = create_modify_effect_schema();
// Test registration of modify effect schema
let result = EFFECT_SCHEMA_REGISTRY.register("azure.policy.modify.test", modify_schema.clone());
assert!(result.is_ok());
assert!(EFFECT_SCHEMA_REGISTRY.contains("azure.policy.modify.test"));
// Verify basic functionality - we can't inspect internal structure due to private as_type()
assert!(EFFECT_SCHEMA_REGISTRY.contains("azure.policy.modify.test"));
// The schema should be retrievable
let retrieved = EFFECT_SCHEMA_REGISTRY.get("azure.policy.modify.test");
assert!(retrieved.is_some());
assert!(Rc::ptr_eq(&modify_schema, &retrieved.unwrap()));
}
#[test]
fn test_multiple_effect_schemas() {
// Register all effect schemas with unique names for this specific test
let deny_schema = create_deny_effect_schema();
let audit_schema = create_audit_effect_schema();
let modify_schema = create_modify_effect_schema();
// Use highly unique names to avoid conflicts with other tests
let deny_name = "azure.policy.deny.multiple.test";
let audit_name = "azure.policy.audit.multiple.test";
let modify_name = "azure.policy.modify.multiple.test";
assert!(EFFECT_SCHEMA_REGISTRY
.register(deny_name, deny_schema)
.is_ok());
assert!(EFFECT_SCHEMA_REGISTRY
.register(audit_name, audit_schema)
.is_ok());
assert!(EFFECT_SCHEMA_REGISTRY
.register(modify_name, modify_schema)
.is_ok());
// Verify all are registered
assert!(EFFECT_SCHEMA_REGISTRY.contains(deny_name));
assert!(EFFECT_SCHEMA_REGISTRY.contains(audit_name));
assert!(EFFECT_SCHEMA_REGISTRY.contains(modify_name));
// Verify they can all be retrieved
assert!(EFFECT_SCHEMA_REGISTRY.get(deny_name).is_some());
assert!(EFFECT_SCHEMA_REGISTRY.get(audit_name).is_some());
assert!(EFFECT_SCHEMA_REGISTRY.get(modify_name).is_some());
}
#[test]
fn test_global_effect_registry() {
// Register Azure Policy effects with unique names
let deny_schema = create_deny_effect_schema();
let audit_schema = create_audit_effect_schema();
let modify_schema = create_modify_effect_schema();
// Use highly unique names to avoid conflicts
let deny_name = "azure.policy.deny.global.test";
let audit_name = "azure.policy.audit.global.test";
let modify_name = "azure.policy.modify.global.test";
assert!(effect::register(deny_name, deny_schema).is_ok());
assert!(effect::register(audit_name, audit_schema).is_ok());
assert!(effect::register(modify_name, modify_schema).is_ok());
// Verify all are registered in global registry
assert!(effect::contains(deny_name));
assert!(effect::contains(audit_name));
assert!(effect::contains(modify_name));
// Test retrieval from global registry
let retrieved_deny = effect::get(deny_name);
let retrieved_audit = effect::get(audit_name);
let retrieved_modify = effect::get(modify_name);
assert!(retrieved_deny.is_some());
assert!(retrieved_audit.is_some());
assert!(retrieved_modify.is_some());
}
#[test]
fn test_effect_schema_validation_patterns() {
// Test schema with various Azure Policy patterns
let complex_effect_schema = json!({
"type": "object",
"properties": {
"effect": {
"enum": ["audit", "deny", "disabled", "modify", "auditIfNotExists", "deployIfNotExists"]
},
"parameters": {
"type": "object",
"description": "Parameters for the effect"
},
"existenceCondition": {
"type": "object",
"description": "Condition for existence-based effects"
},
"deployment": {
"type": "object",
"properties": {
"properties": {
"type": "object",
"properties": {
"mode": {
"enum": ["incremental", "complete"]
},
"template": {
"type": "object"
},
"parameters": {
"type": "object"
}
}
}
}
}
},
"required": ["effect"],
"description": "Comprehensive Azure Policy effect schema"
});
let schema = Schema::from_serde_json_value(complex_effect_schema).unwrap();
let schema_rc = Rc::new(schema);
let result = EFFECT_SCHEMA_REGISTRY.register("azure.policy.complex.patterns", schema_rc);
assert!(result.is_ok());
assert!(EFFECT_SCHEMA_REGISTRY.contains("azure.policy.complex.patterns"));
}
#[test]
fn test_effect_schema_with_invalid_names() {
let effect_schema = create_effect_schema();
// Test invalid names
assert!(EFFECT_SCHEMA_REGISTRY
.register("", effect_schema.clone())
.is_err());
assert!(EFFECT_SCHEMA_REGISTRY
.register(" ", effect_schema.clone())
.is_err());
assert!(EFFECT_SCHEMA_REGISTRY
.register("\t", effect_schema.clone())
.is_err());
assert!(EFFECT_SCHEMA_REGISTRY
.register("\n", effect_schema)
.is_err());
}
#[test]
fn test_effect_schema_duplicate_registration() {
let deny_schema = create_deny_effect_schema();
// First registration should succeed
assert!(EFFECT_SCHEMA_REGISTRY
.register("azure.policy.deny.duplicate", deny_schema.clone())
.is_ok());
// Duplicate registration should fail
let duplicate_result =
EFFECT_SCHEMA_REGISTRY.register("azure.policy.deny.duplicate", deny_schema);
assert!(duplicate_result.is_err());
// Verify error type
match duplicate_result.unwrap_err() {
SchemaRegistryError::AlreadyExists { name, .. } => {
assert_eq!(name.as_ref(), "azure.policy.deny.duplicate");
}
_ => panic!("Expected AlreadyExists error"),
}
}
#[test]
fn test_azure_policy_effect_removal() {
// Register multiple Azure Policy effects with unique names
let effects = vec![
("azure.policy.deny.removal", create_deny_effect_schema()),
("azure.policy.audit.removal", create_audit_effect_schema()),
("azure.policy.modify.removal", create_modify_effect_schema()),
];
for (name, schema) in &effects {
assert!(EFFECT_SCHEMA_REGISTRY
.register(*name, schema.clone())
.is_ok());
}
// Remove one effect
let removed = EFFECT_SCHEMA_REGISTRY.remove("azure.policy.audit.removal");
assert!(removed.is_some());
assert!(!EFFECT_SCHEMA_REGISTRY.contains("azure.policy.audit.removal"));
// Verify the removed schema is correct
let removed_schema = removed.unwrap();
assert!(Rc::ptr_eq(&effects[1].1, &removed_schema));
// Other effects should still be present
assert!(EFFECT_SCHEMA_REGISTRY.contains("azure.policy.deny.removal"));
assert!(EFFECT_SCHEMA_REGISTRY.contains("azure.policy.modify.removal"));
}
#[test]
#[cfg(feature = "std")]
fn test_concurrent_effect_schema_access() {
use std::sync::Barrier;
use std::thread;
let barrier = Rc::new(Barrier::new(3));
let mut handles = vec![];
// Test concurrent registration of different Azure Policy effects
let effects = [
"concurrent_effect_schema_access.deny",
"concurrent_effect_schema_access.audit",
"concurrent_effect_schema_access.modify",
];
for (i, effect_name) in effects.iter().enumerate() {
let barrier = Rc::clone(&barrier);
let name: String = (*effect_name).into();
let handle: thread::JoinHandle<Result<(), SchemaRegistryError>> =
thread::spawn(move || {
let schema = match i {
0 => create_deny_effect_schema(),
1 => create_audit_effect_schema(),
2 => create_modify_effect_schema(),
_ => unreachable!(),
};
barrier.wait();
EFFECT_SCHEMA_REGISTRY.register(name, schema)
});
handles.push(handle);
}
// Wait for all threads to complete
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// All registrations should succeed
for result in results {
assert!(result.is_ok());
}
// Should have effect schemas registered
assert!(EFFECT_SCHEMA_REGISTRY.contains("concurrent_effect_schema_access.deny"));
assert!(EFFECT_SCHEMA_REGISTRY.contains("concurrent_effect_schema_access.audit"));
assert!(EFFECT_SCHEMA_REGISTRY.contains("concurrent_effect_schema_access.modify"));
}
+580
View File
@@ -0,0 +1,580 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use super::super::registry::*;
use crate::{
registry::{instances::RESOURCE_SCHEMA_REGISTRY, schemas::resource},
schema::Schema,
*,
};
use serde_json::json;
type String = Rc<str>;
type SchemaRegistryError = RegistryError;
// Helper function to create a schema for Azure Resource types
fn create_resource_schema() -> Rc<Schema> {
let schema_json = json!({
"enum": ["Microsoft.Compute/virtualMachines", "Microsoft.Storage/storageAccounts", "Microsoft.Network/virtualNetworks"],
"description": "Azure Resource types"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
// Helper function to create a virtual machine resource schema
fn create_vm_resource_schema() -> Rc<Schema> {
let schema_json = json!({
"type": "object",
"properties": {
"type": {
"const": "Microsoft.Compute/virtualMachines"
},
"apiVersion": {
"enum": ["2021-03-01", "2021-07-01", "2022-03-01"]
},
"name": {
"type": "string",
"pattern": "^[a-zA-Z0-9-._]{1,64}$"
},
"location": {
"type": "string",
"description": "Azure region where the VM will be deployed"
},
"properties": {
"type": "object",
"properties": {
"hardwareProfile": {
"type": "object",
"properties": {
"vmSize": {
"enum": ["Standard_B1s", "Standard_B2s", "Standard_D2s_v3", "Standard_D4s_v3"]
}
},
"required": ["vmSize"]
},
"osProfile": {
"type": "object",
"properties": {
"computerName": {
"type": "string"
},
"adminUsername": {
"type": "string"
}
},
"required": ["computerName", "adminUsername"]
}
},
"required": ["hardwareProfile", "osProfile"]
}
},
"required": ["type", "apiVersion", "name", "location", "properties"],
"description": "Schema for Azure Virtual Machine resources"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
// Helper function to create a storage account resource schema
fn create_storage_resource_schema() -> Rc<Schema> {
let schema_json = json!({
"type": "object",
"properties": {
"type": {
"const": "Microsoft.Storage/storageAccounts"
},
"apiVersion": {
"enum": ["2021-04-01", "2021-06-01", "2022-05-01"]
},
"name": {
"type": "string",
"pattern": "^[a-z0-9]{3,24}$"
},
"location": {
"type": "string",
"description": "Azure region for the storage account"
},
"sku": {
"type": "object",
"properties": {
"name": {
"enum": ["Standard_LRS", "Standard_GRS", "Standard_RAGRS", "Premium_LRS"]
}
},
"required": ["name"]
},
"kind": {
"enum": ["Storage", "StorageV2", "BlobStorage", "FileStorage", "BlockBlobStorage"]
},
"properties": {
"type": "object",
"properties": {
"accessTier": {
"enum": ["Hot", "Cool"]
},
"encryption": {
"type": "object",
"properties": {
"services": {
"type": "object"
}
}
}
}
}
},
"required": ["type", "apiVersion", "name", "location", "sku", "kind"],
"description": "Schema for Azure Storage Account resources"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
// Helper function to create a network resource schema
fn create_network_resource_schema() -> Rc<Schema> {
let schema_json = json!({
"type": "object",
"properties": {
"type": {
"const": "Microsoft.Network/virtualNetworks"
},
"apiVersion": {
"enum": ["2020-11-01", "2021-02-01", "2021-05-01"]
},
"name": {
"type": "string",
"pattern": "^[a-zA-Z0-9-._]{2,64}$"
},
"location": {
"type": "string",
"description": "Azure region for the virtual network"
},
"properties": {
"type": "object",
"properties": {
"addressSpace": {
"type": "object",
"properties": {
"addressPrefixes": {
"type": "array",
"items": {
"type": "string",
"pattern": "^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$"
}
}
},
"required": ["addressPrefixes"]
},
"subnets": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"properties": {
"type": "object",
"properties": {
"addressPrefix": {
"type": "string",
"pattern": "^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$"
}
},
"required": ["addressPrefix"]
}
},
"required": ["name", "properties"]
}
}
},
"required": ["addressSpace"]
}
},
"required": ["type", "apiVersion", "name", "location", "properties"],
"description": "Schema for Azure Virtual Network resources"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
#[test]
fn test_basic_resource_enum_schema() {
let resource_schema = create_resource_schema();
// Test registration of basic resource enum schema
let result =
RESOURCE_SCHEMA_REGISTRY.register("azure.resource.types.basic", resource_schema.clone());
assert!(result.is_ok());
assert!(RESOURCE_SCHEMA_REGISTRY.contains("azure.resource.types.basic"));
// Verify schema can be retrieved
let retrieved = RESOURCE_SCHEMA_REGISTRY.get("azure.resource.types.basic");
assert!(retrieved.is_some());
assert!(Rc::ptr_eq(&resource_schema, &retrieved.unwrap()));
}
#[test]
fn test_vm_resource_schema() {
let vm_schema = create_vm_resource_schema();
// Test registration of VM resource schema
let result = RESOURCE_SCHEMA_REGISTRY.register("azure.resource.vm.test", vm_schema.clone());
assert!(result.is_ok());
assert!(RESOURCE_SCHEMA_REGISTRY.contains("azure.resource.vm.test"));
// Verify schema structure
// Verify basic functionality - schema retrieval and pointer equality
// The schema should be retrievable and be the same instance
let retrieved = RESOURCE_SCHEMA_REGISTRY.get("azure.resource.vm.test");
assert!(retrieved.is_some());
assert!(Rc::ptr_eq(&vm_schema, &retrieved.unwrap()));
}
#[test]
fn test_storage_resource_schema() {
let storage_schema = create_storage_resource_schema();
// Test registration of storage resource schema
let result =
RESOURCE_SCHEMA_REGISTRY.register("azure.resource.storage.test", storage_schema.clone());
assert!(result.is_ok());
assert!(RESOURCE_SCHEMA_REGISTRY.contains("azure.resource.storage.test"));
// Verify basic functionality - schema retrieval and pointer equality
// The schema should be retrievable and be the same instance
let retrieved = RESOURCE_SCHEMA_REGISTRY.get("azure.resource.storage.test");
assert!(retrieved.is_some());
assert!(Rc::ptr_eq(&storage_schema, &retrieved.unwrap()));
}
#[test]
fn test_network_resource_schema() {
let network_schema = create_network_resource_schema();
// Test registration of network resource schema
let result =
RESOURCE_SCHEMA_REGISTRY.register("azure.resource.network.test", network_schema.clone());
assert!(result.is_ok());
assert!(RESOURCE_SCHEMA_REGISTRY.contains("azure.resource.network.test"));
// Verify basic functionality - schema retrieval and pointer equality
// The schema should be retrievable and be the same instance
let retrieved = RESOURCE_SCHEMA_REGISTRY.get("azure.resource.network.test");
assert!(retrieved.is_some());
assert!(Rc::ptr_eq(&network_schema, &retrieved.unwrap()));
}
#[test]
fn test_multiple_resource_schemas() {
// Register all resource schemas with unique names
let vm_schema = create_vm_resource_schema();
let storage_schema = create_storage_resource_schema();
let network_schema = create_network_resource_schema();
let vm_name = "azure.resource.vm.multiple";
let storage_name = "azure.resource.storage.multiple";
let network_name = "azure.resource.network.multiple";
assert!(RESOURCE_SCHEMA_REGISTRY
.register(vm_name, vm_schema)
.is_ok());
assert!(RESOURCE_SCHEMA_REGISTRY
.register(storage_name, storage_schema)
.is_ok());
assert!(RESOURCE_SCHEMA_REGISTRY
.register(network_name, network_schema)
.is_ok());
// Verify all are registered
assert!(RESOURCE_SCHEMA_REGISTRY.contains(vm_name));
assert!(RESOURCE_SCHEMA_REGISTRY.contains(storage_name));
assert!(RESOURCE_SCHEMA_REGISTRY.contains(network_name));
// Verify they can all be retrieved
assert!(RESOURCE_SCHEMA_REGISTRY.get(vm_name).is_some());
assert!(RESOURCE_SCHEMA_REGISTRY.get(storage_name).is_some());
assert!(RESOURCE_SCHEMA_REGISTRY.get(network_name).is_some());
}
#[test]
fn test_global_resource_registry() {
// Register Azure Resource schemas with unique names
let vm_schema = create_vm_resource_schema();
let storage_schema = create_storage_resource_schema();
let network_schema = create_network_resource_schema();
let vm_name = "azure.resource.vm.global";
let storage_name = "azure.resource.storage.global";
let network_name = "azure.resource.network.global";
assert!(resource::register(vm_name, vm_schema.clone()).is_ok());
assert!(resource::register(storage_name, storage_schema.clone()).is_ok());
assert!(resource::register(network_name, network_schema.clone()).is_ok());
// Verify all are registered in global registry
assert!(resource::contains(vm_name));
assert!(resource::contains(storage_name));
assert!(resource::contains(network_name));
// Test retrieval from global registry
let retrieved_vm = resource::get(vm_name);
let retrieved_storage = resource::get(storage_name);
let retrieved_network = resource::get(network_name);
assert!(retrieved_vm.is_some());
assert!(retrieved_storage.is_some());
assert!(retrieved_network.is_some());
// Verify pointer equality
assert!(Rc::ptr_eq(&vm_schema, &retrieved_vm.unwrap()));
assert!(Rc::ptr_eq(&storage_schema, &retrieved_storage.unwrap()));
assert!(Rc::ptr_eq(&network_schema, &retrieved_network.unwrap()));
}
#[test]
fn test_resource_schema_validation_patterns() {
// Test schema with various Azure Resource patterns
let complex_resource_schema = json!({
"type": "object",
"properties": {
"resources": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"pattern": "^[a-zA-Z0-9]+\\.[a-zA-Z0-9]+/[a-zA-Z0-9]+$"
},
"apiVersion": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
},
"name": {
"type": "string"
},
"location": {
"type": "string"
},
"dependsOn": {
"type": "array",
"items": {
"type": "string"
}
},
"tags": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["type", "apiVersion", "name"]
}
},
"parameters": {
"type": "object"
},
"variables": {
"type": "object"
},
"outputs": {
"type": "object"
}
},
"required": ["resources"],
"description": "Comprehensive Azure Resource Manager template schema"
});
let schema = Schema::from_serde_json_value(complex_resource_schema).unwrap();
let schema_rc = Rc::new(schema);
let result =
RESOURCE_SCHEMA_REGISTRY.register("azure.template.arm.patterns", schema_rc.clone());
assert!(result.is_ok());
assert!(RESOURCE_SCHEMA_REGISTRY.contains("azure.template.arm.patterns"));
// Verify schema retrieval and pointer equality
let retrieved = RESOURCE_SCHEMA_REGISTRY.get("azure.template.arm.patterns");
assert!(retrieved.is_some());
assert!(Rc::ptr_eq(&schema_rc, &retrieved.unwrap()));
}
#[test]
fn test_resource_schema_with_invalid_names() {
let resource_schema = create_resource_schema();
// Test invalid names
assert!(RESOURCE_SCHEMA_REGISTRY
.register("", resource_schema.clone())
.is_err());
assert!(RESOURCE_SCHEMA_REGISTRY
.register(" ", resource_schema.clone())
.is_err());
assert!(RESOURCE_SCHEMA_REGISTRY
.register("\t", resource_schema.clone())
.is_err());
assert!(RESOURCE_SCHEMA_REGISTRY
.register("\n", resource_schema)
.is_err());
}
#[test]
fn test_resource_schema_duplicate_registration() {
let vm_schema = create_vm_resource_schema();
// First registration should succeed
assert!(RESOURCE_SCHEMA_REGISTRY
.register("azure.resource.vm.duplicate", vm_schema.clone())
.is_ok());
// Duplicate registration should fail
let duplicate_result =
RESOURCE_SCHEMA_REGISTRY.register("azure.resource.vm.duplicate", vm_schema);
assert!(duplicate_result.is_err());
// Verify error type
match duplicate_result.unwrap_err() {
SchemaRegistryError::AlreadyExists { name, .. } => {
assert_eq!(name.as_ref(), "azure.resource.vm.duplicate");
}
_ => panic!("Expected AlreadyExists error"),
}
}
#[test]
fn test_azure_resource_removal() {
// Register multiple Azure Resource schemas with unique names
let resources = vec![
("azure.resource.vm.removal", create_vm_resource_schema()),
(
"azure.resource.storage.removal",
create_storage_resource_schema(),
),
(
"azure.resource.network.removal",
create_network_resource_schema(),
),
];
for (name, schema) in &resources {
assert!(RESOURCE_SCHEMA_REGISTRY
.register(*name, schema.clone())
.is_ok());
}
// Remove one resource
let removed = RESOURCE_SCHEMA_REGISTRY.remove("azure.resource.storage.removal");
assert!(removed.is_some());
assert!(!RESOURCE_SCHEMA_REGISTRY.contains("azure.resource.storage.removal"));
// Verify the removed schema is correct
let removed_schema = removed.unwrap();
assert!(Rc::ptr_eq(&resources[1].1, &removed_schema));
// Other resources should still be present
assert!(RESOURCE_SCHEMA_REGISTRY.contains("azure.resource.vm.removal"));
assert!(RESOURCE_SCHEMA_REGISTRY.contains("azure.resource.network.removal"));
}
#[test]
#[cfg(feature = "std")]
fn test_concurrent_resource_schema_access() {
use std::sync::Barrier;
use std::thread;
let barrier = Rc::new(Barrier::new(3));
let mut handles = vec![];
// Test concurrent registration of different Azure Resource schemas
let resources = [
"concurrent_resource_schema_access.vm",
"concurrent_resource_schema_access.storage",
"concurrent_resource_schema_access.network",
];
for (i, resource_name) in resources.iter().enumerate() {
let barrier = Rc::clone(&barrier);
let name: String = (*resource_name).into();
let handle: thread::JoinHandle<Result<(), SchemaRegistryError>> =
thread::spawn(move || {
let schema = match i {
0 => create_vm_resource_schema(),
1 => create_storage_resource_schema(),
2 => create_network_resource_schema(),
_ => unreachable!(),
};
barrier.wait();
RESOURCE_SCHEMA_REGISTRY.register(name, schema)
});
handles.push(handle);
}
// Wait for all threads to complete
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// All registrations should succeed
for result in results {
assert!(result.is_ok());
}
// Should have resource schemas registered
assert!(RESOURCE_SCHEMA_REGISTRY.contains("concurrent_resource_schema_access.vm"));
assert!(RESOURCE_SCHEMA_REGISTRY.contains("concurrent_resource_schema_access.storage"));
assert!(RESOURCE_SCHEMA_REGISTRY.contains("concurrent_resource_schema_access.network"));
}
#[test]
fn test_resource_type_validation() {
// Test different Azure resource types with specific naming patterns
let resource_types = vec![
"azure.compute.vm.validation",
"azure.storage.account.validation",
"azure.network.vnet.validation",
"azure.keyvault.vault.validation",
"azure.sql.database.validation",
"azure.webapp.site.validation",
];
let basic_schema = create_resource_schema();
// Register all resource types
for resource_type in &resource_types {
let result = RESOURCE_SCHEMA_REGISTRY.register(*resource_type, basic_schema.clone());
assert!(result.is_ok(), "Failed to register {resource_type}");
}
// Verify all are registered
for resource_type in &resource_types {
assert!(
RESOURCE_SCHEMA_REGISTRY.contains(resource_type),
"Missing {resource_type}"
);
assert!(
RESOURCE_SCHEMA_REGISTRY.get(resource_type).is_some(),
"Cannot retrieve {resource_type}"
);
}
// Verify list contains all types
let names = RESOURCE_SCHEMA_REGISTRY.list_names();
for resource_type in &resource_types {
assert!(
names.contains(&(*resource_type).into()),
"Name list missing {resource_type}"
);
}
}
+12 -3
View File
@@ -199,7 +199,9 @@ use crate::{format, Box, Rc, Value, Vec};
type String = Rc<str>;
pub mod error;
mod meta;
pub mod validate;
/// A schema represents a type definition that can be used for validation.
///
@@ -305,7 +307,7 @@ impl Schema {
/// Parse a JSON Schema document into a `Schema` instance.
/// Provides better error messages than `serde_json::from_value`.
fn from_serde_json_value(
pub fn from_serde_json_value(
schema: serde_json::Value,
) -> Result<Self, Box<dyn core::error::Error + Send + Sync>> {
let meta_schema_validation_result = meta::validate_schema_detailed(&schema);
@@ -319,7 +321,7 @@ impl Schema {
/// Parse a JSON Schema document from a string into a `Schema` instance.
/// Provides better error messages than `serde_json::from_str`.
fn from_json_str(s: &str) -> Result<Self, Box<dyn core::error::Error + Send + Sync>> {
pub fn from_json_str(s: &str) -> Result<Self, Box<dyn core::error::Error + Send + Sync>> {
let value: serde_json::Value =
serde_json::from_str(s).map_err(|e| format!("Failed to parse schema: {e}"))?;
Self::from_serde_json_value(value)
@@ -1030,4 +1032,11 @@ impl<'de> Deserialize<'de> for DiscriminatedSubobject {
}
#[cfg(test)]
mod tests;
mod tests {
mod azure;
mod suite;
mod validate {
mod effect;
mod resource;
}
}
+276
View File
@@ -0,0 +1,276 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use crate::*;
type String = Rc<str>;
/// Validation errors that can occur when validating a Value against a Schema.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
/// Value type does not match the expected schema type.
TypeMismatch {
expected: String,
actual: String,
path: String,
},
/// Numeric value is outside the allowed range.
OutOfRange {
value: String,
min: Option<String>,
max: Option<String>,
path: String,
},
/// String length constraint violation.
LengthConstraint {
actual_length: usize,
min_length: Option<usize>,
max_length: Option<usize>,
path: String,
},
/// String does not match required pattern.
PatternMismatch {
value: String,
pattern: String,
path: String,
},
/// Array size constraint violation.
ArraySizeConstraint {
actual_size: usize,
min_items: Option<usize>,
max_items: Option<usize>,
path: String,
},
/// Required object property is missing.
MissingRequiredProperty { property: String, path: String },
/// Object property failed validation.
PropertyValidationFailed {
property: String,
path: String,
error: Box<ValidationError>,
},
/// Additional properties are not allowed.
AdditionalPropertiesNotAllowed { property: String, path: String },
/// Value is not in the allowed enum values.
NotInEnum {
value: String,
allowed_values: Vec<String>,
path: String,
},
/// Value does not match the required constant.
ConstMismatch {
expected: String,
actual: String,
path: String,
},
/// Value does not match any schema in a union (anyOf).
NoUnionMatch {
path: String,
errors: Vec<ValidationError>,
},
/// Invalid regex pattern in schema.
InvalidPattern { pattern: String, error: String },
/// Array item validation failed.
ArrayItemValidationFailed {
index: usize,
path: String,
error: Box<ValidationError>,
},
/// Object key is not a string.
NonStringKey { key_type: String, path: String },
/// Missing discriminator field in discriminated subobject.
MissingDiscriminator { discriminator: String, path: String },
/// Unknown discriminator value in discriminated subobject.
UnknownDiscriminatorValue {
discriminator: String,
value: String,
allowed_values: Vec<String>,
path: String,
},
/// Discriminated subobject validation failed.
DiscriminatedSubobjectValidationFailed {
discriminator: String,
value: String,
path: String,
error: Box<ValidationError>,
},
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValidationError::TypeMismatch {
expected,
actual,
path,
} => {
write!(
f,
"Type mismatch at '{path}': expected {expected}, got {actual}"
)
}
ValidationError::OutOfRange {
value,
min,
max,
path,
} => {
let range_desc = match (min, max) {
(Some(min), Some(max)) => format!("between {min} and {max}"),
(Some(min), None) => format!("at least {min}"),
(None, Some(max)) => format!("at most {max}"),
(None, None) => "within valid range".to_string(),
};
write!(
f,
"Value {value} at '{path}' is out of range: must be {range_desc}"
)
}
ValidationError::LengthConstraint {
actual_length,
min_length,
max_length,
path,
} => {
let constraint_desc = match (min_length, max_length) {
(Some(min), Some(max)) => format!("between {min} and {max} characters"),
(Some(min), None) => format!("at least {min} characters"),
(None, Some(max)) => format!("at most {max} characters"),
(None, None) => "within valid length".to_string(),
};
write!(
f,
"String length {actual_length} at '{path}' violates constraint: must be {constraint_desc}"
)
}
ValidationError::PatternMismatch {
value,
pattern,
path,
} => {
write!(
f,
"String '{value}' at '{path}' does not match pattern '{pattern}'"
)
}
ValidationError::ArraySizeConstraint {
actual_size,
min_items,
max_items,
path,
} => {
let constraint_desc = match (min_items, max_items) {
(Some(min), Some(max)) => format!("between {min} and {max} items"),
(Some(min), None) => format!("at least {min} items"),
(None, Some(max)) => format!("at most {max} items"),
(None, None) => "within valid size".to_string(),
};
write!(
f,
"Array size {actual_size} at '{path}' violates constraint: must have {constraint_desc}"
)
}
ValidationError::MissingRequiredProperty { property, path } => {
write!(f, "Missing required property '{property}' at '{path}'")
}
ValidationError::PropertyValidationFailed {
property,
path,
error,
} => {
write!(
f,
"Property '{property}' at '{path}' failed validation: {error}"
)
}
ValidationError::AdditionalPropertiesNotAllowed { property, path } => {
write!(
f,
"Additional property '{property}' not allowed at '{path}'"
)
}
ValidationError::NotInEnum {
value,
allowed_values,
path,
} => {
let values_json = serde_json::to_string(&allowed_values)
.unwrap_or_else(|_| format!("{allowed_values:?}"));
write!(
f,
"Value '{value}' at '{path}' is not in allowed enum values: {values_json}",
)
}
ValidationError::ConstMismatch {
expected,
actual,
path,
} => {
write!(
f,
"Constant mismatch at '{path}': expected '{expected}', got '{actual}'"
)
}
ValidationError::NoUnionMatch { path, errors } => {
write!(
f,
"Value at '{path}' does not match any schema in union. Errors: {errors:?}"
)
}
ValidationError::InvalidPattern { pattern, error } => {
write!(f, "Invalid regex pattern '{pattern}': {error}")
}
ValidationError::ArrayItemValidationFailed { index, path, error } => {
write!(
f,
"Array item {index} at '{path}' failed validation: {error}"
)
}
ValidationError::NonStringKey { key_type, path } => {
write!(
f,
"Object key at '{path}' must be a string, but found {key_type}"
)
}
ValidationError::MissingDiscriminator {
discriminator,
path,
} => {
write!(
f,
"Missing discriminator field '{discriminator}' at '{path}'"
)
}
ValidationError::UnknownDiscriminatorValue {
discriminator,
value,
allowed_values,
path,
} => {
let values_json: Vec<serde_json::Value> = allowed_values
.iter()
.map(|v| serde_json::Value::String(v.to_string()))
.collect();
write!(
f,
"Unknown discriminator value '{value}' for field '{discriminator}' at '{path}'. Allowed values: {}",
serde_json::to_string(&values_json).unwrap_or_else(|_| format!("{values_json:?}"))
)
}
ValidationError::DiscriminatedSubobjectValidationFailed {
discriminator,
value,
path,
error,
} => {
write!(
f,
"Discriminated subobject validation failed for discriminator '{discriminator}' with value '{value}' at '{path}': {error}"
)
}
}
}
}
impl core::error::Error for ValidationError {}
+1
View File
@@ -3,3 +3,4 @@
mod azure;
mod suite;
mod validate;
File diff suppressed because it is too large Load Diff
+511
View File
@@ -0,0 +1,511 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use crate::{
schema::{error::ValidationError, validate::SchemaValidator, Schema},
*,
};
use serde_json::json;
// Helper function to create a schema for Azure Policy effects
fn create_effect_schema() -> Rc<Schema> {
let schema_json = json!({
"enum": ["audit", "deny", "disabled", "modify"],
"description": "Azure Policy effect types"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
// Helper function to create a deny effect schema
fn create_deny_effect_schema() -> Rc<Schema> {
let schema_json = json!({
"type": "object",
"properties": {
"effect": {
"const": "deny"
},
"description": {
"type": "string",
"description": "Explanation of what is being denied"
}
},
"required": ["effect"],
"description": "Schema for deny effect in Azure Policy"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
// Helper function to create an audit effect schema
fn create_audit_effect_schema() -> Rc<Schema> {
let schema_json = json!({
"type": "object",
"properties": {
"effect": {
"const": "audit"
},
"description": {
"type": "string",
"description": "Explanation of what is being audited"
},
"auditDetails": {
"type": "object",
"properties": {
"category": {
"enum": ["security", "compliance", "cost", "operational"]
},
"severity": {
"enum": ["low", "medium", "high", "critical"]
}
}
}
},
"required": ["effect"],
"description": "Schema for audit effect in Azure Policy"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
// Helper function to create a modify effect schema
fn create_modify_effect_schema() -> Rc<Schema> {
let schema_json = json!({
"type": "object",
"properties": {
"effect": {
"const": "modify"
},
"description": {
"type": "string",
"description": "Explanation of what is being modified"
},
"modifyDetails": {
"type": "object",
"properties": {
"roleDefinitionIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of role definition IDs required for modification"
},
"operations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"operation": {
"enum": ["add", "replace", "remove"]
},
"field": {
"type": "string"
},
"value": {
"type": "any",
"description": "Value to add or replace"
}
},
"required": ["operation", "field"]
}
}
},
"required": ["roleDefinitionIds", "operations"]
}
},
"required": ["effect", "modifyDetails"],
"description": "Schema for modify effect in Azure Policy"
});
let schema = Schema::from_serde_json_value(schema_json).unwrap();
Rc::new(schema)
}
// Schema validation tests for Azure Policy effects
#[test]
fn test_validate_deny_effect_valid() {
let schema = create_deny_effect_schema();
let valid_deny_data = json!({
"effect": "deny",
"description": "Deny resources that don't meet security requirements"
});
let value = Value::from(valid_deny_data);
let result = SchemaValidator::validate(&value, &schema);
assert!(result.is_ok());
}
#[test]
fn test_validate_deny_effect_missing_required() {
let schema = create_deny_effect_schema();
let invalid_deny_data = json!({
"description": "Missing required effect field"
});
let value = Value::from(invalid_deny_data);
let result = SchemaValidator::validate(&value, &schema);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::MissingRequiredProperty { property, .. } => {
assert_eq!(property, "effect".into());
}
other => panic!("Expected MissingRequiredProperty error, got: {:?}", other),
}
}
#[test]
fn test_validate_deny_effect_wrong_const() {
let schema = create_deny_effect_schema();
let invalid_deny_data = json!({
"effect": "audit",
"description": "Wrong effect type"
});
let value = Value::from(invalid_deny_data);
let result = SchemaValidator::validate(&value, &schema);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::PropertyValidationFailed {
property, error, ..
} => {
assert_eq!(property, "effect".into());
match error.as_ref() {
ValidationError::ConstMismatch {
expected, actual, ..
} => {
assert_eq!(*expected, "\"deny\"".into());
assert_eq!(*actual, "\"audit\"".into());
}
other => panic!(
"Expected ConstMismatch error in nested structure, got: {:?}",
other
),
}
}
other => panic!("Expected PropertyValidationFailed error, got: {:?}", other),
}
}
#[test]
fn test_validate_audit_effect_valid() {
let schema = create_audit_effect_schema();
let valid_audit_data = json!({
"effect": "audit",
"description": "Audit non-compliant resources",
"auditDetails": {
"category": "security",
"severity": "high"
}
});
let value = Value::from(valid_audit_data);
let result = SchemaValidator::validate(&value, &schema);
assert!(result.is_ok());
}
#[test]
fn test_validate_audit_effect_invalid_enum() {
let schema = create_audit_effect_schema();
let invalid_audit_data = json!({
"effect": "audit",
"description": "Audit with invalid category",
"auditDetails": {
"category": "invalid_category",
"severity": "medium"
}
});
let value = Value::from(invalid_audit_data);
let result = SchemaValidator::validate(&value, &schema);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::PropertyValidationFailed {
property, error, ..
} => {
assert_eq!(property, "auditDetails".into());
match error.as_ref() {
ValidationError::PropertyValidationFailed {
property: inner_prop,
error: inner_error,
..
} => {
assert_eq!(*inner_prop, "category".into());
match inner_error.as_ref() {
ValidationError::NotInEnum { .. } => {
// Expected nested error structure
}
other => panic!(
"Expected NotInEnum error in nested structure, got: {:?}",
other
),
}
}
other => panic!(
"Expected nested PropertyValidationFailed error, got: {:?}",
other
),
}
}
other => panic!("Expected PropertyValidationFailed error, got: {:?}", other),
}
}
#[test]
fn test_validate_modify_effect_valid() {
let schema = create_modify_effect_schema();
let valid_modify_data = json!({
"effect": "modify",
"description": "Modify resources to add required tags",
"modifyDetails": {
"roleDefinitionIds": [
"/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
],
"operations": [
{
"operation": "add",
"field": "tags.Environment",
"value": "Production"
}
]
}
});
let value = Value::from(valid_modify_data);
let result = SchemaValidator::validate(&value, &schema);
assert!(result.is_ok());
}
#[test]
fn test_validate_modify_effect_missing_required_details() {
let schema = create_modify_effect_schema();
let invalid_modify_data = json!({
"effect": "modify",
"description": "Missing modifyDetails"
});
let value = Value::from(invalid_modify_data);
let result = SchemaValidator::validate(&value, &schema);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::MissingRequiredProperty { property, .. } => {
assert_eq!(property, "modifyDetails".into());
}
other => panic!("Expected MissingRequiredProperty error, got: {:?}", other),
}
}
#[test]
fn test_validate_modify_effect_invalid_operation() {
let schema = create_modify_effect_schema();
let invalid_modify_data = json!({
"effect": "modify",
"description": "Invalid operation type",
"modifyDetails": {
"roleDefinitionIds": ["role-id-1"],
"operations": [
{
"operation": "invalid_op",
"field": "tags.Environment",
"value": "Production"
}
]
}
});
let value = Value::from(invalid_modify_data);
let result = SchemaValidator::validate(&value, &schema);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::PropertyValidationFailed {
property, error, ..
} => {
assert_eq!(property, "modifyDetails".into());
match error.as_ref() {
ValidationError::PropertyValidationFailed {
property: inner_prop,
error: inner_error,
..
} => {
assert_eq!(*inner_prop, "operations".into());
match inner_error.as_ref() {
ValidationError::ArrayItemValidationFailed {
index,
error: array_error,
..
} => {
assert_eq!(*index, 0);
match array_error.as_ref() {
ValidationError::PropertyValidationFailed {
property: op_prop,
error: op_error,
..
} => {
assert_eq!(*op_prop, "operation".into());
match op_error.as_ref() {
ValidationError::NotInEnum { .. } => {
// Expected deeply nested error structure
}
other => panic!("Expected NotInEnum error in operation validation, got: {:?}", other),
}
}
other => panic!(
"Expected PropertyValidationFailed for operation, got: {:?}",
other
),
}
}
other => {
panic!("Expected ArrayItemValidationFailed error, got: {:?}", other)
}
}
}
other => panic!(
"Expected nested PropertyValidationFailed error, got: {:?}",
other
),
}
}
other => panic!("Expected PropertyValidationFailed error, got: {:?}", other),
}
}
#[test]
fn test_validate_basic_effect_enum() {
let schema = create_effect_schema();
// Test all valid enum values
let valid_effects = ["audit", "deny", "disabled", "modify"];
for effect in valid_effects {
let value = Value::from(effect);
let result = SchemaValidator::validate(&value, &schema);
assert!(result.is_ok(), "Effect '{effect}' should be valid");
}
// Test invalid enum value
let invalid_value = Value::from("invalid_effect");
let result = SchemaValidator::validate(&invalid_value, &schema);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::NotInEnum { .. } => {
// Expected error type
}
other => panic!("Expected NotInEnum error, got: {:?}", other),
}
}
#[test]
fn test_validate_complex_azure_policy_effect() {
let complex_schema_json = json!({
"type": "object",
"properties": {
"effect": {
"enum": ["auditIfNotExists", "deployIfNotExists"]
},
"parameters": {
"type": "object",
"additionalProperties": { "type": "any" }
},
"existenceCondition": {
"type": "object",
"properties": {
"field": { "type": "string" },
"equals": { "type": "string" }
},
"required": ["field"],
"additionalProperties": { "type": "any" }
},
"deployment": {
"type": "object",
"properties": {
"properties": {
"type": "object",
"properties": {
"mode": {
"enum": ["incremental", "complete"]
},
"template": {
"type": "object",
"additionalProperties": { "type": "any" }
},
"parameters": {
"type": "object",
"additionalProperties": { "type": "any" }
}
},
"required": ["mode", "template"],
"additionalProperties": { "type": "any" }
}
},
"required": ["properties"],
"additionalProperties": { "type": "any" }
}
},
"required": ["effect"],
"additionalProperties": { "type": "any" }
});
let schema = Schema::from_serde_json_value(complex_schema_json).unwrap();
let valid_complex_data = json!({
"effect": "deployIfNotExists",
"parameters": {},
"existenceCondition": {
"field": "Microsoft.Security/complianceResults/resourceStatus",
"equals": "OffByPolicy"
},
"deployment": {
"properties": {
"mode": "incremental",
"template": {
"$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"resources": []
},
"parameters": {}
}
}
});
let value = Value::from(valid_complex_data);
let result = SchemaValidator::validate(&value, &schema);
assert!(result.is_ok());
}
#[test]
fn test_validate_effect_type_mismatch() {
let schema = create_deny_effect_schema();
// Pass a non-object value to object schema
let invalid_data = Value::from("not an object");
let result = SchemaValidator::validate(&invalid_data, &schema);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::TypeMismatch {
expected, actual, ..
} => {
assert_eq!(expected, "object".into());
assert_eq!(actual, "string".into());
}
other => panic!("Expected TypeMismatch error, got: {:?}", other),
}
}
File diff suppressed because it is too large Load Diff
+731
View File
@@ -0,0 +1,731 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
#![allow(dead_code)]
use crate::{
schema::{error::ValidationError, Schema, Type},
*,
};
use alloc::collections::BTreeMap;
use regex::Regex;
type String = Rc<str>;
/// Validator for checking if a Value conforms to a Schema.
pub struct SchemaValidator;
impl SchemaValidator {
/// Validates a Value against a Schema.
///
/// # Arguments
/// * `value` - The Value to validate
/// * `schema` - The Schema to validate against
///
/// # Returns
/// * `Ok(())` if the value conforms to the schema
/// * `Err(ValidationError)` if validation fails
///
/// # Example
/// ```rust
/// use regorus::schema::{Schema, validate::SchemaValidator};
/// use regorus::Value;
/// use serde_json::json;
///
/// let schema_json = json!({
/// "type": "string",
/// "minLength": 1,
/// "maxLength": 10
/// });
/// let schema = Schema::from_serde_json_value(schema_json).unwrap();
/// let value = Value::from("hello");
///
/// let result = SchemaValidator::validate(&value, &schema);
/// assert!(result.is_ok());
/// ```
pub fn validate(value: &Value, schema: &Schema) -> Result<(), ValidationError> {
Self::validate_with_path(value, schema, "")
}
/// Internal validation function that tracks the current path for error reporting.
fn validate_with_path(
value: &Value,
schema: &Schema,
path: &str,
) -> Result<(), ValidationError> {
match schema.as_type() {
Type::Any { .. } => {
// Any type accepts all values
Ok(())
}
Type::Integer {
minimum, maximum, ..
} => Self::validate_integer(value, *minimum, *maximum, path),
Type::Number {
minimum, maximum, ..
} => Self::validate_number(value, *minimum, *maximum, path),
Type::Boolean { .. } => Self::validate_boolean(value, path),
Type::Null { .. } => Self::validate_null(value, path),
Type::String {
min_length,
max_length,
pattern,
..
} => Self::validate_string(value, *min_length, *max_length, pattern.as_ref(), path),
Type::Array {
items,
min_items,
max_items,
..
} => Self::validate_array(value, items, *min_items, *max_items, path),
Type::Object {
properties,
required,
additional_properties,
discriminated_subobject,
..
} => Self::validate_object(
value,
properties,
required.as_ref().map(|r| &**r),
additional_properties.as_ref(),
discriminated_subobject.as_ref().map(|d| &**d),
path,
),
Type::AnyOf(schemas) => Self::validate_any_of(value, schemas, path),
Type::Const {
value: const_value, ..
} => Self::validate_const(value, const_value, path),
Type::Enum { values, .. } => Self::validate_enum(value, values, path),
Type::Set { items, .. } => Self::validate_set(value, items, path),
}
}
fn validate_integer(
value: &Value,
minimum: Option<i64>,
maximum: Option<i64>,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::Number(num) => {
if let Some(int_val) = num.as_i64() {
if let Some(min) = minimum {
if int_val < min {
return Err(ValidationError::OutOfRange {
value: int_val.to_string().into(),
min: Some(min.to_string().into()),
max: maximum.map(|m| m.to_string().into()),
path: path.to_string().into(),
});
}
}
if let Some(max) = maximum {
if int_val > max {
return Err(ValidationError::OutOfRange {
value: int_val.to_string().into(),
min: minimum.map(|m| m.to_string().into()),
max: Some(max.to_string().into()),
path: path.into(),
});
}
}
Ok(())
} else {
Err(ValidationError::TypeMismatch {
expected: "integer".into(),
actual: "non-integer number".into(),
path: path.into(),
})
}
}
_ => Err(ValidationError::TypeMismatch {
expected: "integer".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_number(
value: &Value,
minimum: Option<f64>,
maximum: Option<f64>,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::Number(num) => {
if let Some(float_val) = num.as_f64() {
if let Some(min) = minimum {
if float_val < min {
return Err(ValidationError::OutOfRange {
value: float_val.to_string().into(),
min: Some(min.to_string().into()),
max: maximum.map(|m| m.to_string().into()),
path: path.into(),
});
}
}
if let Some(max) = maximum {
if float_val > max {
return Err(ValidationError::OutOfRange {
value: float_val.to_string().into(),
min: minimum.map(|m| m.to_string().into()),
max: Some(max.to_string().into()),
path: path.to_string().into(),
});
}
}
Ok(())
} else {
Err(ValidationError::TypeMismatch {
expected: "number".into(),
actual: "non-numeric value".into(),
path: path.into(),
})
}
}
_ => Err(ValidationError::TypeMismatch {
expected: "number".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_boolean(value: &Value, path: &str) -> Result<(), ValidationError> {
match value {
Value::Bool(_) => Ok(()),
_ => Err(ValidationError::TypeMismatch {
expected: "boolean".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_null(value: &Value, path: &str) -> Result<(), ValidationError> {
match value {
Value::Null => Ok(()),
_ => Err(ValidationError::TypeMismatch {
expected: "null".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_string(
value: &Value,
min_length: Option<usize>,
max_length: Option<usize>,
pattern: Option<&String>,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::String(string_value) => {
let str_len = string_value.len();
// Check length constraints
if let Some(min) = min_length {
if str_len < min {
return Err(ValidationError::LengthConstraint {
actual_length: str_len,
min_length: Some(min),
max_length,
path: path.into(),
});
}
}
if let Some(max) = max_length {
if str_len > max {
return Err(ValidationError::LengthConstraint {
actual_length: str_len,
min_length,
max_length: Some(max),
path: path.into(),
});
}
}
// Check pattern constraint
if let Some(pattern_str) = pattern {
let regex =
Regex::new(pattern_str).map_err(|e| ValidationError::InvalidPattern {
pattern: pattern_str.as_ref().into(),
error: e.to_string().into(),
})?;
if !regex.is_match(string_value) {
return Err(ValidationError::PatternMismatch {
value: string_value.to_string().into(),
pattern: pattern_str.clone(),
path: path.into(),
});
}
}
Ok(())
}
_ => Err(ValidationError::TypeMismatch {
expected: "string".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_array(
value: &Value,
items_schema: &Schema,
min_items: Option<usize>,
max_items: Option<usize>,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::Array(array_value) => {
let arr_len = array_value.len();
// Check size constraints
if let Some(min) = min_items {
if arr_len < min {
return Err(ValidationError::ArraySizeConstraint {
actual_size: arr_len,
min_items: Some(min),
max_items,
path: path.into(),
});
}
}
if let Some(max) = max_items {
if arr_len > max {
return Err(ValidationError::ArraySizeConstraint {
actual_size: arr_len,
min_items,
max_items: Some(max),
path: path.into(),
});
}
}
// Validate each item
for (index, item) in array_value.iter().enumerate() {
Self::validate_with_path(
item,
items_schema,
&if path.is_empty() {
format!("[{index}]")
} else {
format!("{path}[{index}]")
},
)
.map_err(|e| {
ValidationError::ArrayItemValidationFailed {
index,
path: path.into(),
error: Box::new(e),
}
})?;
}
Ok(())
}
_ => Err(ValidationError::TypeMismatch {
expected: "array".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_object(
value: &Value,
properties: &BTreeMap<String, Schema>,
required: Option<&Vec<String>>,
additional_properties: Option<&Schema>,
discriminated_subobject: Option<&crate::schema::DiscriminatedSubobject>,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::Object(object_value) => {
// Check required properties
if let Some(required_props) = required {
for required_prop in required_props.iter() {
if !object_value.contains_key(&Value::String(required_prop.clone())) {
return Err(ValidationError::MissingRequiredProperty {
property: required_prop.clone(),
path: path.into(),
});
}
}
}
// Handle discriminated subobjects (allOf with if/then)
// Validates against the appropriate variant schema based on discriminator field value
if let Some(discriminated_subobject) = discriminated_subobject {
Self::validate_discriminated_subobject_with_base(
object_value,
discriminated_subobject,
properties,
additional_properties,
path,
)?;
} else {
// Only validate regular object properties if no discriminated subobject exists
// Validate each property
for (prop_name, prop_value) in object_value.iter() {
// First, ensure the property key is a string
let prop_name_str = match prop_name {
Value::String(string_key) => string_key,
_ => {
return Err(ValidationError::NonStringKey {
key_type: Self::value_type_name(prop_name),
path: path.into(),
});
}
};
// Create property path lazily using a closure
let make_prop_path = || {
if path.is_empty() {
format!("[{prop_name_str}]")
} else {
format!("{path}.{prop_name_str}")
}
};
if let Some(prop_schema) = properties.get(prop_name_str) {
// Property is defined in schema, validate against it
Self::validate_with_path(prop_value, prop_schema, &make_prop_path())
.map_err(|e| ValidationError::PropertyValidationFailed {
property: prop_name_str.clone(),
path: path.into(),
error: Box::new(e),
})?;
} else if let Some(additional_schema) = additional_properties {
// Property is not defined but additional properties are allowed
Self::validate_with_path(
prop_value,
additional_schema,
&make_prop_path(),
)
.map_err(|e| {
ValidationError::PropertyValidationFailed {
property: prop_name_str.clone(),
path: path.into(),
error: Box::new(e),
}
})?;
} else {
// Property is not defined and additional properties are not allowed
return Err(ValidationError::AdditionalPropertiesNotAllowed {
property: prop_name_str.clone(),
path: path.into(),
});
}
}
}
Ok(())
}
_ => Err(ValidationError::TypeMismatch {
expected: "object".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_any_of(
value: &Value,
schemas: &Vec<Schema>,
path: &str,
) -> Result<(), ValidationError> {
let mut errors = Vec::new();
for schema in schemas {
match Self::validate_with_path(value, schema, path) {
Ok(()) => return Ok(()), // If any schema matches, validation succeeds
Err(e) => errors.push(e),
}
}
// If no schema matched, return error with all validation attempts
Err(ValidationError::NoUnionMatch {
path: path.into(),
errors,
})
}
fn validate_const(
value: &Value,
const_value: &Value,
path: &str,
) -> Result<(), ValidationError> {
if value == const_value {
Ok(())
} else {
let expected_json =
serde_json::to_string(const_value).unwrap_or_else(|_| format!("{const_value:?}"));
let actual_json = serde_json::to_string(value).unwrap_or_else(|_| format!("{value:?}"));
Err(ValidationError::ConstMismatch {
expected: expected_json.into(),
actual: actual_json.into(),
path: path.into(),
})
}
}
fn validate_enum(
value: &Value,
allowed_values: &[Value],
path: &str,
) -> Result<(), ValidationError> {
if allowed_values.contains(value) {
Ok(())
} else {
// Convert Value to JSON string, fallback to debug format if JSON serialization fails
let value_json = serde_json::to_string(value).unwrap_or_else(|_| format!("{value:?}"));
let allowed_json: Vec<String> = allowed_values
.iter()
.map(|v| {
serde_json::to_string(v)
.unwrap_or_else(|_| format!("{v:?}"))
.into()
})
.collect();
Err(ValidationError::NotInEnum {
value: value_json.into(),
allowed_values: allowed_json,
path: path.into(),
})
}
}
fn validate_set(
value: &Value,
items_schema: &Schema,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::Set(set_value) => {
// Validate each item in the set
for (index, item) in set_value.iter().enumerate() {
Self::validate_with_path(
item,
items_schema,
&if path.is_empty() {
format!("{{{index}}}]")
} else {
format!("{path}{{{index}}}]")
},
)?;
}
Ok(())
}
_ => Err(ValidationError::TypeMismatch {
expected: "set".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_discriminated_subobject_with_base(
object_value: &BTreeMap<Value, Value>,
discriminated_subobject: &crate::schema::DiscriminatedSubobject,
base_properties: &BTreeMap<String, Schema>,
base_additional_properties: Option<&Schema>,
path: &str,
) -> Result<(), ValidationError> {
let discriminator_field = &discriminated_subobject.discriminator;
let discriminator_key = Value::String(discriminator_field.clone());
// Find the discriminator field value in the object
let discriminator_value = object_value.get(&discriminator_key).ok_or_else(|| {
ValidationError::MissingDiscriminator {
discriminator: discriminator_field.clone(),
path: path.into(),
}
})?;
// Extract the string value from the discriminator field
let discriminator_str = match discriminator_value {
Value::String(string_value) => string_value.as_ref(),
_ => {
return Err(ValidationError::TypeMismatch {
expected: "string".into(),
actual: Self::value_type_name(discriminator_value),
path: format!("{path}.{discriminator_field}").into(),
});
}
};
// Find the corresponding variant schema
let variant_schema = discriminated_subobject
.variants
.get(discriminator_str)
.ok_or_else(|| ValidationError::UnknownDiscriminatorValue {
discriminator: discriminator_field.clone(),
value: discriminator_str.into(),
allowed_values: discriminated_subobject.variants.keys().cloned().collect(),
path: path.into(),
})?;
// Validate all properties against the appropriate schemas
for (prop_name, prop_value) in object_value.iter() {
// First, ensure the property key is a string
let prop_name_str = match prop_name {
Value::String(string_key) => string_key,
_ => {
return Err(ValidationError::NonStringKey {
key_type: Self::value_type_name(prop_name),
path: path.into(),
});
}
};
// Create property path lazily using a closure
let make_prop_path = || {
if path.is_empty() {
format!("[{prop_name_str}]")
} else {
format!("{path}.{prop_name_str}")
}
};
// Check if this property is defined in the variant schema first
if variant_schema.properties.get(prop_name_str).is_some() {
// Validate later in subobject.
continue;
}
// Check if this property is defined in the base schema properties
if let Some(prop_schema) = base_properties.get(prop_name_str) {
// Property is defined in base schema, validate against it
Self::validate_with_path(prop_value, prop_schema, &make_prop_path()).map_err(
|e| ValidationError::PropertyValidationFailed {
property: prop_name_str.clone(),
path: path.into(),
error: Box::new(e),
},
)?;
continue;
}
// Check if additional properties are allowed in the variant
if variant_schema.additional_properties.is_some() {
// Property is not defined but additional properties are allowed in variant.
// Validate later.
continue;
} else if let Some(base_additional) = base_additional_properties {
// Check if additional properties are allowed in the base schema
Self::validate_with_path(prop_value, base_additional, &make_prop_path()).map_err(
|e| ValidationError::PropertyValidationFailed {
property: prop_name_str.clone(),
path: path.into(),
error: Box::new(e),
},
)?;
} else {
// Property is not defined and additional properties are not allowed
return Err(ValidationError::AdditionalPropertiesNotAllowed {
property: prop_name_str.clone(),
path: path.into(),
});
}
}
// Validate the object against the variant schema for required properties
Self::validate_subobject(object_value, variant_schema, path).map_err(|e| {
ValidationError::DiscriminatedSubobjectValidationFailed {
discriminator: discriminator_field.clone(),
value: discriminator_str.into(),
path: path.into(),
error: Box::new(e),
}
})
}
fn validate_subobject(
object_value: &BTreeMap<Value, Value>,
subobject: &crate::schema::Subobject,
path: &str,
) -> Result<(), ValidationError> {
// Check required properties from the subobject
if let Some(required_props) = &subobject.required {
for required_prop in required_props.iter() {
if !object_value.contains_key(&Value::String(required_prop.clone())) {
return Err(ValidationError::MissingRequiredProperty {
property: required_prop.clone(),
path: path.into(),
});
}
}
}
// Validate each property in the subobject
for (prop_name, prop_schema) in subobject.properties.iter() {
let prop_key = Value::String(prop_name.clone());
if let Some(prop_value) = object_value.get(&prop_key) {
Self::validate_with_path(
prop_value,
prop_schema,
&if path.is_empty() {
format!("[{prop_name}]")
} else {
format!("{path}.{prop_name}")
},
)
.map_err(|e| ValidationError::PropertyValidationFailed {
property: prop_name.clone(),
path: path.into(),
error: Box::new(e),
})?;
}
}
// Handle additional properties if specified
if let Some(additional_schema) = &subobject.additional_properties {
for (prop_name, prop_value) in object_value.iter() {
if let Value::String(prop_name_str) = prop_name {
if !subobject.properties.contains_key(prop_name_str) {
Self::validate_with_path(
prop_value,
additional_schema,
&if path.is_empty() {
format!("[{prop_name_str}]")
} else {
format!("{path}.{prop_name_str}")
},
)
.map_err(|e| {
ValidationError::PropertyValidationFailed {
property: prop_name_str.clone(),
path: path.into(),
error: Box::new(e),
}
})?;
}
}
}
}
Ok(())
}
fn value_type_name(value: &Value) -> String {
match value {
Value::Null => "null".into(),
Value::Bool(_) => "boolean".into(),
Value::Number(_) => "number".into(),
Value::String(_) => "string".into(),
Value::Array(_) => "array".into(),
Value::Set(_) => "set".into(),
Value::Object(_) => "object".into(),
Value::Undefined => "undefined".into(),
}
}
}